[DS] 2. 클래스와 구조체
클래스와 구조체의 개념, 접근 지정자, 생성자 / 소멸자, 메모리 정렬에 따른 패딩 현상 등.
클래스(Class)와 구조체(Struct)?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 구조체
struct MyPerson
{
public:
const char* name = "SDTR";
int birthYear = 1999;
};
// 클래스
class MyInfoViewer
{
public:
MyInfoViewer(MyPerson* person){Player = person;}
~MyInfoViewer(){}
protected:
MyPerson* Player;
public:
void PrintInfo() { /* ... */ }
};
다양한 자료형의 데이터와 함수를 한데 묶을 때 사용하는 방법.
구조체와 클래스는 본질적으로 같다. 기본 접근제어자가 public이냐, private이냐의 차이. 관례적으로 구조체는 데이터를 모아 새로운 자료형으로써 쓰기 위한 성격이 강하고, 클래스는 기능이 포함된 객체의 성격이 강하다.
다만 이는 C++에 한정된 이야기이고, C# 등과 같은 다른 언어에서는 큰 차이가 존재하니 주의.
접근 지정자?
멤버에 대한 접근 지정자는 public, protected, private 세 종류가 존재한다.
- public : 해당 객체 내 외부 모두 자유롭게 접근 가능하다.
구조체(struct)의 기본 접근지정자이다.
- protected : 해당 객체를 상속받는 자식 객체들까지가 접근 가능하다.
상속 관계에서 자식들은 쓸 수 있으면서도, 외부에서는 은닉화가 필요할 때 사용된다.
- private : 해당 객체 자신만이 접근 가능하다.
클래스(class)의 기본 접근지정자이다. 객체 자신만이 접근 가능하게 하고 싶을 때 사용된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 접근 지정자를 전부 직접 지정해줬다면 구조체나 클래스나 똑같다.
struct MyParent
{
public: int iPublic = 1;
protected: int iProtected = 2;
private: int iPrivate = 3;
virtual void DummyFunction()
{
iPublic; // 접근 가능.
iProtected; // 접근 가능.
iPrivate; // 접근 가능.
// ...
}
};
struct MyChild : public MyParent // MyParent를 부모로써 상속
{
void DummyFunction() override
{
iPublic; // 접근 가능.
iProtected; // 접근 가능. (MyParent의 자식이므로)
iPrivate; // 접근 불가! (MyParent 자신이 아니므로)
// ...
}
};
int main()
{
MyChild chd = MyChild();
chd.iPublic; // 접근 가능.
chd.iProtected; // 접근 불가! (MyParent 자신/자식이 아닌 외부에서 접근 시도하므로)
chd.iPrivate; // 접근 불가! (MyParent 자신이 아니므로)
}
생성자와 소멸자
생성자는 객체가 생성될 때, 소멸자는 객체가 소멸될 때 자동으로 호출된다.
이를 통해 생성자 호출 시엔 멤버 변수의 초기화를, 소멸자 호출 시엔 내부적으로 동적 생성했던 객체의 메모리를 해제하는 등의 응용이 가능하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MyClass
{
public:
MyClass(int i) : num(i) { std::cout << "Constructed:" << num << std::endl; }
~MyClass() { std::cout << "Destructed:" << num << std::endl; }
private:
int num = 0;
};
int main()
{
MyClass myclass1 = MyClass(1); // 일반 객체 생성. 생성자(1) 호출!
MyClass* myclass2 = new MyClass(2); // 동적 객체 생성. 생성자(2) 호출!
delete myclass2; // 동적 객체 삭제. 소멸자(2) 호출!
} // 종료or스코프를 벗어날 시 일반 객체 삭제.
// └─ 소멸자(1) 호출!
1
2
3
4
Constructed:1
Constructed:2
Destructed:2
Destructed:1
일반 생성자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MyClass
{
public:
MyClass(int iInput, float fInput) : i(iInput), f(fInput) {};
// MyClass(int iInput, float fInput) {i = iInput; f = fInput;};
// MyClass(int iInput, float fInput) : i(iInput) {f = fInput;}; // 3개 다 된다.
private:
int i = 0;
float f = 1.5f;
};
int main()
{
MyClass myclass1 = {1, 2.5f};
MyClass myclass2 = MyClass(1, 2.5f);
}
복사 생성자
복사 생성자를 직접 정의해주지 않으면, 컴파일러는 디폴트 복사 생성자를 임의로 생성한다.
하지만 이 디폴트 복사 생성자는 동적 객체의 경우 얕은 복사만을 수행하기에, 복사 생성자 호출시 같은 값의 새 객체를 만드는 것이 아닌, 복사 대상이 가리키고 있던 동적 메모리 주소(포인터 값)만을 그대로 복사하게 된다.
이 경우 두 객체가 소멸할 시, 이미 한번 해제된 메모리 영역에 대해 한번 더 해제를 시도하게 되고, 이는 런타임 에러를 발생시킨다.
아래는 복사 생성자 정의를 통해 이를 방지하는 예시이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class MyArray
{
public:
// 일반 생성자
MyArray(int size) : m_Size(size)
{
m_Data = new int[m_Size]; // 동적 객체 생성
}
// 디폴트 복사 생성자
// MyArray(const MyArray& other)
// {
// this->m_Data = other.m_Data;
// this0>m_Size = other.m_Size;
// }
// 복사 생성자 (깊은 복사)
MyArray(const MyArray& other) : m_Size(other.m_Size)
{
m_Data = new int[m_Size];
for (int i = 0; i < m_Size; ++i)
m_Data[i] = other.m_Data[i];
}
~MyArray()
{
delete[] m_Data; // 동적 객체 소멸
}
private:
int* m_Data = nullptr;
int m_Size = 0;
};
int main()
{
MyArray a(5);
MyArray b = a; // 복사 생성자 호출
// MyArray b(a);
}
복사 대입 연산자
복사 생성자가 새로운 객체를 초기화할 때 호출된다면, 복사 대입 연산자는 이미 생성되어 있는 두 객체 간에 대입이 일어날 때 호출된다.
복사 생성자와 마찬가지로, 컴파일러가 만들어내는 디폴트 대입 연산자는 얕은 복사를 수행하므로, 중복 해제와 같은 문제를 방지하기 위해 직접 오버로딩해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class MyArray
{
public:
// 일반 생성자
MyArray(int size) : m_Size(size) {}
{
m_Data = new int[m_Size]; // 동적 객체 생성
}
// 복사 생성자
MyArray(const MyArray& other) : m_Size(other.m_Size)
{
m_Data = new int[m_Size];
for (int i = 0; i < m_Size; ++i)
m_Data[i] = other.m_Data[i];
}
// 디폴트 복사 대입 연산자
// MyArray& operator=(const MyArray& other)
// {
// this->m_Data = other.m_Data; // 얕은 복사
// this->m_Size = other.m_Size;
// return *this;
// }
// 복사 대입 연산자 (깊은 복사)
MyArray& operator=(const MyArray& other)
{
if (this == &other) // 1. 자기 대입 방지
return *this;
delete[] m_Data; // 2. 값을 받기 전, 본인이 들고 있던 기존 객체 해제
m_Size = other.m_Size; // 3. 새 객체 생성 후 값을 복사
m_Data = new int[m_Size];
for (int i = 0; i < m_Size; ++i)
m_Data[i] = other.m_Data[i];
return *this;
}
~MyArray()
{
delete[] m_Data; // 동적 객체 소멸
}
private:
int* m_Data = nullptr;
int m_Size = 0;
};
int main()
{
MyArray a(5);
MyArray b(10);
b = a; // "복사 대입 연산자" 호출
// 추가로, 선언과 동시에 대입 시에는 "복사 생성자"가 호출된다.
MyArray c = a; // 복사 생성자 호출!
}
클래스와 구조체 패딩
가장 큰 기본 타입 멤버에 맞게 메모리 정렬이 일어나는 특성상, 클래스 및 구조체 내부의 멤버 변수 선언 순서에 따라 메모리 구조에 패딩이 생길 수 있다.
패딩은 많을 수록 불필요하게 차지하는 공간도 많아지기에 메모리 효율이 떨어진다.
1
2
3
4
5
6
7
8
typedef struct tagPlayerType
{
int iPlayerID; // 4byte 1
int iAtkTypeID; // 4byte 2
bool isAtkable; // 1byte ─┐ 3
bool isDefable; // 1byte ─┘
float fHitpoint; // 4byte 4
}PLAYER_TYPE;
예시로 만든 구조체이다. 이 구조체의 메모리 레이아웃을 확인해보면 아래와 같다. 가장 큰 기본 타입이 4byte이므로, 이에 맞게 정렬되는 모습이다.
1
2
3
4
5
6
7
8
typedef struct tagPlayerType
{
int iPlayerID;
int iAtkTypeID;
bool isAtkable;
float fHitpoint; // ─┐
bool isDefable; // ─┘ 순서 변경
}PLAYER_TYPE;
이번엔 의도적으로 순서를 비틀어보았다. 메모리 구조가 비효율적으로 바뀐 모습이다.


댓글
불러오는 중...