객체
객체 (Object)
C++ 프로그램은 객체(object)를 만들고, 파괴하고, 가리키고, 접근하고, 조작하며 동작해요. 그런데 C++에서 "객체"가 정확히 뭘 의미하는지를 한 번에 잡고 갈 필요가 있어요. OOP에서 말하는 객체와는 다른 의미를 갖고 있으니까요.
본문
C++의 객체는 다음을 가져요:
- 크기 (
sizeof로 알 수 있어요) - 정렬 요구사항 (
alignof로 알 수 있어요) - 저장 수명 (automatic, static, dynamic, thread-local)
- 수명 (저장 수명이나 임시 객체에 의해 경계가 정해져요)
- 타입
- 값 (기본 초기화된 비클래스 타입처럼 불확정(indeterminate)일 수 있어요)
- 선택적으로 이름
다음 개체(entity)들은 객체가 아니에요: 값, 참조, 함수, 열거자, 타입, 비정적 클래스 멤버, 템플릿, 클래스·함수 템플릿 특수화, 네임스페이스, 매개변수 팩, this.
**변수(variable)**는 비정적 데이터 멤버가 아니면서 선언으로 도입된 객체 또는 참조예요.
객체 생성 (Object creation)
객체는 정의(definition), new 표현식, throw 표현식, 유니온의 활성 멤버 변경, 임시 객체가 필요한 표현식의 평가를 통해 명시적으로 만들어져요. 명시적 객체 생성에서는 만들어진 객체가 유일하게 정의돼요.
암시적 수명(implicit-lifetime) 타입의 객체는 다음에 의해 암시적으로 생성될 수도 있어요:
- 상수 평가(constant evaluation) 중이 아닌,
unsigned char나std::byte(C++17 이후) 타입 배열의 수명을 시작하는 연산 (그런 경우 객체는 그 배열 안에 생성돼요) - 다음 할당 함수의 호출 (그런 경우 객체는 할당된 저장소 안에 생성돼요):
operator new(상수 평가 중이 아닌 경우)operator new[](상수 평가 중이 아닌 경우)std::mallocstd::callocstd::reallocstd::aligned_alloc(C++17 이후)
- 다음 객체 표현(object representation) 복사 함수의 호출 (그런 경우 객체는 저장소의 대상 영역이나 결과 안에 생성돼요):
std::memcpystd::memmovestd::bit_cast(C++20 이후)
- 다음 특정 함수의 호출 (그런 경우 객체는 지정된 저장소 영역 안에 생성돼요):
std::start_lifetime_asstd::start_lifetime_as_array(C++23 이후)
같은 저장소 영역에 하나 이상의 객체가 생성될 수 있는데, 그렇게 해도 프로그램에 정의된 동작이 주어지는 한 괜찮아요. 그런 생성이 불가능하다면(예: 상충하는 연산 때문에) 프로그램의 동작은 정의되지 않아요. 암시적으로 생성된 객체의 여러 집합이 모두 정의된 동작을 준다면, 어떤 집합이 생성되는지는 **불특정(unspecified)**이에요. 다시 말해, 암시적으로 생성된 객체는 유일하게 정의될 필요가 없어요.
지정된 저장소 영역 안에 객체를 암시적으로 생성한 뒤, 어떤 연산들은 적합한 생성된 객체에 대한 포인터를 만들어요. 그 적합한 객체는 저장소 영역과 같은 주소를 가져요. 마찬가지로, 그런 포인터 값이 프로그램에 정의된 동작을 주지 못하면 동작이 정의되지 않고, 여러 값이 정의된 동작을 준다면 어떤 포인터 값이 생성되는지는 불특정이에요.
#include <cstdlib>
struct X { int a, b; };
X* MakeX()
{
// One of possible defined behaviors:
// the call to std::malloc implicitly creates an object of type X
// and its subobjects a and b, and returns a pointer to that X object
X* p = static_cast<X*>(std::malloc(sizeof(X)));
p->a = 1;
p->b = 2;
return p;
}
std::allocator::allocate의 호출이나 유니온 타입의 암시적으로 정의된 복사·이동 특수 멤버 함수도 객체를 만들 수 있어요.
객체 표현과 값 표현 (Object representation and value representation)
어떤 타입과 객체는 객체 표현(object representation)과 값 표현(value representation)을 가져요. 아래 표에 정의되어 있어요:
| 개체 | 객체 표현 | 값 표현 |
|---|---|---|
완전 객체 타입 T |
타입 T의 비트필드가 아닌 완전 객체가 차지하는 N개의 unsigned char 객체의 시퀀스. 여기서 N은 sizeof(T) |
타입 T의 값을 표현하는 데 참여하는, T의 객체 표현 안의 비트 집합 |
타입 T의 비트필드가 아닌 완전 객체 obj |
T의 객체 표현에 해당하는 obj의 바이트 |
T의 값 표현에 해당하는 obj의 비트 |
비트필드 객체 bf |
bf가 차지하는 N개의 비트 시퀀스. N은 비트필드의 너비 |
bf의 값을 표현하는 데 참여하는, bf의 객체 표현 안의 비트 집합 |
타입이나 객체의 객체 표현 중 값 표현에 속하지 않는 비트를 **패딩 비트(padding bit)**라고 해요.
TriviallyCopyable 타입에서는 값 표현이 객체 표현의 일부예요. 다시 말해 객체가 저장소에서 차지하는 바이트를 복사하는 것만으로 같은 값을 가진 다른 객체를 만들어내기에 충분하다는 뜻이에요. (객체가 잠재적으로 겹치는(potentially-overlapping) 부분객체이거나, 값이 타입의 트랩 표현(trap representation)이어서 CPU에 로드하면 하드웨어 예외가 발생하는 경우 — 예: SNaN("signalling not-a-number") 부동소수점 값이나 NaT("not-a-thing") 정수 — 는 예외예요.)
대부분의 구현이 정수 타입에 대한 트랩 표현·패딩 비트·여러 표현을 허용하지 않지만, 예외는 있어요. 예를 들어 Itanium에서 정수 타입의 값은 트랩 표현일 수 있어요.
반대는 꼭 성립하지 않아요. TriviallyCopyable 타입의 두 객체가 서로 다른 객체 표현을 갖더라도 같은 값을 나타낼 수 있어요. 예를 들어 여러 부동소수점 비트 패턴이 같은 특수 값 NaN을 나타내요. 더 흔하게는, 정렬 요구사항이나 비트필드 크기 등을 맞추기 위해 패딩 비트가 도입될 수 있어요.
#include <cassert>
struct S
{
char c; // 1 byte value
// 3 bytes of padding bits (assuming alignof(float) == 4)
float f; // 4 bytes value (assuming sizeof(float) == 4)
bool operator==(const S& arg) const // value-based equality
{
return c == arg.c && f == arg.f;
}
};
void f()
{
assert(sizeof(S) == 8);
S s1 = {'a', 3.14};
S s2 = s1;
reinterpret_cast<unsigned char*>(&s1)[2] = 'b'; // modify some padding bits
assert(s1 == s2); // value did not change
}
이 예제는 패딩 비트를 바꿔도 값은 변하지 않는다는 걸 보여줘요. s2 = s1이 비트 단위 복사로 이뤄지고, 나중에 s1의 패딩 비트 일부를 바꿔도 ==는 값(멤버별) 비교를 하므로 여전히 같다고 나와요.
char, signed char, unsigned char, char8_t(C++20 이후) 타입의 객체(과대 비트필드가 아니라면)는 객체 표현의 모든 비트가 값 표현에 참여해야 하고, 각 가능한 비트 패턴이 서로 다른 값을 나타내야 해요. (패딩 비트·트랩 비트·여러 표현이 허용되지 않아요.)
부분객체 (Subobjects)
객체는 부분객체(subobject)를 가질 수 있어요. 여기에는 다음이 포함돼요:
- 멤버 객체
- 기반 클래스 부분객체
- 배열 요소
다른 객체의 부분객체가 아닌 객체를 **완전 객체(complete object)**라고 해요.
완전 객체, 멤버 부분객체, 배열 요소가 클래스 타입이라면, 그 타입을 **가장 파생된 클래스(most derived class)**로 간주해 기반 클래스 부분객체의 클래스 타입과 구분해요. 가장 파생된 클래스 타입의 객체나 비클래스 타입의 객체를 **가장 파생된 객체(most derived object)**라고 불러요.
클래스에 대해 다음을 **잠재적으로 생성되는 부분객체(potentially constructed subobject)**라고 해요:
- 비정적 데이터 멤버
- 비가상 직접 기반 클래스
- 클래스가 추상이 아니면, 가상 기반 클래스
크기 (Size)
부분객체가 기반 클래스 부분객체이거나 [[no_unique_address]] 속성(C++20 이후)으로 선언된 비정적 데이터 멤버라면, 이를 **잠재적으로 겹치는 부분객체(potentially-overlapping subobject)**라고 해요.
객체 obj는 다음 조건을 모두 만족할 때에만 0 크기를 가질 수 있어요:
obj가 잠재적으로 겹치는 부분객체일 것obj가 가상 멤버 함수와 가상 기반 클래스가 없는 클래스 타입일 것obj가 0이 아닌 크기의 부분객체나 0이 아닌 길이의 이름 없는 비트필드를 갖지 않을 것
위 조건을 모두 만족하는 객체 obj에 대해:
obj가 비정적 데이터 멤버가 없는 standard-layout(C++11 이후) 클래스 타입의 기반 클래스 부분객체라면 0 크기예요.- 그 외에는
obj가 0 크기를 갖는 상황이 구현 정의예요.
자세한 내용은 공(empty) 기반 최적화(empty base optimization)를 참고해요.
0이 아닌 크기의 비트필드가 아닌 객체는 저장소의 바이트를 하나 이상 차지해야 해요. 그리고 그 부분객체가 차지하는(전체든 부분이든) 모든 바이트를 포함해야 해요. 객체가 trivially copyable 또는 standard-layout(C++11 이후) 타입이라면 차지하는 저장소는 연속적이어야 해요.
주소 (Address)
객체가 비트필드거나 0 크기의 부분객체가 아니라면, 그 객체의 주소는 그것이 차지하는 첫 번째 바이트의 주소예요.
객체는 다른 객체를 포함할 수 있는데, 그 경우 포함된 객체는 전자(바깥 객체) 안에 중첩(nested)돼요. 객체 a가 다음 중 하나를 만족하면 객체 b 안에 중첩돼 있다고 해요:
a가b의 부분객체b가a에게 저장소를 제공a가c안에 중첩되고c가b안에 중첩된 어떤 객체c가 존재
객체가 다음 중 하나라면 **잠재적으로 유일하지 않은 객체(potentially non-unique object)**예요:
- 문자열 리터럴 객체
- 이니셜라이저 리스트(initializer list)의 백업 배열 (C++11 이후)
- 배열 타입의 템플릿 매개변수 객체 (C++26 이후,
std::meta::reflect_constant_string,std::meta::reflect_constant_array참고) - 잠재적으로 유일하지 않은 객체의 부분객체
수명이 겹치는 두 비트필드가 아닌 객체에 대해:
- 다음 중 하나를 만족하면 같은 주소를 가질 수 있어요:
- 하나가 다른 하나 안에 중첩되어 있음
- 둘 중 하나가 0 크기의 부분객체이고 타입이 유사(similar)하지 않음
- 둘 다 잠재적으로 유일하지 않은 객체
- 그 외에는 항상 서로 다른 주소를 갖고 서로 분리된 바이트 저장소를 차지해요.
// character literals are always unique
static const char test1 = 'x';
static const char test2 = 'x';
const bool b = &test1 != &test2; // always true
// the character 'x' accessed from "r", "s" and "il"
// may have the same address (i.e., these objects may share storage)
static const char (&r) [] = "x";
static const char *s = "x";
static std::initializer_list<char> il = {'x'};
const bool b2 = r != il.begin(); // unspecified result
const bool b3 = r != s; // unspecified result
const bool b4 = il.begin() != &test1; // always true
const bool b5 = r != &test1; // always true
이 예제에서 값 리터럴 'x'는 항상 유일하지만(b는 항상 true), 문자열 리터럴 "x", s, il이 접근하는 'x'는 저장소를 공유할 수 있어요. 그래서 b2, b3는 불특정 결과예요.
다형적 객체 (Polymorphic objects)
가상 함수를 선언하거나 상속받은 클래스 타입의 객체를 다형적 객체라고 해요. 각 다형적 객체 안에서 구현은 추가 정보를 저장해요. (현재의 모든 구현에서는 최적화로 제거되지 않는 한 포인터 하나예요.) 이 정보는 가상 함수 호출과 RTTI 기능(dynamic_cast, typeid)이 객체가 어떤 표현식에 쓰였는지와 무관하게 런타임에 실제로 어떤 타입으로 생성됐는지 판단하는 데 쓰여요.
다형적이지 않은 객체에 대해서는 값의 해석이 객체가 쓰인 표현식으로부터 결정되고, 컴파일 타임에 정해져요.
#include <iostream>
#include <typeinfo>
struct Base1
{
// polymorphic type: declares a virtual member
virtual ~Base1() {}
};
struct Derived1 : Base1
{
// polymorphic type: inherits a virtual member
};
struct Base2
{
// non-polymorphic type
};
struct Derived2 : Base2
{
// non-polymorphic type
};
int main()
{
Derived1 obj1; // object1 created with type Derived1
Derived2 obj2; // object2 created with type Derived2
Base1& b1 = obj1; // b1 refers to the object obj1
Base2& b2 = obj2; // b2 refers to the object obj2
std::cout << "Expression type of b1: " << typeid(decltype(b1)).name() << '\n'
<< "Expression type of b2: " << typeid(decltype(b2)).name() << '\n'
<< "Object type of b1: " << typeid(b1).name() << '\n'
<< "Object type of b2: " << typeid(b2).name() << '\n'
<< "Size of b1: " << sizeof b1 << '\n'
<< "Size of b2: " << sizeof b2 << '\n';
}
가능한 출력:
Expression type of b1: Base1
Expression type of b2: Base2
Object type of b1: Derived1
Object type of b2: Base2
Size of b1: 8
Size of b2: 1
Base1&인 b1은 표현식 타입은 Base1이지만 다형적 객체라서 런타임에 실제 객체 타입인 Derived1을 알아내요. 반면 다형적이지 않은 Base2&의 b2는 객체 타입도 Base2로 나와요. 또 다형적 객체는 가상 함수 정보 때문에 크기가 커질 수 있어요 (b1은 8바이트, b2는 1바이트).
엄격한 별칭 규칙 (Strict aliasing)
객체를 생성된 타입과 다른 타입의 표현식으로 접근하는 것은 많은 경우 정의되지 않은 동작이에요. 예외 목록과 예시는 reinterpret_cast를 참고해요.
정렬 (Alignment)
모든 객체 타입은 **정렬 요구사항(alignment requirement)**이라는 속성을 가져요. 이는 std::size_t 타입의 음이 아닌 정수(항상 2의 거듭제곱)로, 이 타입의 객체를 할당할 수 있는 연속된 주소 사이의 바이트 수를 나타내요.
타입의 정렬 요구사항은 alignof나 std::alignment_of로 조회할 수 있어요. 포인터 정렬 함수 std::align은 버퍼 안에서 적절히 정렬된 포인터를 얻는 데 쓸 수 있고, std::aligned_storage는 적절히 정렬된 저장소를 얻는 데 쓸 수 있어요(until C++23). (C++11 이후)
각 객체 타입은 그 타입의 모든 객체에 자신의 정렬 요구사항을 부과해요. 더 엄격한 정렬(더 큰 정렬 요구사항)은 alignas(C++11 이후)로 요청할 수 있어요. 객체 타입의 정렬 요구사항을 충족하지 않는 저장소에 객체를 만들려고 하면 정의되지 않은 동작이에요.
클래스의 모든 비정적 멤버의 정렬 요구사항을 충족하려고, 일부 멤버 뒤에 패딩 비트를 삽입할 수 있어요.
#include <iostream>
// objects of type S can be allocated at any address
// because both S.a and S.b can be allocated at any address
struct S
{
char a; // size: 1, alignment: 1
char b; // size: 1, alignment: 1
}; // size: 2, alignment: 1
// objects of type X must be allocated at 4-byte boundaries
// because X.n must be allocated at 4-byte boundaries
// because int's alignment requirement is (usually) 4
struct X
{
int n; // size: 4, alignment: 4
char c; // size: 1, alignment: 1
// three bytes of padding bits
}; // size: 8, alignment: 4
int main()
{
std::cout << "alignof(S) = " << alignof(S) << '\n'
<< "sizeof(S) = " << sizeof(S) << '\n'
<< "alignof(X) = " << alignof(X) << '\n'
<< "sizeof(X) = " << sizeof(X) << '\n';
}
가능한 출력:
alignof(S) = 1
sizeof(S) = 2
alignof(X) = 4
sizeof(X) = 8
X는 int n이 4바이트 정렬을 요구하므로 X 전체가 4바이트 경계에 있어야 해요. char c 뒤에 3바이트 패딩이 생겨 sizeof(X) == 8이 돼요.
가장 약한 정렬(가장 작은 정렬 요구사항)은 char, signed char, unsigned char의 정렬로 1이에요. 스칼라 타입의 가장 큰 기본 정렬(fundamental alignment)은 구현 정의이고 std::max_align_t(C++11 이후)의 정렬과 같아요.
특정 문맥에서 특정 확장 정렬(extended alignment) 요청을 구현이 지원하지 않으면, 프로그램은 ill-formed예요.
타입의 정렬이 std::max_align_t보다 더 엄격(더 큼)해지면 이를 **확장 정렬 요구사항(extended alignment requirement)**을 가진 타입이라고 해요. 정렬이 확장된 타입이나, 확장 정렬을 가진 비정적 데이터 멤버를 가진 클래스 타입을 **과정렬 타입(over-aligned type)**이라고 해요.
할당자 타입은 과정렬 타입을 올바르게 처리해야 해요. (C++11 이후)
new 표현식과 std::get_temporary_buffer가 과정렬 타입을 지원하는지 여부는 구현 정의예요. (C++11 이후, until C++20)
참고 (Notes)
C++의 객체는 객체 지향 프로그래밍(OOP)에서 말하는 객체와 의미가 달라요:
| C++의 객체 | OOP의 객체 |
|---|---|
어떤 객체 타입이든 될 수 있어요 (std::is_object 참고) |
클래스 타입이어야 해요 |
| "인스턴스(instance)" 개념이 없어요 | "인스턴스" 개념이 있어요 (instanceof 같은 메커니즘으로 "인스턴스-관계"를 감지해요) |
| "인터페이스" 개념이 없어요 | "인터페이스" 개념이 있어요 (인터페이스 구현 여부를 감지하는 메커니즘이 있어요) |
다형성은 virtual 멤버로 명시적으로 활성화해야 해요 |
다형성은 항상 활성화돼 있어요 |
결함 보고 P0593R6에서는, 바이트 배열을 만들거나 할당 함수(사용자 정의·constexpr일 수 있음)를 호출할 때 상수 평가 중에도 암시적 객체 생성이 일어난다고 간주했어요. 하지만 그런 허용은 상수 평가에 원치 않는 비결정성(indeterminism)을 만들었고, 일부 측면에서 구현할 수 없었어요. 그래서 P2747R2는 상수 평가에서 그런 암시적 객체 생성을 금지했어요. 전체 논문은 결함 보고가 아니지만, 우리는 그런 변경을 의도적으로 결함 보고처럼 취급해요.
결함 보고 (Defect reports)
다음은 이전에 발행된 C++ 표준에 소급 적용된, 동작을 바꾸는 결함 보고예요.
| DR | 적용 대상 | 발행 당시 동작 | 올바른 동작 |
|---|---|---|---|
| CWG 633 | C++98 | 변수는 객체일 수만 있었음 | 참조일 수도 있음 |
| CWG 734 | C++98 | 같은 스코프에 정의된, 같은 값을 갖는 게 보장된 변수들이 같은 주소를 가질 수 있는지 불특정이었음 | 수명이 겹치면 값과 무관하게 주소가 다르다고 보장됨 |
| CWG 1189 | C++98 | 같은 타입의 두 기반 클래스 부분객체가 같은 주소를 가질 수 있었음 | 항상 서로 다른 주소를 가짐 |
| CWG 1861 | C++98 | 좁은 문자 타입의 과대 비트필드에서 객체 표현의 모든 비트가 값 표현에 여전히 참여했음 | 패딩 비트 허용 |
| CWG 2489 | C++98 | char[]는 저장소를 제공할 수 없는데 객체가 그 저장소 안에 암시적으로 생성될 수 있었음 |
char[]의 저장소 안에는 객체를 암시적으로 생성할 수 없음 |
| CWG 2519 | C++98 | 객체 표현의 정의가 비트필드를 다루지 않았음 | 비트필드를 다룸 |
| CWG 2719 | C++98 | 정렬되지 않은 저장소에 객체를 만드는 동작이 불명확했음 | 이 경우 동작은 정의되지 않음 |
| CWG 2753 | C++11 | 이니셜라이저 리스트의 백업 배열이 문자열 리터럴과 저장소를 공유할 수 있는지 불명확했음 | 저장소를 공유할 수 있음 |
| CWG 2795 | C++98 | 수명이 겹치는 두 객체가 같은 주소를 가질 수 있는지 판단할 때, 둘 중 하나가 0 크기의 부분객체면 유사한 서로 다른 타입을 가질 수 있었음 | 유사하지 않은 타입만 허용 |
| P0593R6 | C++98 | 이전 객체 모델은 표준 라이브러리가 요구하는 유용한 관용구를 많이 지원하지 못했고 C의 effective type과도 호환되지 않았음 | 암시적 객체 생성 추가 |
더 알아보기 (Learn more)
sizeof,alignof— 객체의 크기와 정렬 요구사항을 알아내는 연산자예요.- 수명(lifetime) — 객체가 존재하는 기간을 다루는 주제예요.
- storage duration — 객체가 언제 생성·소멸되는지를 결정하는 저장 수명이에요.