관리 메뉴

기억을 위한 기록들

[Fundamental C++] 6. 클래스 (1) - 메모리구조, 크기 본문

C & CPP/Fundamental C++

[Fundamental C++] 6. 클래스 (1) - 메모리구조, 크기

에드윈H 2021. 4. 29. 16:30

클래스 이전에 구조체가 있었다. 구조체 멤버의 기본 접근 지정자는 public이고 클래스의 접근 지정자는 private으로 되어있다. 왜 그런 걸까? 하고 보면 원래 C언어에서 먼저 구조체가 만들어졌고, 접근 지정자의 개념이 없었다. 그러다 C++ 이 추가되고 캡슐화 및 정보은닉의 개념을 구현하기 위해 도입된 것이다. 

구조체는 처음부터 외부에서 자유롭게 접근되었기에 기본 접근 지정자는 public인것이고 클래스는 private인 것이다.

 

 

 

 

클래스의 메모리 구조

클래스를 이해한다는 것은 클래스의 메모리 구조를 이해하고 컴파일러가 어떻게 그 구조를 이용하는지를 아는 것이 거의 전부라고 할 수 있다.

 

어떤 클래스로 예를 보자

class CTest
{
public:

     int mInt;
     char mChar;
     
     static int m_SInt;
     
     
     int GetInt()
     {
        return mInt;
     }
};

해당 클래스는 2개의 일반 멤버 변수 2개와 정적 멤버 변수(m_SInt), 멤버 함수 GetInt()가 있다.

위 클래스로 만들어진 객체가 생긴다고 하면 메모리의 구조는

 

mInt(4Byte)
mChar(1Byte)

이런 식으로 생기게 되고 멤버 변수의 순서대로 메모리에도 생기게 된다. 그런데 메모리 구조에 정적 멤버 변수와 함수는 보이지 않는다.

 

정적 멤버 변수는 여러 개의 객체가 생긴다고 해도 오직 하나만 존재하게 된다. 클래스 안에 정의되어 있지만, 전역 변수와 같다고 할 수 있다.

 

그리고 GetInt함수는 메모리의 코드 영역에 위치하여 CPU에 의해 실행된다. 따라서 클래스 객체의 메모리 영역에 있을 필요가 없다.

 

 

클래스가 정의되면 클래스의 일반 멤버 변수는 해당 변수가 클래스의 메모리 시작 위치에서 얼마나 떨어져서 위치하게 되는지를 나타내고, 오프셋(Offset) 정보를 갖게 된다. 각 변수에 대한 오프셋 정보는 컴파일러에 의해 기억된다.

CTest 클래스를 보면 (mInt, 0), (mChar, 4)라는 정보(mChar가 4인 것은 mInt는 4바이트이기에 0부터 4위 치 뒤인 4)를 컴파일러가 기억한다.

 

예를 들어

CTest a;

a.mChar = 'A';

라는 구문은 a객체 메모리의 시작 위치를 구한 후 mChar이 나타내는 오프셋 주소인 4를 더하여 해당 메모리에 접근하여 'A'라는 문자를 복사하는 것이다. 정리하면 멤버 변수는 오프셋 정보를 이용하여 메모리 연산을 하는 것뿐이다.

클래스에서 멤버 변수를 생각할 때는 오프셋도 함께 생각하자는 것이다.

 

 

클래스 멤버변수 오프셋 크기 출력하는 방법(double변수 추가)

#include <iostream>

using namespace std;
class CTest
{
public:
	int mInt;
	char mChar;
	double mDouble;
};

int main()
{
	int offSetInt = (int)(&(((CTest*)0)->mInt));
	int offSetmChar = (int)(&(((CTest*)0)->mChar));
	int offSetmDouble = (int)(&(((CTest*)0)->mDouble));


	cout << offSetInt << endl;
	cout << offSetmChar << endl;
	cout << offSetmDouble << endl;
	return 0;
}

실행결과

하지만 뭔가 이상하다 크기대로라면 int는 0이 맞고, char도 4가 맞는데 char가 1바이트라서 double은 5가 나와야 하는데 8이 나온 것이다.

 

이것은 컴파일러가 최적화한 것인데, 문제가 가끔 될 때도 있지만, 대부분은 문제없이 처리된다.

일반적으로 멤버 변수는 오프셋이 변수  타입 크기의 배수일 때, CPU의 메모리 접근 횟수가 최소화되어 최적의 성능을 내는 것으로 알려져 있다. 그러나 이런 식으로 배치하게 되면 낭비되는 빈 공간이 생기게 되며, 특히 변수 타입의 크기가 너무 클 경우 낭비되는 공간도 함께 커지게 되는 것이다.

 

따라서 가능하면 오프셋이 타입 크기의 배수가 되도록 설정을 하지만, 너무 커지지 않도록 최댓값을 둔다.

 

 

 

기본 타입 중 가장 큰 변수의 크기는 double이나 64비트 정수를 나타내는 _int64처럼 모두 8비트이다. 따라서 컴파일러는 멤버 변수를 메모리에 위치시킬 때 오프셋이 변수 타입 크기와 8중 작은 쪽의 최소 배수가 되도록 조정을 하게 된다.

 

즉, 0/4/8이 나온 것은 

mInt(4Byte)
mChar(1Byte)
No Use(3Bye)
mDouble(8Byte)

이런 게 되며 mChar는 원래 1바이트이나, 가장 작은 바이트의 타입인(mInt)와 8중에 최소 배수인 4의 크기로 맞춰지게 되며, mDouble의 오프셋이 8이 되는 것이다.

이것은 컴파일러 옵션에서 설정 가능하다.

관련 링크 :

docs.microsoft.com/ko-kr/cpp/build/reference/zp-struct-member-alignment?view=msvc-160

 

/Zp(구조체 멤버 맞춤)

/Zp(구조체 멤버 맞춤)에 대한 자세한 정보

docs.microsoft.com

 

 

 

 

 

클래스의 크기

 

클래스 크기는 sizeof함수로 쉽게 확인 가능하며, 멤버가 기본 타입이면 쉽게 크기를 구하겠지만, 멤버가 클래스 타입일 경우 재귀적으로 멤버 클래스의 크기를 구해야 한다.  오래 걸릴 것 같지만 sizeof는 컴파일 타임에 이미 계산되어 나오기에 성능에는 아무런 영향이 없다.

 

특수한 경우의 클래스 크기가 있는데, 

 

class A
{
public:
	A();
	~A();
};


class B
{
public:
	B();
	virtual ~B(); //가상소멸자로 선언
};

두 개의 클래스 모두 멤버 객체가 없다. 대신에 B클래스는 가상 함수 소멸자, 가상 소멸자로 선언되어 있다.

 

우선 아무런 멤버 객체가 없다면 사이즈가 0이 나와야 하지 않을까? 하지만 그렇지 않다. 0의 크기를 갖고 있지만, 0의 크기를 메모리에서 차지할 순 없다. 그래서 C++에서는 컴파일러가 크기가 0이 되는 클래스에 대해 1바이트 크기의 멤버를 추가하게 된다.

 

int main()
{
	A a;

	cout << sizeof(a) << endl;
	
	return 0;
}

A클래스 객체는 1이 나온다. 하지만 B도 그럴까? 그렇다면 물어보지 않았을 것이다.

B클래스는 시스템에 따라서 다를 수도 있지만, 4 혹인 8이 나오게 된다. (4는 x86, 8은 x64)

 

그 이유는 바로 가상 함수가 하나라도 추가하게 되면, 가상 함수를 위한 가상 함수 테이블이 생성되고 클래스에는 해당 테이블을 가리키는 vfptr이라는 가상 함수 테이블 포인터가 추가되기 때문이다. 포인터는 시스템에 따라 4 혹은 8의 크기가 나오게 되는 것이다.

 

결과 :

 

#include <iostream>

using namespace std;
class A
{
public:
	A()
	{}
	~A()
	{}
};


class B
{
public:
	B()
	{}
	virtual ~B()
	{}
};
int main()
{
	A a;
	B b;

	cout << sizeof(a) << endl;
	cout << sizeof(b) << endl;

	return 0;
}

B클래스는 가상 함수 테이블 포인터가 있기에 크기가 4가 출력이 되는 것을 확인할 수 있다.