Post

[DS] 2. 클래스와 구조체

클래스와 구조체의 개념, 접근 지정자, 생성자 / 소멸자, 메모리 정렬에 따른 패딩 현상 등.

[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이므로, 이에 맞게 정렬되는 모습이다.

2026-08-16-11-53-37 객체 하나 당 16byte만큼의 공간을 차지한다.

1
2
3
4
5
6
7
8
typedef struct tagPlayerType
{
  int iPlayerID;   
  int iAtkTypeID;   
  bool isAtkable;  
  float fHitpoint; // ─┐
  bool isDefable;  // ─┘ 순서 변경
}PLAYER_TYPE;

이번엔 의도적으로 순서를 비틀어보았다. 메모리 구조가 비효율적으로 바뀐 모습이다.

2026-08-16-11-54-01 16byte만 먹던게 이제는 20byte를 먹는다.

This post is licensed under CC BY 4.0 by the author.

댓글

불러오는 중...