공부/C++

동적 할당, 동적 할당 연산자, 배열 동적 할당, nullptr 초기화 이유, const 위치 차이점, 얕은 복사, 깊은 복사, 복사 생성자, 복사 대입 연산자

월러비 2025. 6. 20. 19:33

짧은 설명

  • 클래스와 구조체의 차이 : 기본적인 접근지정자
    • 구조체 : public
    • 클래스 : private
  • 생성자 / 소멸자
    • 객체가 생성될때 동작 : 생성자
    • 객체가 소멸될떄 동작 : 소멸자
    • 생성자 또는 소멸자를 작성하지 않더라도 객체가 생성될때 아무 동작도 안하는 생성자 또는 소멸자가 생성된다.
  • 멤버 이니셜라이저 : 클래스에 선언한 멤버 변수를 초기화하는 것이다.
    • 생성자가 호출될때 같이 동작한다.
  • 정적 멤버 : 클래스 단위로 하나만 존재하는 멤버다.
    • 프로그램이 종료될떄까지 메모리에 남아있는다.
    • 클래스를 기준으로 접근해야한다.
      • 클래스이름::멤버이름
  • 상수형 메서드 : 읽기 전용 함수에 더 제약을 걸어서 함수 호출시에 변수의 값이 바뀔 걱정을 줄여준다.
    • 왠만하면 const 붙여서 함수를 붙이는게 좋다.
  • 전역 함수는 이항 연산자 중에서 좌우에 의해 함수를 하나 더 만들어야하는 경우에 작성한다.

동적 할당

  • 할당 : 메모리에 올리는것을 의미한다.
  • 정적 할당 : 객체선언, 변수선언, 매개변수가 있는 함수 호출 등 코드를 작성할 떄 메모리에 올라가는 순간이 정해지는 할당이다.
    • 코드에 메모리에 생성과 소멸이 이미 정해지는 할당이다.
    • 전역 변수 : 프로그램 시작 - 생성 , 종료 - 소멸

동적 할당

  • 프로그램 실행 중에 필요한 만큼 메모리를 할당하고 해제하는 방법
  • 힙(heap) 영역에 메모리 할당
    • 스택 메모리 : 정적 할당 (매개 변수 등)
    • 힙 메모리 : 할당과 지우는것을 직접 지정할 수 있는 메모리다. (전역 변수 등)
  • 컴파일 시점이 아닌 런타임에 메모리 크기 결정 가능

C++의 동적 할당 연산자

  • new: 메모리 할당
  • delete: 메모리 해제
  • new[]: 배열 동적 할당 - new + [] 자체가 연산자 하나다.
  • delete[]: 배열 메모리 해제 - delete + [] 자체가 연산자 하나다.
int* a = new int(5);
//a = new int(10); //이전에 할당한 주소와의 연결이 끊긴다. 
// 이전 메모리는 어디선가 계속 남아있다. => 메모리 릭(누수)

cout << a << endl;    // 할당된 메모리 주소 출력
cout << *a << endl;   // 값 출력: 5
*a = 10;             // 값 변경
cout << a << endl;    // 주소는 동일
cout << *a << endl;   // 변경된 값 출력: 10

delete a;            // 메모리 해제
  • new int(5): int형 메모리를 할당하고 5로 초기화
    • (생성자 인자에 넘겨줄 값)
    • 동일한 데이터형이면 무엇이든 넣을 수 있다.
    • new를 시키면 delete를 어디선가 반드시 해줘야한다.
      • 안하면 어디선가 문제가 생긴다.
      • 메모리 릭(누수) 라고 한다.
  • 포인터 a는 할당된 메모리의 주소를 저장
  • a로 할당된 메모리의 값에 접근
  • delete a로 할당된 메모리 반납
    • 삭제한 메모리에 *포인터로 접근하게 된다면 프로그램이 오류가 생겨 정지한다.
  • 주의: delete 후에 포인터를 사용하면 안된다!

배열 동적 할당

  • 실행 시간에 배열 크기 결정 가능
  • 큰 배열을 힙에 할당하여 스택 오버플로우 방지
  • 함수 종료 후에도 데이터 유지 가능
자료형* 포인터 = new 자료형[크기];
delete[] 포인터;
  • 배열은 반드시 new[]와 delete[] 쌍으로 사용해야한다.
  • delete가 아닌 delete[] 사용한다!
int count = 5;
//int array[count]; //불가능 / 정적 할당 배열은 인자로 상수가 와야한다.
int* a = new int[count]; //가능

cout << a[0] << endl;
cout << *(a + 0) << endl; //위와 같은 결과다.

delete a[count];            // 메모리 해제
class Vector2 {
public:
    Vector2() : x(0), y(0) {
        cout << this << " : Vector2()" << endl;
    }
    Vector2(float x, float y) : x(x), y(y) {
        cout << this << " : Vector2(float, float)" << endl;
    }
    ~Vector2() {
        cout << this << " : ~Vector2()" << endl;
    }

    float GetX() const { return x; }
    float GetY() const { return y; }

private:
    float x, y;
};

int main() {
    cout << "main 시작" << endl;

    // 정적 할당된 객체
    Vector2 s1;
    Vector2 s2(2, 3);

    // 동적 할당된 객체
    Vector2* d1 = new Vector2;
    Vector2* d2 = new Vector2(4, 5);

    cout << "(" << d1->GetX() << ", " << d1->GetY() << ")" << endl;
    cout << "(" << d2->GetX() << ", " << d2->GetY() << ")" << endl;

    delete d1;  // 소멸자 호출됨
    delete d2;  // 소멸자 호출됨

    cout << "main 끝" << endl;
}
  • 정적 객체: 선언과 동시에 생성자 호출
  • 동적 객체: new 연산자 실행 시 생성자 호출
    • 객체 동적 할당 시 생성자 자동 호출
  • delete 실행 시 소멸자 호출
    • delete 시 소멸자 자동 호출
  • 정적 객체는 main 함수 종료 시 자동으로 소멸자 호출
    • 정적 할당의 소멸은 선언의 역순으로 소멸한다.
    • 객체 1, 객체2 선언 → 객체 2, 객체 1 순으로 소멸한다.
  • 화살표 연산자(>)로 멤버 접근

nullptr 초기화는 왜 해줘야하는가?

  • 포인터형은 메모리 주소를 저장한다.
    • 이게 유용한 주소인지 소멸한 주소인지 사용자는 모른다.
  • 제일 간단한 유효 포인터 체크 방법 : nullptr → 널 체크 방법이다.
    • NULL(매크로) / 0 으로 쓰기도 한다.
    • 포인터는 보통 nullptr로 쓴다.
    • 제대로 된 주소가 들어있지 않다고 표현하기 위해 사용한다.
  • 배열 == nullptr 로 체크한다.
    • 이걸 그래서 왜 하는가?
    • delete 체크를 위해서다.
      • 이미 삭제된 메모리를 또 삭제하기 위해 접근하면 프로그램이 정지한다.

이차원 배열 동적 할당

  • 첫번쨰 배열 포인터 : 행
  • 그 배열 안에 배열들을 넣는다 : 열

const 위치에 따른 차이점

  • const int* ptr = #
    • int* ptr2 = #
    • *ptr1 = 20; ⇒ 불가능
    • 데이터형 변경이 불가능해진다.
    • 상수도 저장하는게 가능해진다.
      • ptr = 10;
  • int* const ptr = #
    • 초기화된 주소가 변경이 되지 않게된다.
  • const int* const ptr1 = #
    • 주소도 못바꾸고, 읽기만 가능해진다.

동적할당 정리

  • new 연산자 : 메모리 할당하는 연산
    • int* a = new int(5)
    • a 메모리 먼저 생성 → int 바이트의 메모리 생성 → 5 할당 → a 메모리에 해당 int 메모리의 주소 할당
  • delete : 할당된 메모리 해제
    • 변수 a는 프로그램 종료시 메모리 소멸
    • int 메모리 소멸 : delete 호출시 소멸
    • 변수 a 에 할당한 주소는 남아있는다 - > 접근하면 프로그램 오류로 정지한다.
      • nullptr로 초기화하거나 다른 주소를 할당해야한다.

객체 복사

얕은 복사 / 깊은 복사

  • 얕은 복사 : 참조 복사, 레퍼런스 복사
    • 메모리를 그대로 가져온 것이다.
  • 깊은 복사 : 메모리 자체가 동일한 값이 되도록 복사
    • 메모리를 받을 빈 메모리를 생성한 다음에 복사하는 것이다.

얕은 복사(Shallow Copy)의 문제점

  • 포인터 멤버 변수의 주소값만 복사
  • 두 객체가 같은 메모리를 가리킴
  • 한 객체 소멸 시 다른 객체의 포인터가 댕글링 포인터가 됨

깊은 복사(Deep Copy)의 필요성

  • 실제 데이터까지 복사하여 독립적인 메모리 확보
  • 각 객체가 자신만의 메모리 영역 소유
  • 안전한 객체 복사와 소멸 보장
class String {
public:
    String() {
        strData = nullptr;
        len = 0;
    }
    String(const char* str) {
        len = strlen(str);
        strData = new char[len + 1]; //+1 : null문자까지 포함해서 문자배열을 만든다는 의미다.
        strcpy(strData, str); //문자열 복사
    }
    ~String() {
        if (strData) { //nullptr인 경우 false가 나온다.
            delete[] strData; 
        }
    }

    const char* GetStrData() const {
        if (strData) return strData;
        return ""; //nullptr이면 빈 문자열을 반환하는 것이다.
    }
    int GetLen() const {
        return len;
    }

private:
    char* strData;
    int len;
};

int main() {
    String s1;
    String s2("Hello");
    
    String s3 = s2; //문제점

    cout << s1.GetLen() << endl;      // 0
    cout << s1.GetStrData() << endl;  // (빈 문자열)
    cout << s2.GetLen() << endl;      // 5
    cout << s2.GetStrData() << endl;  // Hello
}

  • 동적으로 문자열 메모리 할당
  • 생성자에서 할당, 소멸자에서 해제
  • nullptr 체크로 안전한 메모리 관리

문제점

  • 할당 연산자는 바이트 대 바이트로 진행된다.
    • new를 하면 어딘가에 메모리가 만들어지고 할당되고 있는 것이다.
    • 이것을 변수 메모리에 할당하고 다른 변수 메모리에 넣는것이다.
    • s2소멸할때 동적 할당된 배열 메모리도 삭제된다.
    • s3은 s2와 같은 메모리 주소를 가지고있는데 s3이 메모리에 접근해서 사용하려한다면 이미 삭제 되어있는 메모리 주소이기 때문에 오류가 발생한다. - 얕은 복사의 문제점이다.
      • 복사 생성자가 필요하다.

복사 생성자

  • 즉, 본인 자체를 레퍼런스로 매개변수를 받는 생성자다.
class String {
public:
    String() {
        strData = NULL;
        len = 0;
    }
    String(const char* str) {
        len = strlen(str);
        strData = new char[len + 1];
        strcpy(strData, str);
    }
    String(const String& rhs) {  // 복사 생성자
        len = rhs.len;
        strData = new char[len + 1]; //이때 새로운 메모리가 할당되기에 원본이 삭제되어도 안전하다.
        strcpy(strData, rhs.strData);
    }
    ~String() {
        if (strData) {
            delete[] strData;
        }
    }

    const char* GetStrData() const {
        if (strData) return strData;
        return "";
    }
    int GetLen() const {
        return len;
    }

private:
    char* strData;
    int len;
};

int main() {
    String s1("Hello");
    String s2 = s1;  // String s2(s1);과 같음. 복사 생성자가 호출된다.

    cout << s1.GetStrData() << endl;  // Hello
    cout << s2.GetStrData() << endl;  // Hello
}

복사 생성자의 역할:

  • 객체 생성 시 다른 객체로 초기화할 때 호출
  • 깊은 복사 구현으로 독립적인 메모리 확보
  • const String& 매개변수로 원본 보호
  • 복사 생성자 없이 작동 되었던 이유 : 기본 복사 생성자가 자동으로 생성되기 때문이다.
    • 기본 복사 생성자 역할 : 메모리 대 메모리 복사를 해주는 역할이다.

아까와 다른점

  • 아까는 동일한 주소를 복사했다 - 얕은 복사
  • 동적할당한 배열의 메모리를 어딘가에 메모리를 만든 다음 값을 해당 메모리에 복사한다.
    • 그 후 그 메모리의 주소를 할당한다.
    • 결과 : 원본의 배열 메모리가 삭제되어도 프로그램 오류가 발생하지 않는다.

복사 생성자 왜 해야한다.

  • 깊은 복사를 구현하기 위해 사용한다.
  • 복사 생성자는 단지 기존의 원본은 레퍼런스로 받아오기 위해 사용하는 것이다.

복사 대입 연산자 (대입연산자 오버로드)

class String {
public:
    String() {
        strData = NULL;
        len = 0;
    }
    String(const char* str) {
        len = strlen(str);
        strData = new char[len + 1];
        strcpy(strData, str);
    }
    String(const String& rhs) {  // 복사 생성자
        len = rhs.len;
        strData = new char[len + 1];
        strcpy(strData, rhs.strData);
    }
    ~String() {
        if (strData) {
            delete[] strData;
        }
    }

    String& operator=(const String& rhs) {  // 복사 대입 연산자
        if (this != &rhs) {  // 자기 자신과의 대입 검사 / 내가 나를 할당하는지 확인
            len = rhs.len;
            delete[] strData;  // 기존 메모리 해제 / 안지우면 기존 메모리가 메모리 릭 걸린다.
            strData = new char[len + 1]; //새로 할당
            strcpy(strData, rhs.strData);
        }
        return *this;
    }

    const char* GetStrData() const {
        if (strData) return strData;
        return "";
    }
    int GetLen() const {
        return len;
    }

private:
    char* strData;
    int len;
};

int main() {
    String s1("Hello");
    String s2("World");
    s2 = s1;  // 복사 대입 연산자 호출
    cout << s1.GetStrData() << endl;  // Hello
    cout << s2.GetStrData() << endl;  // Hello
}

  • 생성자 : 객체가 만들어지는 시점
  • 대입 연산자 : 얕은 복사를 안하기 위해 깊은 복사하기 위해 사용한다.
    • 대입 연산자로 덮어 쓰려는 메모리가 이미 사용되어있을 가능성이 있다.
    • 방법 : 이미 사용하고 있는 메모리를 정리하고 새로운 메모리를 받아야한다.
  • 이미 존재하는 객체에 다른 객체를 대입할 때 호출
  • 자기 자신과의 대입 검사 필수 (if (this != &rhs))
  • 기존 메모리 해제 후 새로운 메모리 할당
  • this 반환으로 연쇄 대입 가능
    • 대입하고 있는 데이터형과 반환되는 데이터형이 같아야한다.
    • a = 10; ⇒ a를 리턴하는 것이다.
    • (a = 10) = 20; ⇒ ()가 먼저 수행되고, a가 반환되고 있기 때문에 에러 없이 동작하는 것이다.
      • 결과 : a == 20

결론

  • 깊은 복사를 한다면 대입 연산자를 반드시 오버로드 해줘야한다.