0. 비어있는 구조체, 클래스

struct EmptyStruct
{

};

class EmptyClass
{
public:
    EmptyClass();
    ~EmptyClass();

private:
	void test();
};

위 구조체와 클래스의 크기를 확인해보면 몇 바이트가 할당될까? 정답은 각각 1바이트다.

C++에서 빈 구조체나 클래스에 1바이트가 할당되는 이유는, 객체마다 고유한 주소를 가져야 하기 때문이다.

만약 크기가 0이라면, 여러 개의 빈 객체를 생성해도 전부 같은 주소를 가리키게 될 수 있다. 이는 객체 지향 관점에서 매우 위험한 상황이며, Undefined behavior를 일으킬 수 있다.

그래서 C++은 강제로 최소 바이트인 1바이트를 부여해서 각 객체가 다른 메모리 주소를 가지도록 보장한다. 최소 크기를 배정함으로써 컴파일러가 주소 기반으로 객체를 구분할 수 있다.

0-1. 멤버 함수는 왜 메모리 크기를 차지하지 않을까?

위 예시의 EmptyStruct는 정말 아무것도 들어있지 않으니 그럴 수 있다 치자(사실 컴파일러가 몰래 생성자, 소멸자를 만들긴 할 것이다). 하지만 EmptyClass는 내부에 생성자, 소멸자, 그리고 test라는 함수가 존재한다. 그런데 왜 1바이트밖에 차지하지 않을까? (함수) 포인터의 크기는 8바이트인데 말이다(x64).

일반 멤버 함수는 클래스별로 고정된 코드 영역에 존재하기 때문이다.

struct Foo
{
	void hello() { std::cout << "Hello\n"; }
};

int main()
{
	Foo f;
	f.hello();
	return 0;
}

위 코드가 실행되는 과정을 살펴보자.

  1. 컴파일 단계
    • f.hello()Foo::hello(&f)와 같은 형태로 처리된다.
    • 컴파일러는 Foo::hello가 정적 함수일 경우(virtual 함수가 아닐 경우), call Foo::hello 형태의 심볼 참조를 포함하는 어셈블리 코드를 생성한다.
  2. 어셈블리 단계
    • 어셈블러는 어셈블리 코드를 기계어로 변환하지만, 함수 호출 등의 심볼은 해결되지 않은 상태로 남는다.
    • 결과적으로 .o 파일에는 심볼 테이블이 함께 포함된다.
  3. 링크 단계
    • 링커는 각 목적 파일(.o)의 심볼 테이블을 분석하고, 함수의 실제 주소(또는 offset)를 계산한다.
    • 이 값을 이용해 call 명령의 인자를 완전히 채워 넣어, 실행 가능한 기계어가 완성된다.

0-2. 그렇다면 virtual 함수는 메모리를 차지할까?

#include <iostream>

struct Empty {};

struct NormalFunc
{
	void foo() {}
};

struct VirtualFunc
{
	virtual void foo() {}
};

int main()
{
	std::cout << "sizeof(Empty) = " << sizeof(Empty) << '\n';
	std::cout << "sizeof(NormalFunc) = " << sizeof(NormalFunc) << '\n';
	std::cout << "sizeof(VirtualFunc) = " << sizeof(VirtualFunc) << '\n';
}

/*
출력 결과 (x64 기준)

sizeof(Empty) = 1
sizeof(NormalFunc) = 1
sizeof(VirtualFunc) = 1
*/

virtual function은 런타임에 vtable을 참조하여 실제 실행할 함수를 결정한다. 이를 위해 객체 메모리 영역에 vptr이 추가되며, x64 시스템 기준 8바이트의 메모리 공간을 차지한다.

1. EBO(Empty Base Optimization)

객체의 주소를 구분하기 위해서 1바이트가 할당된다는 것은 이해했다. 하지만 아무 의미도 없는 클래스에 1바이트를 할당하자니 메모리가 낭비된다. 이를 위해 컴파일러는 EBO라는 컴파일러 최적화 방법을 제공한다.

EBO : 빈 클래스를 상속하면, 그 빈 클래스는 크기를 0으로 처리할 수 있다. 상속이 아닌, 멤버 변수로 넣을 경우는 그대로 1바이트가 할당된다.

class Empty 
{
};

class NotOptimized
{
	int data;
	Empty empty; // 빈 클래스지만 1바이트를 차지함
};
class Empty 
{
};

class Optimized : Empty
{
	int data;
};

// => sizeof(Optimized) : 4 Byte
// 같은 정보를 더 적은 메모리를 활용해 담을 수 있다.

std::function, std::tuple, std::allocator 등 템플릿 클래스에서 Policy 클래스나 Allocator처럼 빈 타입을 상속해서 공간 낭비를 줄이는 데 사용한다.

예를 들어, std::tuple<int, Empty>의 크기는 sizeof(int)와 결과가 같다.

주의할 점

  • EBO는 빈 클래스만 상속했을 때만 최적화된다.
  • 여러 번 상속하거나 다중 상속에서는 제한이 생길 수 있다(컴파일러마다 다르다).
  • C++ 표준이 아닌 컴파일러의 최적화 전략이라 100% 보장되지는 않는다.

2. 왜 빈 클래스를 상속받을까?

왜 아무 의미도 없어 보이는 빈 클래스를 상속받을까? 대표적인 경우를 살펴보자.

2-1. Policy Class

템플릿에서 동작을 커스터마이즈하고 싶을 때 사용한다.

struct NoLock
{
	void lock() {}
	void unlock() {}
};

template <typename LockPolicy = NoLock>
class Data : private LockPolicy
{
public:
	void Access()
	{
		this->lock(); //LockPolicy::lock()
		// ... do something
		this->unlock();
	}
}
  • NoLock은 아무 동작도 안 하지만, 인터페이스는 유지한다.
  • EBO 덕분에 크기 추가가 없다.
  • 필요하면 MutexLock같은 걸 넘겨서 기능 확장이 가능하다.

2-2. Tag Class

타입 구분 용도로만 사용된다. (보통 dynamic_cast, type_traits, SFINAE 등에 활용)

struct serializableTag {};

class MyClass : public SerializableTag
{
	// 직렬화 가능한 클래스임을 표시
	// \* 직렬화(Serialization) : 데이터를 디스크에 저장하거나 네트워크 통신에 
	// 사용하기 위한 형식으로 변환하는 것을 말한다.
};
  • 그냥 타입 구분용으로만 사용된다.
  • 이때도 상속이 필요하지만, 메모리는 낭비되면 안 됨 -> EBO가 유리

2-3. Allocator, Traits 구조체 등 STL 확장

C++ STL에서 Allocator나 연산자를 정의할 때 구조를 빈 클래스로 상속해서 사용한다.

template <typename T, typename Alloc = std::allocator<T>>
class MyContainer : private Alloc
{
	// Alloc이 빈 타입이면 EBO로 최적화됨
};

Leave a comment