짧은 설명
- 클래스와 구조체의 차이 : 기본적인 접근지정자
- 구조체 : 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
결론
- 깊은 복사를 한다면 대입 연산자를 반드시 오버로드 해줘야한다.