Structs와 Unions
Structs와 Unions (구조체와 공용체)
D 언어에서 자료를 묶어서 다루는 방법, 그중에서도 값을 그대로 들고 다니는 타입 두 가지를 살펴볼게요. **Struct(구조체)**와 **Union(공용체)**는 클래스처럼 참조로 동작하지 않고, 값을 복사해서 쓰는 타입이에요. 메모리에 어떻게 배치되는지, 생성자와 소멸자가 어떤 식으로 호출되는지, 그리고 복사와 이동이 언제 일어나는지까지 차근차근 짚어 보겠습니다.
본문
개요 (Overview)
클래스가 참조 타입(reference type)인 데 비해, **구조체와 공용체는 값 타입(value type)**입니다. 값을 복사해 주고받는 방식으로 동작한다는 뜻이에요.
Structs (구조체)
구조체 선언의 문법은 다음과 같아요.
StructDeclaration:
struct Identifier ;
struct Identifier AggregateBody
StructTemplateDeclaration
AnonStructDeclaration
AnonStructDeclaration:
struct AggregateBody
AggregateBody:
{ DeclDefsopt }
구조체는 데이터와 그 데이터에 대한 연산을 단순하게 묶어 놓은 집합체입니다. 구조체의 **비정적(non-static) 데이터 멤버를 필드(field)**라고 불러요. 구조체 인스턴스의 멤버는 . 연산자로 접근합니다. 아래 예시는 정수 필드 하나를 가진 구조체 타입을 선언해요.
struct S
{
int i;
}
void main()
{
S a; // declare struct instance
a.i = 3;
S b = a; // copy a
a.i++;
assert(a.i == 4);
assert(b.i == 3);
}
눈여겨볼 점이 있어요. lvalue(이름이 있어 주소를 가진 값)로부터 구조체 인스턴스를 할당하거나 초기화하면 원본 구조체를 복사합니다. 위 코드에서 b = a로 복사한 뒤 a.i를 증가시켜도 b.i는 그대로 3인 게 그 이유죠.
구조체는 정체성(identity)이 없다고 정의합니다. 즉 구현이 편한 대로 구조체를 비트 단위로 복사해도 된다는 뜻이에요. 그래서 구조체는 '객체가 누구인가'보다 '어떤 값을 갖고 있는가'에 집중하는 타입입니다.
Storage (저장 방식)
struct S { int i; }
S* p = new S;
S* q = p;
p.i = 2; // `p.i` is the same as `(*p).i`
assert(q.i == 2); // q points to the same struct instance as p
지역 변수라면 구조체/공용체 인스턴스는 기본적으로 **스택(stack)**에 할당됩니다. 힙(heap)에 두고 싶다면 new를 쓰면 되고, 그러면 포인터가 돌아와요.
구조체나 공용체의 포인터는 . 연산자로 멤버에 접근할 때 자동으로 역참조(dereference)됩니다. 그래서 p.i는 (*p).i와 같은 뜻이에요.
좀 더 자세한 메모리 배치 이야기는 뒤의 "구조체 배치 (Struct Layout)" 절에서 다룰게요.
Disabling new (new 금지)
NewDeclaration:
new ( ) ;
@disable new();를 붙이면 특정 선언에서 new를 막을 수 있어요.
struct S
{
@disable new();
}
void main()
{
S s; // OK
S* p = new S; // Error, `new` is disabled
}
다만 이런 인스턴스도 메모리 버퍼에 올려 쓰는(emplace) 건 여전히 가능하다는 점 참고하세요.
Unions (공용체)
UnionDeclaration:
union Identifier ;
union Identifier AggregateBody
UnionTemplateDeclaration
AnonUnionDeclaration
AnonUnionDeclaration:
union AggregateBody
구조체는 여러 필드를 **순차적(sequential)**으로 저장합니다. 반대로 공용체의 여러 필드는 같은 메모리 공간을 겹쳐서(overlapping) 사용해요. 즉 필드 하나에 값을 넣으면 다른 필드도 같은 바이트를 보게 되는 거죠.
union U
{
ubyte i;
char c;
}
void main()
{
U u;
u.i = 3;
assert(u.c == '\x03');
u.c++;
assert(u.i == 4);
}
위 코드에서 u.i = 3을 넣었더니 u.c도 같은 바이트를 읽어서 제어 문자 \x03로 보이는 걸 확인할 수 있어요. 두 필드가 메모리를 공유하고 있기 때문입니다.
Members (멤버)
구조체나 공용체가 안에 어떤 선언을 담을 수 있는지는 규칙이 정해져 있어요.
Union Limitations (공용체의 제약)
공용체는 **postblit, 소멸자(destructor), 불변식(invariant)**을 가질 수 없습니다. 만약 소멸자를 가진 필드를 생성자로 만든 경우라면, 그 필드는 수동으로 소멸시켜 줘야 해요.
Recursive Structs and Unions (재귀적 구조체와 공용체)
구조체와 공용체는 자기 타입의 비정적 인스턴스를 필드로 가질 수 없습니다. 다만 자기 타입을 가리키는 포인터나 슬라이스는 가질 수 있어요.
struct S
{
S* ptr; // OK
S[] slice; // OK
S s; // error
S[2] array; // error
static S global; // OK
}
ptr(포인터)과 slice는 크기가 정해져 있어서 괜찮지만, s와 array처럼 자기 자신을 통째로 품으려 하면 크기가 무한정 커져서 오류가 나는 거예요. static S global;처럼 정적으로 선언하면 허용됩니다.
Struct Layout (구조체 배치)
필드는 어휘 순서(선언 순서)대로 배치됩니다. 그때 적용 중인 정렬 속성(Align Attribute)에 따라 각 필드가 정렬되고, 필드 사이에는 정렬을 맞추기 위한 이름 없는 패딩(padding)이 들어가요. 첫 필드와 객체의 시작점 사이에는 패딩이 없습니다.
extern(C) struct C {}
struct D {}
static assert(C.sizeof == 0);
static assert(D.sizeof == 1);
extern(D)인 구조체 가운데 크기가 0이 아닌 필드를 하나도 갖지 않는 것(일명 빈 구조체, Empty Struct)은 크기가 1바이트예요. 반면 extern(C) 구조체는 크기가 0이죠.
비정적 함수에 중첩된(nested) D 구조체 중 바깥 스코프의 문맥(컨텍스트)에 접근하는 것은, 그 문맥을 가리키는 숨겨진 필드를 하나 더 가집니다.
빈 구조체를 다룰 때 알아둘 점이 몇 가지 있어요.
- 구조체 필드의 기본 배치는 대응하는 C 컴파일러와 정확히 일치합니다.
- g++와 clang++은 빈 구조체 처리 방식이 달라요. 둘 다
sizeof에서1을 돌려주지만, clang++은 빈 구조체를 인자 스택에 넣지 않고 g++은 넣습니다. 이건 g++과 clang++ 사이의 바이너리 비호환성이에요. dmd는 OSX와 FreeBSD에서는 clang++처럼, Linux와 그 외 Posix 플랫폼에서는 g++처럼 동작합니다. - clang과 gcc 모두 빈 구조체의
sizeof에서0을 돌려줍니다. 그리고 clang++/g++에서extern "C++"를 쓰더라도 각자의 C 컴파일러 동작과 일치하게 만들지는 않아요. - 패딩 데이터에는 접근할 수 있지만, 그 내용은 정의되지 않았습니다.
- 크기가 0이 아닌 필드가 없는 구조체를
extern (C)함수에 넘기거나 반환해서는 안 됩니다. C11 6.7.2.1p8에 따르면 이건 정의되지 않은 동작(undefined behavior)이에요. - 외부에서 정의된 배치와 맞춰야 한다면
align속성으로 정확한 배치를 기술하고, 예상대로 나오는지 Static Assert로 확인하는 게 좋아요. - 패딩 내용이 흔히 0이긴 하지만, 그걸 믿고 의존하면 안 됩니다.
- C/C++ 코드와 연동할 때는 빈 구조체를 쓰지 않는 게 좋아요.
- 가변 인자(variadic) 함수의 인자나 인수로 빈 구조체를 쓰지 마세요.
Bit Field Declarations (비트 필드 선언)
비트 필드(BitfieldDeclarator)로 비트 필드를 선언합니다. 비트 필드의 폭은 AssignExpression이나 ConditionalExpression으로 지정해요. 이 값은 컴파일 타임에 평가되며, 0부터 비트 필드 타입의 비트 수까지 범위의 정수여야 합니다.
비트 필드는 초기화식(Initializer)을 가질 수 있는데, 이는 컴파일 타임 상수로 평가되어야 하고 비트 필드의 폭 안에 들어맞아야 해요.
비트 필드의 타입은 부호가 있든 없든 **정수 타입(integral type)**이어야 합니다. 비트 필드에 대입하는 값은 지정된 폭 안에 들어맞아야 하고, 비트 필드는 자기 타입 값을 담기에 충분히 큰 메모리 단위 안에 배치됩니다.
익명(anonymous) 비트 필드는 식별자(Identifier)가 없어요. 그리고 폭이 0인 비트 필드는 반드시 익명이어야 하며, 그러면 다음 비트 필드가 다음 메모리 단위에 배치됩니다.
비트 필드에는 몇 가지 제약이 걸려요. 비트 필드의 주소를 구할 수 없고, ref 선언은 비트 필드로 초기화할 수 없으며, 비트 필드의 배열도 허용되지 않습니다. 비트 필드는 오직 구조체·공용체·클래스의 필드로만 쓸 수 있고, 반드시 비정적(non-static)이어야 해요.
struct B
{
int x:3 = 2, y:2;
}
static assert(B.sizeof == 4);
int vaporator(B b)
{
b.x = 4;
b.y = 2;
return b.x + b.y; // returns 6
}
위 예시에서 x는 3비트, y는 2비트짜리 필드예요. x에 4를 넣으면 3비트 폭 안에 들어가고, y에 2를 넣으면 2비트 폭에 맞죠. 그래서 b.x + b.y가 6이 됩니다.
비트 필드 값은 폭에 맞는 int나 uint 타입으로 다뤄져요.
Plain Old Data (POD)
구조체나 공용체가 아래 조건을 모두 만족하면 **Plain Old Data(POD)**라고 부릅니다.
- 정적(static)이거나 중첩(nested)이 아닐 것
- postblit, 복사 생성자, 소멸자, 대입 연산자가 없을 것
- 스스로가 non-POD인 필드를 갖지 않을 것
Opaque Structs and Unions (불투명 구조체와 공용체)
불투명한 구조체/공용체 선언은 AggregateBody(중괄호 본문)가 없어요.
struct S;
union U;
struct V(T);
union W(T);
이런 타입의 멤버는 사용자에게 완전히 숨겨지므로, 그 타입에 대해 할 수 있는 연산은 내용물을 몰라도 되는 연산뿐입니다.
struct S;
S.sizeof; // error, size is not known
S s; // error, cannot initialize unknown contents
S* p; // ok, knowledge of members is not necessary
S.sizeof는 크기를 알아야 하므로 오류, S s;는 내용을 초기화할 수 없으므로 오류가 나죠. 오직 S* p;처럼 멤버 지식이 필요 없는 포인터 선언만 허용됩니다.
Initialization (초기화)
Default Initialization of Structs (구조체의 기본 초기화)
기본적으로 구조체 필드는 초기화식(Initializer)이 있으면 그 값으로, 없으면 **타입의 기본값(default initializer)**으로 초기화됩니다.
struct S { int a = 4; int b; }
S x; // x.a is set to 4, x.b to 0
여기서 x.a는 선언에 달린 = 4로 초기화되고, x.b는 그런 초기화식이 없어서 int의 기본값인 0이 되는 거예요. 필드 초기화식은 컴파일 타임에 평가됩니다.
중첩된 구조체라면 문맥 포인터(context pointer)가 기본값으로 초기화돼요.
Static Initialization of Structs (구조체의 정적 초기화)
StructInitializer:
{ StructMemberInitializersopt }
StructMemberInitializers:
StructMemberInitializer
StructMemberInitializer ,
StructMemberInitializer , StructMemberInitializers
StructMemberInitializer:
NonVoidInitializer
Identifier : NonVoidInitializer
StructInitializer가 주어지면, 각 StructMemberInitializer가 대응하는 필드를 초기화합니다.
Identifier : NonVoidInitializer문법을 쓰는 초기화식은 어떤 순서로도 나타날 수 있어요. 이때 식별자는 반드시 필드 이름과 일치해야 합니다.- 첫 StructMemberInitializer가 식별자를 지정하지 않으면, 그것은 StructDeclaration의 첫 번째 필드를 가리킵니다.
- 그 뒤에 나오는 식별자 없는 NonVoidInitializer는 앞선 초기화식이 가리킨 필드의 **다음 필드(어휘 순서)**를 가리켜요.
StructMemberInitializer가 덮지 않는 필드는 기본 초기화됩니다.
struct S { int a, b, c, d = 7; }
S r; // r.a = 0, r.b = 0, r.c = 0, r.d = 7
S s = { a:1, b:2 }; // s.a = 1, s.b = 2, s.c = 0, s.d = 7
S t = { c:4, b:5, a:2, d:5 }; // t.a = 2, t.b = 5, t.c = 4, t.d = 5
S u = { 1, 2 }; // u.a = 1, u.b = 2, u.c = 0, u.d = 7
S v = { 1, d:3 }; // v.a = 1, v.b = 0, v.c = 0, v.d = 3
S w = { b:1, 3 }; // w.a = 0, w.b = 1, w.c = 3, w.d = 7
S x = { 1, a:2 }; // error: duplicate initializer for field `a`
첫 번째 예시들이 어떻게 매핑되는지 천천히 봐요. S s = { a:1, b:2 };는 이름을 지정해서 원하는 필드만 채우고, S u = { 1, 2 };는 순서대로 첫·둘째 필드에 넣죠. S w = { b:1, 3 };은 b:1 다음의 식별자 없는 3이 그다음 필드인 c에 들어갑니다. 그리고 한 필드를 두 번 이상 초기화하면 오류예요.
Default Initialization of Unions (공용체의 기본 초기화)
공용체는 기본적으로 첫 번째 필드의 초기화식대로 초기화되고, 초기화식이 없으면 첫 번째 필드 타입의 기본 초기화자로 초기화됩니다. 공용체가 첫 필드보다 크면 나머지 비트는 0으로 채워져요.
union U { int a = 4; long b; }
U x; // x.a is set to 4, x.b to an implementation-defined value
만약 첫 번째 필드가 아닌 다른 멤버에 초기화식을 넣으면 오류입니다.
union V { int a; long b = 4; } // error: union field `b` with default initialization `4` must be before field `a`
union W { int a = 4; long b = 5; } // error: overlapping default initialization for `a` and `b`
기본 초기화자는 컴파일 타임에 평가됩니다.
Static Initialization of Unions (공용체의 정적 초기화)
공용체는 구조체와 비슷하게 초기화되는데, 딱 하나 차이가 있어요. 멤버 초기화식을 하나만 허용한다는 점입니다. 멤버 초기화식이 식별자를 지정하지 않으면 공용체의 첫 번째 멤버를 초기화해요.
union U { int a; double b; }
U u = { 2 }; // u.a = 2
U v = { b : 5.0 }; // v.b = 5.0
U w = { 2, 3 }; // error: overlapping initialization for field `a` and `b`
공용체가 초기화된 필드보다 크면, 남는 비트는 0으로 채워집니다.
Dynamic Initialization of Structs (구조체의 동적 초기화)
정적 초기화 문법은 비정적 변수를 초기화하는 데도 쓸 수 있어요. 이때 초기화식은 컴파일 타임에 평가될 필요가 없습니다.
struct S { int a, b, c, d = 7; }
void test(int i)
{
S q = { 1, b:i }; // q.a = 1, q.b = i, q.c = 0, q.d = 7
}
구조체를 같은 타입의 다른 값으로 동적으로 초기화할 수도 있어요.
struct S { int a; }
S t; // default initialized
t.a = 3;
S s = t; // s.a is set to 3
구조체에 생성자가 있고, 다른 타입의 값으로 초기화한다면 생성자가 호출됩니다.
struct S
{
int a;
this(int v)
{
this.a = v;
}
}
S s = 3; // sets s.a to 3 using S's constructor
구조체에 생성자는 없지만 opCall이 정의되어 있고 다른 타입의 값으로 초기화한다면, 그 연산자가 호출돼요.
struct S
{
int a;
static S opCall(int v)
{
S s;
s.a = v;
return s;
}
static S opCall(S v)
{
assert(0);
}
}
S s = 3; // sets s.a to 3 using S.opCall(int)
S t = s; // sets t.a to 3, S.opCall(S) is not called
여기서 S s = 3;은 S.opCall(int)를 호출해서 s.a를 3으로 만들고, S t = s;처럼 같은 타입으로부터의 초기화는 S.opCall(S)를 부르지 않고 그냥 복사해요.
Dynamic Initialization of Unions (공용체의 동적 초기화)
정적 초기화 문법은 공용체의 비정적 변수를 초기화할 때도 쓸 수 있어요. 역시 초기화식이 컴파일 타임 값일 필요는 없습니다.
union U { int a; double b; }
void test(int i)
{
U u = { a : i }; // u.a = i
U v = { b : 5.0 }; // v.b = 5.0
}
Struct Literals (구조체 리터럴)
구조체 리터럴은 구조체 이름 뒤에 괄호로 묶인 이름 붙은 인자 목록을 붙인 형태예요.
struct S { int x; float y; }
S s1 = S(1, 2); // set field x to 1, field y to 2
S s2 = S(y: 2, x: 1); // same as above
assert(s1 == s2);
S(1, 2)는 x에 1, y에 2를 넣고, S(y: 2, x: 1)은 이름을 지정해서 같은 결과를 만들어요.
구조체에 생성자나 opCall이라는 멤버 함수가 있으면, 그 구조체의 구조체 리터럴은 쓸 수 없습니다. 이때는 opCall 연산자 오버로딩 문서의 해결책을 참고하세요.
구조체 리터럴은 문법상 함수 호출과 똑같이 생겼는데, 배치 규칙이 정해져 있어요.
- 첫 인자에 이름이 없으면, 어휘상 가장 먼저 정의된 구조체 필드에 대입됩니다.
- 이름 붙은 인자는 같은 이름의 구조체 필드에 대입됩니다. 그런 필드가 없으면 오류예요.
- 그 밖의 인자는 앞선 인자가 대입한 필드 기준으로 다음에 정의된 필드에 대입됩니다. 그런 필드가 없으면(즉 앞선 인자가 마지막 필드에 대입된 경우) 오류예요.
- 한 필드를 두 번 대입해도 오류입니다.
- 값을 대입받지 않은 필드는 각자의 기본 초기화자로 초기화됩니다.
구조체 안에 공용체 필드가 있으면, 구조체 리터럴 안에서 공용체의 멤버 중 하나만 초기화할 수 있어요. 이는 공용체 리터럴의 동작과 일치합니다.
struct S { int x = 1, y = 2, z = 3; }
S s0 = S(y: 5, 6, x: 4); // `6` is assigned to field `z`, which comes after `y`
assert(s0.z == 6);
S s1 = S(y: 5, z: 6); // Field x is not assigned, set to default initializer `1`
assert(s1.x == 1);
//S s2 = S(y: 5, x: 4, 5); // Error: field `y` is assigned twice
//S s3 = S(z: 2, 3); // Error: no field beyond `z`
Union Literals (공용체 리터럴)
공용체 리터럴은 구조체 리터럴과 비슷한데, 한 필드만 초기화식으로 초기화할 수 있어요. 공용체 메모리의 나머지는 0으로 채워집니다.
union U
{
byte a;
char[2] b;
}
U u = U(2);
assert(u.a == 2);
assert(u.b == [2, 0]);
Anonymous Structs and Unions (익명 구조체와 공용체)
부모 클래스·구조체·공용체의 멤버로 익명 구조체나 익명 공용체를 선언할 수 있어요. 이때 struct나 union 뒤의 식별자를 생략합니다. 익명 구조체는 부모 타입 안에 순차 저장되는 필드들을 선언하고, 익명 공용체는 겹쳐 저장되는 필드들을 선언해요.
익명 공용체는 클래스나 구조체 안에서 필드들이 메모리를 공유하게 만들 때 유용합니다. 별도의 공용체 타입으로 부모 필드를 만들어 이름을 붙이지 않아도 되거든요.
struct S
{
int a;
union
{
byte b;
char c;
}
}
S s = S(1, 2);
assert(s.a == 1);
assert(s.b == 2);
assert(s.c == 2); // overlaps with `b`
여기서 b와 c가 겹쳐 있어서 s.b에 넣은 2가 s.c에서도 보입니다.
반대로 익명 구조체는 공용체 안에서 여러 필드를 순차 저장하고 싶을 때 유용해요.
union U
{
int a;
struct
{
uint b;
bool c;
}
}
U u = U(1);
assert(u.a == 1);
assert(u.b == 1); // overlaps with `a`
assert(u.c == false); // no overlap
Struct Properties (구조체 속성)
구조체 인스턴스는 .alignof, .tupleof 같은 속성을 제공합니다. 특히 .tupleof는 구조체를 필드들의 튜플처럼 다루고 싶을 때 유용해요.
Struct Field Properties (구조체 필드 속성)
필드마다 .offsetof 같은 속성을 쓸 수 있고, 필드의 align도 확인할 수 있어요.
Const, Immutable and Shared Structs
구조체 선언에 const, immutable, shared와 같은 저장 클래스(storage class)를 붙일 수 있어요. 이는 구조체의 각 멤버를 각각 const, immutable, shared로 선언한 것과 같은 효과를 냅니다.
const struct S { int a; int b = 2; }
void main()
{
S s = S(3); // initializes s.a to 3
S t; // initializes t.a to 0
t = s; // error, t.a and t.b are const, so cannot modify them.
t.a = 4; // error, t.a is const
}
여기서 S s = S(3);은 s.a를 3으로 초기화했지만, S t;는 t.a를 0으로 초기화해요. 그리고 const로 선언됐기 때문에 t = s;나 t.a = 4;처럼 값을 바꾸려 하면 오류가 납니다.
Union Constructors (공용체 생성자)
공용체도 구조체와 같은 방식으로 생성됩니다.
Struct Constructors (구조체 생성자)
구조체 생성자는 정적 초기화나 구조체 리터럴로는 표현하기 어려운 더 복잡한 초기화가 필요할 때 인스턴스를 초기화하는 데 쓰여요.
생성자는 함수 이름이 this이고 반환값이 없어요. 문법은 클래스 생성자와 같습니다. 구조체 생성자는 구조체 이름 뒤에 Parameters를 붙여 호출합니다. ParameterList가 비어 있으면 구조체 인스턴스는 기본 초기화됩니다.
struct S
{
int x, y = 4, z = 6;
this(int a, int b)
{
x = a;
y = b;
}
}
void main()
{
S a = S(4, 5); // calls S.this(4, 5): a.x = 4, a.y = 5, a.z = 6
S b = S(); // default initialized: b.x = 0, b.y = 4, b.z = 6
S c = S(1); // error, matching this(int) not found
}
S a = S(4, 5);는 S.this(4, 5)를 호출해 a.z는 선언의 기본값 6으로, 나머지는 인자대로 채워요. S b = S();는 기본 초기화되고, S c = S(1);은 맞는 this(int)가 없어 오류입니다.
**이름 붙은 인자(named argument)**는 생성자에 전달되며, 이때는 구조체 필드 이름이 아니라 생성자 매개변수 이름과 맞춰집니다.
struct S
{
int x;
int y;
this(int y, int z) { this.x = y; this.y = z; }
}
S a = S(x: 3, y: 4); // Error: constructor has no parameter named `x`
S b = S(y: 3, 4); // `y: 3` will set field `x` through parameter `y`
S b = S(y: 3, 4);에서 y: 3은 생성자의 매개변수 y를 거쳐 필드 x에 들어가요. 한편 S a = S(x: 3, y: 4);는 생성자에 x라는 매개변수가 없어서 오류입니다.
기본 생성자(즉 빈 ParameterList를 가진 생성자)는 허용되지 않아요.
struct S
{
int x;
this() { } // error, struct default constructor not allowed
}
Delegating Constructors (위임 생성자)
생성자가 같은 구조체의 다른 생성자를 호출해 공통 초기화를 공유할 수 있는데, 이걸 **위임 생성자(delegating constructor)**라고 부릅니다.
struct S
{
int j = 1;
long k = 2;
this(long k)
{
this.k = k;
}
this(int i)
{
// At this point: j=1, k=2
this(6L); // delegating constructor call
// At this point: j=1, k=6
j = i;
// At this point: j=i, k=6
}
}
this(int i)가 시작될 때는 j=1, k=2였다가, this(6L); 호출 이후 k가 6으로 바뀌고, 마지막 j = i;로 끝나는 흐름을 주석으로 확인할 수 있어요.
다음 제약들이 적용됩니다.
struct S
{
int a;
this(int i) { }
this(char c)
{
c || this(1); // error, not on all paths
}
this(wchar w)
{
(w) ? this(1) : this('c'); // ok
}
this(byte b)
{
foreach (i; 0 .. b)
{
this(1); // error, inside loop
}
}
}
- 생성자 코드가 위임 생성자 호출을 포함하면, 생성자를 통과하는 모든 실행 경로는 정확히 한 번 위임 생성자 호출을 만들어야 해요.
- 위임 생성자 호출 전에
this를 암시적·명시적으로 참조하는 것은 불법입니다. - 위임 생성자가 반환되면 그 시점부터 모든 필드가 구성(constructed)된 것으로 간주합니다.
- 위임 생성자 호출은 레이블(label) 뒤에 나타날 수 없어요.
클래스의 위임 생성자에 대한 더 자세한 내용은 해당 문서를 참고하세요.
Struct Instantiation (구조체 인스턴스화)
구조체 인스턴스가 생성될 때는 다음 단계가 일어나요.
- 먼저 구조체 정의에 주어진 값들로 원시 데이터를 정적으로 초기화합니다. 이는 정적 버전의 객체를 새로 할당된 객체에 메모리 복사하는 것과 동등해요.
- 그다음 구조체에 생성자가 정의되어 있다면, 인자 목록에 맞는 생성자를 호출합니다.
- 구조체 불변식(invariant) 검사가 켜져 있으면, 생성자 끝에서 구조체 불변식을 호출합니다.
Constructor Attributes (생성자의 속성)
생성자는 비활성화(disable)될 수 있어요.
const, immutable, shared로 한정된 생성자는 그 특정 한정자로 객체 인스턴스를 구성합니다.
struct S1
{
int[] a;
this(int n) { a = new int[](n); }
}
struct S2
{
int[] a;
this(int n) immutable { a = new int[](n); }
}
void main()
{
// Mutable constructor creates mutable object.
S1 m1 = S1(1);
// Constructed mutable object is implicitly convertible to const.
const S1 c1 = S1(1);
// Constructed mutable object is not implicitly convertible to immutable.
immutable i1 = S1(1); // error
// Mutable constructor cannot construct immutable object.
auto x1 = immutable S1(1); // error
// Immutable constructor creates immutable object.
immutable i2 = immutable S2(1);
// Immutable constructor cannot construct mutable object.
auto x2 = S2(1); // error
// Constructed immutable object is not implicitly convertible to mutable.
S2 m2 = immutable S2(1); // error
// Constructed immutable object is implicitly convertible to const.
const S2 c2 = immutable S2(1);
}
한정자에 따라 가변(mutable)/불변(immutable) 객체를 만들 수 있는지가 갈려요. 예컨대 가변 생성자 S1(1)로 만든 객체는 const로는 암시 변환되지만 immutable로는 되지 않죠. 그리고 immutable 생성자 S2(1)는 반대로 가변 객체를 만들 수 없습니다.
생성자는 다른 한정자로 오버로드할 수 있어요.
struct S
{
this(int); // non-shared mutable constructor
this(int) shared; // shared mutable constructor
this(int) immutable; // immutable constructor
}
void fun()
{
S m = S(1);
shared s = shared S(2);
immutable i = immutable S(3);
}
shared 한정 생성자는 공유 객체를 만들고, immutable 한정 생성자는 불변 객체를 만들죠. 호출할 때도 각각 shared S(2)나 immutable S(3)처럼 한정자를 붙입니다.
Pure Constructors (순수 생성자)
생성자가 **유일한 객체(unique object)**를 만들 수 있다면(즉 pure라면), 그 객체는 어떤 한정자로든 암시 변환될 수 있어요.
struct S
{
this(int) pure;
// Based on the definition, this creates a mutable object. But the
// created object cannot contain any mutable global data.
// Therefore the created object is unique.
this(int[] arr) immutable pure;
// Based on the definition, this creates an immutable object. But
// the argument int[] never appears in the created object so it
// isn't implicitly convertible to immutable. Also, it cannot store
// any immutable global data.
// Therefore the created object is unique.
}
void fun()
{
immutable i = immutable S(1); // this(int) pure is called
shared s = shared S(1); // this(int) pure is called
S m = S([1,2,3]); // this(int[]) immutable pure is called
}
this(int) pure는 가변 객체를 만들지만 전역 가변 데이터를 담지 않으므로 유일하고, this(int[]) immutable pure는 불변 객체를 만들되 인자 int[]가 객체 안에 남지 않아 유일해요. 그래서 각각 immutable S(1), shared S(1), S([1,2,3]) 어디에든 쓸 수 있습니다.
Disabling Default Initialization (기본 초기화 금지)
구조체 생성자가 @disable로 달려 있고 ParameterList가 비어 있으면, 그 구조체는 기본 초기화가 금지됩니다. 그런 구조체는 ParameterList가 비지 않은 다른 생성자 호출로만 만들 수 있어요. 다른 유효한 생성자가 없다면, VoidInitializer를 통하지 않고는 인스턴스화할 수 없습니다.
금지된 생성자는 FunctionBody를 가질 수 없고, 어떤 필드가 기본 초기화가 금지되어 있다면 구조체의 기본 초기화도 함께 금지됩니다.
struct S
{
int x;
// Disables default initialization
@disable this();
this(int v) { x = v; }
}
struct T
{
int y;
S s;
}
void main()
{
S s; // error: default initialization is disabled
S t = S(); // error: also disabled
S u = S(1); // constructed by calling `S.this(1)`
S v = void; // not initialized, but allowed
S w = { 1 }; // error: cannot use { } since constructor exists
S[3] a; // error: default initialization is disabled
S[3] b = [S(1), S(20), S(-2)]; // ok
T t; // error: default initialization is disabled
}
S s;, S t = S();, S[3] a;, T t; 모두 기본 초기화가 금지되어 오류이고, S u = S(1);은 생성자로, S v = void;는 VoidInitializer로만 만들 수 있어요. 참고로 S w = { 1 };처럼 생성자가 있는데 { } 문법을 쓰면 안 됩니다.
Field initialization inside a constructor (생성자 안의 필드 초기화)
생성자 본문에서 위임 생성자를 호출하면 모든 필드 대입은 '대입(assignment)'으로 간주됩니다. 그렇지 않으면 필드의 **첫 번째 대입이 초기화(initialization)**가 돼요. 이때 field = expression 형태의 대입은 typeof(field)(expression)과 동등하게 처리됩니다. 필드의 값은 초기화 전에도, 위임 생성자로 구성하기 전에도 읽을 수 있어요.
struct S
{
int num;
int ber;
this(int i)
{
num = i + 1; // initialization
num = i + 2; // assignment
ber = ber + 1; // ok to read before initialization
}
this(int i, int j)
{
this(i);
num = i + 1; // assignment
}
}
this(int i)에서는 num = i + 1;이 초기화, 그다음 num = i + 2;는 대입이에요. 게다가 ber = ber + 1;처럼 초기화 전에 ber를 읽는 것도 허용됩니다. 반면 this(int i, int j)는 this(i);로 위임했기 때문에 num = i + 1;이 전부 대입이 돼요.
필드 타입에 opAssign 메서드가 있어도, 초기화에는 쓰이지 않습니다.
struct A
{
this(int n) {}
void opAssign(A rhs) {}
}
struct S
{
A val;
this(int i)
{
val = A(i); // val is initialized to the value of A(i)
val = A(2); // rewritten to val.opAssign(A(2))
}
}
val = A(i);는 val을 A(i) 값으로 초기화하지만, 두 번째 val = A(2);는 val.opAssign(A(2))로 다시 쓰여요.
필드 타입이 가변이 아니라면, 여러 번 초기화는 거부됩니다.
struct S
{
immutable int num;
this(int)
{
num = 1; // OK
num = 2; // Error: assignment to immutable
}
}
immutable int num은 첫 대입 num = 1;은 초기화로 허용되지만, 두 번째 num = 2;는 immutable에 대한 대입이라 오류예요.
필드가 한 경로에서 초기화된다면 모든 경로에서 초기화되어야 합니다.
struct S
{
immutable int num;
immutable int ber;
this(int i)
{
if (i)
num = 3; // initialization
else
num = 4; // initialization
}
this(long j)
{
j ? (num = 3) : (num = 4); // ok
j || (ber = 3); // Error: initialized on only one path
j && (ber = 3); // Error: initialized on only one path
}
}
if (i) ... else ...처럼 양쪽 경로 모두에서 초기화하면 괜찮지만, j || (ber = 3);이나 j && (ber = 3);처럼 한 경로에서만 초기화하면 오류입니다.
필드 초기화는 반복문이나 레이블 뒤에 나타날 수 없어요.
struct S
{
immutable int num;
immutable string str;
this(int j)
{
foreach (i; 0..j)
{
num = 1; // Error: field initialization not allowed in loops
}
size_t i = 0;
Label:
str = "hello"; // Error: field initialization not allowed after labels
if (i++ < 2)
goto Label;
}
this(int j, int k)
{
switch (j)
{
case 1: ++j; break;
default: break;
}
num = j; // Error: `case` and `default` are also labels
}
}
foreach 안에서 num = 1;, 레이블 뒤에서 str = "hello";, 그리고 switch의 case/default(이것도 레이블) 뒤에서 num = j; 하는 것 모두 오류입니다.
필드 타입의 기본 생성이 금지되어 있다면, 그 필드는 생성자 안에서 반드시 초기화되어야 해요.
struct S { int y; @disable this(); }
struct T
{
S s;
this(S t) { s = t; } // ok
this(int i) { this('c'); } // ok
this(char) { } // Error: s not initialized
}
T의 필드 s는 S가 @disable this();라서 기본 생성이 안 돼요. 그래서 this(S t) { s = t; }처럼 초기화하거나 this(int i) { this('c'); }처럼 위임하면 괜찮지만, this(char) { }는 s를 초기화하지 않아 오류예요.
Struct Copy Constructors (구조체 복사 생성자)
경고: 원래 계획은 복사 생성자가 postblit 생성자를 대체하고, postblit 생성자는 레거시 코드에만 남기는 것이었어요. 그런데 druntime에서 동적 배열과 연관 배열을 처리하는 컴파일러 훅이 아직 복사 생성자를 제대로 지원하도록 모두 갱신되지 않았습니다(issue #20970). 그래서 동적 배열이나 연관 배열에 쓰일 수 있는 타입은 복사 생성자보다 postblit 생성자를 사용해야 해요. 복사 생성자는 동적 배열의 원소나 연관 배열의 키/값에서 호출되어야 할 모든 상황에서 호출되지 않을 수 있는데, postblit 생성자는 그런 문제가 없습니다.
호환성 때문에, 복사 생성자와 postblit을 둘 다 명시적으로 정의한 struct는 암시적 복사에 postblit만 사용합니다. 다만 postblit이 금지되어 있으면 복사 생성자를 사용해요. 구조체가 (사용자 정의든 생성된 것이든) 복사 생성자를 정의하면서 postblit을 정의한 필드를 가질 경우, postblit이 복사 생성자보다 우선한다는 deprecation 경고가 나옵니다.
복사 생성자는 같은 타입의 다른 인스턴스로부터 struct 인스턴스를 초기화하는 데 쓰여요. 복사 생성자를 정의한 struct는 POD가 아닙니다.
어떤 생성자 선언이 다음 요건을 모두 만족하면 복사 생성자 선언입니다.
- 기본 인자(default argument)가 없는 매개변수를 정확히 하나 취하고, 그 뒤에 기본 인자를 가진 매개변수들을 얼마든지 취합니다.
- 첫 매개변수가
ref매개변수예요. - 첫 매개변수의 타입이
typeof(this)와 같은 타입(선택적으로 타입 한정자가 하나 이상 붙은)이에요. - 템플릿 생성자 선언이 아닙니다.
struct A
{
this(ref return scope A rhs) {} // copy constructor
this(ref return scope const A rhs, int b = 7) {} // copy constructor with default parameter
}
복사 생성자는 일반 생성자처럼 타입 검사됩니다.
복사 생성자가 정의되면, 다음 상황에서 암시적 호출이 삽입됩니다.
struct A
{
int[] arr;
this(ref return scope A rhs) { arr = rhs.arr.dup; }
}
void main()
{
A a;
a.arr = [1, 2];
A b = a; // copy constructor gets called
b.arr[] += 1;
assert(a.arr == [1, 2]); // a is unchanged
assert(b.arr == [2, 3]);
}
- 변수가 명시적으로 초기화될 때:
struct A
{
this(ref return scope A another) {}
}
void fun(A a) {}
void main()
{
A a;
fun(a); // copy constructor gets called
}
- 매개변수가 함수에 값으로 전달될 때:
struct A
{
this(ref return scope A another) {}
}
A fun()
{
A a;
return a; // NRVO, no copy constructor call
}
A a;
A gun()
{
return a; // cannot perform NRVO, rewrite to: return (A __tmp; __tmp.copyCtor(a));
}
void main()
{
A a = fun();
A b = gun();
}
- 함수에서 매개변수를 값으로 반환하는데 **NRVO(명명된 반환 값 최적화, Named Return Value Optimization)**를 수행할 수 없을 때:
return a;는(A __tmp; __tmp.copyCtor(a));처럼 다시 쓰여요.
Disabled Copying (복사 금지)
struct에 복사 생성자가 정의되면(또는 @disable로 표시되면), 컴파일러는 그 struct에 대해 기본 복사/블릿(blit) 생성자를 더 이상 암시적으로 생성하지 않습니다.
struct A
{
int[] a;
this(ref return scope A rhs) {}
}
void fun(immutable A) {}
void main()
{
immutable A a;
fun(a); // error: copy constructor cannot be called with types (immutable) immutable
}
struct A
{
@disable this(ref A);
}
void main()
{
A a;
A b = a; // error: copy constructor is disabled
}
A a; A b = a;에서 복사 생성자가 금지되어 있어 오류가 납니다.
그리고 공용체에 복사 생성자를 정의한 필드가 있다면, U 타입 객체를 복사로 초기화할 때마다 오류가 발행돼요. 겹쳐진(익명 공용체) 필드에도 같은 규칙이 적용됩니다.
struct S
{
this(ref S);
}
union U
{
S s;
}
void main()
{
U a;
U b = a; // error, could not generate copy constructor for U
}
Copy Constructor Attributes (복사 생성자 속성)
복사 생성자는 매개변수에 적용된 한정자(한정된 소스에서 복사)나 복사 생성자 자신의 한정자(한정된 대상으로 복사)로 오버로드할 수 있어요.
struct A
{
this(ref return scope A another) {} // 1 - mutable source, mutable destination
this(ref return scope immutable A another) {} // 2 - immutable source, mutable destination
this(ref return scope A another) immutable {} // 3 - mutable source, immutable destination
this(ref return scope immutable A another) immutable {} // 4 - immutable source, immutable destination
}
void main()
{
A a;
immutable A ia;
A a2 = a; // calls 1
A a3 = ia; // calls 2
immutable A a4 = a; // calls 3
immutable A a5 = ia; // calls 4
}
각각 가변/불변 소스와 대상의 네 조합에 대응합니다.
소스가 mutable, const, immutable 어느 것이든 같게 취급하고 싶다면 복사 생성자 매개변수에 inout 한정자를 쓰세요.
struct A
{
this(ref return scope inout A rhs) immutable {}
}
void main()
{
A r1;
const(A) r2;
immutable(A) r3;
// All call the same copy constructor because `inout` acts like a wildcard
immutable(A) a = r1;
immutable(A) b = r2;
immutable(A) c = r3;
}
inout이 와일드카드처럼 작동해서, 세 종류 모두 같은 복사 생성자를 호출해요.
Implicit Copy Constructors (암시적 복사 생성자)
다음 조건을 모두 만족하면 컴파일러가 struct S에 대해 복사 생성자를 암시적으로 생성합니다.
S가 복사 생성자를 명시적으로 선언하지 않았을 것S가 복사 생성자를 가진 직접 멤버를 최소 하나 정의하고, 그 멤버가 다른 멤버와 (union으로) 겹치지 않았을 것
위 제약을 만족하면 다음 복사 생성자가 생성됩니다.
this(ref return scope inout(S) src) inout
{
foreach (i, ref inout field; src.tupleof)
this.tupleof[i] = field;
}
생성된 복사 생성자가 타입 검사를 통과하지 못하면, 그것은 @disable 속성을 받게 돼요.
Struct Move Constructors (구조체 이동 생성자)
이동(move) 생성자는 복사 생성자와 아주 비슷해요. 차이는 복사 생성자가 원본을 복사하는 반면, 이동 생성자는 원본의 내용을 옮기고 원본의 수명은 끝난다는 점이에요.
이동 생성자를 선언한다면 복사 생성자도 함께 선언하세요.
어떤 생성자 선언이 다음 요건을 모두 만족하면 이동 생성자 선언입니다.
- 기본 인자가 없는 매개변수를 정확히 하나 취하고, 그 뒤에 기본 인자를 가진 매개변수들을 얼마든지 취합니다.
- 첫 매개변수가
ref매개변수가 아닙니다. - 첫 매개변수의 타입이
typeof(this)와 같은 타입(선택적으로 한정자가 붙은)이에요. - 템플릿 생성자 선언이 아닙니다.
struct A
{
this(ref return scope A rhs) {} // copy constructor
this(return scope A rhs) {} // move constructor
this(return scope const A rhs, int b = 7) {} // move constructor with default parameter
}
이동 생성자는 일반 생성자처럼 타입 검사됩니다.
이동 생성자의 첫 매개변수는 rvalue만 받아들입니다. lvalue는 __rvalue(Expression)을 써서 rvalue로 강제할 수 있어요.
이동 생성자가 정의되면, 다음 상황에서 암시적 호출이 삽입됩니다.
struct A
{
int[] arr;
this(return scope A rhs) // move constructor
{
arr = rhs.arr;
rhs.arr = null; // do not leave dangling reference to array
}
this(ref return scope A rhs) { assert(0); }
}
void main()
{
A a;
a.arr = [1, 2];
A b = __rvalue(a); // move constructor gets called
b.arr[] += 1;
assert(a.arr is null); // a.arr is gone
assert(b.arr == [2, 3]);
}
- 변수가 명시적으로 초기화될 때:
A b = __rvalue(a);처럼 rvalue로 만들어 넘기면 이동 생성자가 호출되고,a.arr은 이제null이에요.
struct A
{
this(return scope A another) {} // move constructor
this(ref return scope A rhs) { assert(0); }
}
void fun(A a) {}
void main()
{
A a;
fun(__rvalue(a)); // move constructor gets called
// `a` is no longer a valid object
}
- 매개변수가 함수에 값으로 전달될 때:
fun(__rvalue(a));는 이동 생성자를 호출하고, 이후a는 더 이상 유효한 객체가 아니에요.
Disabled Moving (이동 금지)
struct에 이동 생성자가 정의되면(또는 @disable로 표시되면), 컴파일러는 그 struct에 대해 기본 이동 생성자를 더 이상 암시적으로 생성하지 않습니다.
struct A
{
this(ref A);
@disable this(A);
}
void main()
{
A a;
A b = __rvalue(a); // error: move constructor is disabled
}
공용체에 이동 생성자를 정의한 필드가 있다면, U 타입 객체를 이동으로 초기화할 때마다 오류가 발행돼요. 겹쳐진(익명 공용체) 필드에도 같은 규칙이 적용됩니다.
struct S
{
this(ref S);
this(S);
}
union U
{
S s;
}
void main()
{
U a;
U b = __rvalue(a); // error, could not generate move constructor for U
}
Move Constructor Attributes (이동 생성자 속성)
이동 생성자는 매개변수에 적용된 한정자(한정된 소스에서 이동)나 이동 생성자 자신의 한정자(한정된 대상으로 이동)로 오버로드할 수 있어요.
struct A
{
this(ref return scope A another) { assert(0); } // copy constructor
this(return scope A another) {} // 1 - mutable source, mutable destination
this(return scope immutable A another) {} // 2 - immutable source, mutable destination
this(return scope A another) immutable {} // 3 - mutable source, immutable destination
this(return scope immutable A another) immutable {} // 4 - immutable source, immutable destination
}
void main()
{
A a;
immutable A ia;
A a2 = __rvalue(a); // calls 1
A a3 = __rvalue(ia); // calls 2
A b;
immutable A ib;
immutable A b4 = __rvalue(b); // calls 3
immutable A b5 = __rvalue(ib); // calls 4
}
struct A
{
this(ref return scope inout A rhs) immutable { assert(0); }
this(return scope inout A rhs) immutable {}
}
void main()
{
A r1;
const(A) r2;
immutable(A) r3;
// All call the same move constructor because `inout` acts like a wildcard
immutable(A) a = __rvalue(r1);
immutable(A) b = __rvalue(r2);
immutable(A) c = __rvalue(r3);
}
여기서도 inout을 쓰면 mutable, const, immutable 소스를 같은 이동 생성자로 처리합니다.
Implicit Move Constructors (암시적 이동 생성자)
다음 조건을 모두 만족하면 컴파일러가 struct S에 대해 이동 생성자를 암시적으로 생성합니다.
S가 이동 생성자를 명시적으로 선언하지 않았을 것S가 이동 생성자를 가진 직접 멤버를 최소 하나 정의하고, 그 멤버가 다른 멤버와 (union으로) 겹치지 않았을 것
위 제약을 만족하면 다음 이동 생성자가 생성됩니다.
this(return scope inout(S) src) inout
{
foreach (i, ref inout field; src.tupleof)
this.tupleof[i] = __rvalue(field);
}
생성된 이동 생성자가 타입 검사를 통과하지 못하면 @disable 속성을 받아요.
import core.stdc.stdio;
struct T
{
int i;
inout this(ref inout T t) { this.i = t.i - 1; printf("this(ref T)\n"); }
inout this(inout T t) { this.i = t.i + 1; printf("this(T)\n"); }
}
struct S
{
T t;
}
void main()
{
S s;
s.t.i = 3;
S u = s;
printf("u.t.i = %d\n", u.t.i);
assert(u.t.i == 2);
S v = __rvalue(u);
printf("v.t.i = %d\n", v.t.i);
assert(v.t.i == 3);
}
이 예시는 복사와 이동을 모두 가진 구조체의 동작을 보여줘요. T는 ref 복사 생성자(this(ref T), 값에 1을 뺌)와 값 이동 생성자(this(T), 값에 1을 더함)를 가집니다. S u = s;는 복사라 u.t.i가 3-1=2가 되고, S v = __rvalue(u);는 이동이라 v.t.i가 2+1=3이 돼요.
Struct Postblits (구조체 Postblit)
Postblit:
this ( this ) MemberFunctionAttributesopt FunctionBody
this ( this ) MemberFunctionAttributesopt MissingFunctionBody
경고: 원래 계획은 복사 생성자가 postblit 생성자를 대체하고, postblit 생성자는 레거시 코드에만 남기는 것이었어요. 그런데 druntime에서 동적 배열과 연관 배열을 처리하는 컴파일러 훅이 아직 복사 생성자를 제대로 지원하도록 모두 갱신되지 않았습니다(issue #20970). 그래서 동적 배열이나 연관 배열에 쓰일 수 있는 타입은 복사 생성자보다 postblit 생성자를 사용해야 해요. 복사 생성자는 동적 배열의 원소나 연관 배열의 키/값에서 호출되어야 할 모든 상황에서 호출되지 않을 수 있는데, postblit 생성자는 그런 문제가 없습니다.
호환성 때문에, 복사 생성자와 postblit을 둘 다 명시적으로 정의한 struct는 암시적 복사에 postblit만 사용합니다. 다만 postblit이 금지되어 있으면 복사 생성자를 사용해요. 구조체가 복사 생성자를 정의하면서 postblit을 정의한 필드를 가질 경우 postblit이 우선한다는 deprecation 경고가 나옵니다.
**복사 구성(copy construction)**은 같은 타입의 다른 인스턴스에서 구조체 인스턴스를 초기화하는 것으로 정의됩니다. 복사 구성은 두 부분으로 나뉘어요.
- **필드를 블릿(blit)**하는 것, 즉 비트를 복사하는 부분
- 결과에 대해 postblit을 실행하는 부분
첫 번째 부분은 언어가 자동으로 처리하고, 두 번째 부분은 구조체에 postblit 함수가 정의되어 있을 때 실행돼요. postblit은 대상 구조체 객체에만 접근할 수 있고 원본에는 접근하지 못합니다. 그 역할은 참조한 데이터의 복사본을 만들거나 참조 횟수를 늘리는 등 대상을 필요에 따라 '고쳐 주는(fix up)' 일이에요.
struct S
{
int[] a; // array is privately owned by this instance
this(this)
{
a = a.dup;
}
}
구조체 postblit을 금지하면 그 객체는 복사가 불가능해져요.
struct T
{
@disable this(this); // disabling makes T not copyable
}
struct S
{
T t; // uncopyable member makes S also not copyable
}
void main()
{
S s;
S t = s; // error, S is not copyable
}
T가 @disable this(this);로 복사 불가능해져서, T를 필드로 가진 S도 함께 복사 불가능이 되는 거예요.
구조체 배치에 따라 컴파일러는 내부적으로 다음 postblit 함수들을 생성할 수 있어요.
void __postblit()— 컴파일러가 명시적으로 정의된this(this)postblit에 이 이름을 붙여서, 일반 함수처럼 정확히 취급해요. 구조체가 postblit을 정의하면 (서명이 어떻든)__postblit이라는 이름의 함수는 정의할 수 없어요. 이름이 충돌해서 컴파일 오류가 나거든요.void __fieldPostblit()— 구조체X가 (명시적이든 암시적이든) postblit을 정의한 구조체 멤버를 최소 하나 가지면,X에 대한 필드 postblit이 생성돼요. 이는 구조체 필드들의 하위 postblit을 선언 순서대로 모두 호출합니다.void __aggrPostblit()— 구조체가 명시적으로 정의한 postblit을 갖고 구조체 멤버 중 postblit을 가진 것이 최소 1개 있으면, 먼저__fieldPostblit을 부르고 나서__postblit을 부르는 집계(aggregated) postblit이 생성돼요.void __xpostblit()— 필드/집계 postblit은 구조체를 위해 생성되긴 하지만 실제 구조체 멤버는 아니에요. 그것들을 호출할 수 있게 컴파일러가 내부적으로 가장 포괄적인 생성 postblit을 가리키는__xpostblit별칭을 구조체 멤버로 만들어 둡니다.
// struct with alias __xpostblit = __postblit
struct X
{
this(this) {}
}
// struct with alias __xpostblit = __fieldPostblit
// which contains a call to X.__xpostblit
struct Y
{
X a;
}
// struct with alias __xpostblit = __aggrPostblit which contains
// a call to Y.__xpostblit and a call to Z.__postblit
struct Z
{
Y a;
this(this) {}
}
void main()
{
// X has __postblit and __xpostblit (pointing to __postblit)
static assert(__traits(hasMember, X, "__postblit"));
static assert(__traits(hasMember, X, "__xpostblit"));
// Y does not have __postblit, but has __xpostblit (pointing to __fieldPostblit)
static assert(!__traits(hasMember, Y, "__postblit"));
static assert(__traits(hasMember, Y, "__xpostblit"));
// __fieldPostblit is not a member of the struct
static assert(!__traits(hasMember, Y, "__fieldPostblit"));
// Z has __postblit and __xpostblit (pointing to __aggrPostblit)
static assert(__traits(hasMember, Z, "__postblit"));
static assert(__traits(hasMember, Z, "__xpostblit"));
// __aggrPostblit is not a member of the struct
static assert(!__traits(hasMember, Z, "__aggrPostblit"));
}
주석을 따라가 보면, X는 __postblit(그리고 그것을 가리키는 __xpostblit)을 갖고, Y는 X.__xpostblit을 호출하는 __fieldPostblit을 가리키는 __xpostblit을 가지며, Z는 Y.__xpostblit과 Z.__postblit을 함께 호출하는 __aggrPostblit을 가리키는 __xpostblit을 가져요. __fieldPostblit과 __aggrPostblit은 구조체 멤버가 아니므로 hasMember로 찾을 수 없습니다.
this(this)를 정의하지도 않고, 이걸 전이적으로 정의하는 필드도 없는 구조체에는 위 postblit 중 어느 것도 정의되지 않아요. 구조체가 postblit을 (암시적·명시적으로) 정의하지 않았는데도 내부 생성 postblit과 같은 이름/서명을 쓰는 함수를 정의하면, 컴파일러는 그 함수들이 실제 postblit이 아님을 알아채고 복사 시 호출을 삽입하지 않아요.
struct X
{}
int a;
struct Y
{
int a;
X b;
void __fieldPostPostblit()
{
a = 42;
}
}
void main()
{
static assert(!__traits(hasMember, X, "__postblit"));
static assert(!__traits(hasMember, X, "__xpostblit"));
static assert(!__traits(hasMember, Y, "__postblit"));
static assert(!__traits(hasMember, Y, "__xpostblit"));
Y y;
auto y2 = y;
assert(a == 0); // __fieldPostBlit does not get called
}
Y.__fieldPostPostblit은 이름이 비슷하지만 실제 postblit이 아니라서, auto y2 = y;로 복사돼도 호출되지 않고 a는 0으로 남아요.
postblit은 오버로드할 수 없습니다. postblit을 두 개 이상 정의하면, 서명이 달라도 컴파일러가 둘 다 __postblit이라는 이름을 붙여 함수 이름 충돌 오류를 내요.
struct X
{
this(this) {}
this(this) const {} // error: function X.__postblit conflicts with function X.__postblit
}
이제 한정자(qualifier)별 postblit 동작을 살펴볼게요.
- postblit을
const로 한정하면(this(this) const;나const this(this);) mutable·const·immutable객체 어디에서든 호출은 성공해요. 다만 postblit이 객체를const로 보기 때문에 객체를 수정하지는 못합니다. 그래서constpostblit은 쓰임새가 제한적이에요.
struct S
{
int n;
this(this) const
{
import std.stdio : writeln;
writeln("postblit called");
//++n; // error: cannot modify this.n in `const` function
}
}
void main()
{
S s1;
auto s2 = s1;
const S s3;
auto s4 = s3;
immutable S s5;
auto s6 = s5;
}
this(this) const 안에서 ++n;을 시도하면 불변식 수정이라 오류가 나요.
- postblit을
immutable로 한정하면(this(this) immutable이나immutable this(this);) 코드는 **잘못된 형태(ill-formed)**가 돼요.immutablepostblit은 컴파일 단계를 통과하지만 실제로는 호출될 수 없습니다.
struct Y
{
// not invoked anywhere, no error is issued
this(this) immutable
{ }
}
struct S
{
this(this) immutable
{ }
}
void main()
{
S s1;
auto s2 = s1; // error: immutable method `__postblit` is not callable using a mutable object
const S s3;
auto s4 = s3; // error: immutable method `__postblit` is not callable using a mutable object
immutable S s5;
auto s6 = s5; // error: immutable method `__postblit` is not callable using a mutable object
}
auto s2 = s1; 등에서 immutable 메서드 __postblit을 가변 객체로 호출할 수 없다며 오류가 나요.
- postblit을
shared로 한정하면(this(this) shared나shared this(this);) shared 객체만 postblit을 호출할 수 있고, unshared 객체에는 컴파일 타임 오류가 납니다.
struct S
{
this(this) shared
{ }
}
void main()
{
S s1;
auto s2 = s1; // error: shared method `__postblit` is not callable using a non-shared object
const S s3;
auto s4 = s3; // error: shared method `__postblit` is not callable using a non-shared object
immutable S s5;
auto s6 = s5; // error: shared method `__postblit` is not callable using a non-shared object
// calling the shared postblit on a shared object is accepted
shared S s7;
auto s8 = s7;
}
shared S s7; auto s8 = s7;처럼 shared 객체에서는 호출이 허용돼요.
한정 없는(unqualified) postblit은 구조체가 immutable이나 const로 인스턴스화되어도 호출되지만, shared로 인스턴스화하면 오류가 나요.
struct S
{
int n;
this(this) { ++n; }
}
void main()
{
immutable S a; // shared S a; => error : non-shared method is not callable using a shared object
auto a2 = a;
import std.stdio: writeln;
writeln(a2.n); // prints 1
}
postblit 관점에서 보면, 구조체 정의를 한정하는 것은 postblit을 명시적으로 한정하는 것과 같은 결과를 냅니다.
다음 표는 postblit을 성공적으로 호출하기 위해 필요한 객체 타입에 따라 postblit에 붙는 한정자들의 가능한 조합을 모두 보여줘요.
const와 immutable을 함께 사용해 postblit을 명시적으로 한정할 때는(this(this) const immutable; 같은 형태) 선언 순서는 상관없어요. 다만 지금은 필드 postblit 등 다른 요소와 상호작용해서 구체적인 동작이 문서에서 이어집니다.
구조체 S와 필드 postblit을 가진 구조체를 한정자로 감싼 상황을 살펴봐요.
struct S
{
this(this)
{ }
}
// `__xpostblit` aliases the aggregated postblit so the `const` applies to it.
// However, the aggregated postblit calls the field postblit which does not have
// any qualifier applied, resulting in a qualifier mismatch error
const struct B
{
S a; // error : mutable method B.__fieldPostblit is not callable using a const object
this(this)
{ }
}
// `__xpostblit` aliases the field postblit; no error
const struct B2
{
S a;
}
// Similar to B
immutable struct C
{
S a; // error : mutable method C.__fieldPostblit is not callable using a immutable object
this(this)
{ }
}
// Similar to B2, compiles
immutable struct C2
{
S a;
}
S는 한정 없는 postblit을 가지므로,const struct B처럼 쓸 때 (__xpostblit이 집계 postblit을 가리키면서) 그 집계 postblit이 한정 없는 필드 postblit을 호출하려다 한정자 불일치 오류가 나요.- 반면
const struct B2처럼 필드 postblit만 있고 사용자 postblit이 없으면__xpostblit이 필드 postblit을 가리키므로 오류가 없습니다.immutable struct C2도 마찬가지로 컴파일돼요. immutable struct C는B와 비슷하게 오류가 납니다.
위 상황의 오류들은 생성된 코드에 대한 것이라 줄 번호(line number)가 없어요.
한편 shared로 구조체 전체를 한정하면 이 속성이 생성된 postblit에 올바르게 전파됩니다.
shared struct A
{
this(this)
{
import std.stdio : writeln;
writeln("the shared postblit was called");
}
}
struct B
{
A a;
}
void main()
{
shared B b1;
auto b2 = b1;
}
공용체는 postblit을 가진 필드를 가질 수 있지만, 공용체 자체가 postblit을 가지지는 않습니다. 공용체를 복사해도 어떤 필드의 postblit도 호출되지 않아요. 그 호출이 필요하다면 프로그래머가 명시적으로 삽입해야 합니다.
struct S
{
int count;
this(this)
{
++count;
}
}
union U
{
S s;
}
void main()
{
U a = U.init;
U b = a;
assert(b.s.count == 0);
b.s.__postblit;
assert(b.s.count == 1);
}
U b = a;로 복사해도 b.s.count는 0으로 남고, b.s.__postblit;을 직접 호출해야 1이 됩니다.
Member Functions (메서드)
구조체/공용체는 클래스처럼 비정적 멤버 함수를 가질 수 있어요. 이런 함수(인스턴스 메서드라고 불러요)는 구조체 인스턴스를 가리키는 숨겨진 this 매개변수를 가집니다.
메서드는 MemberFunctionAttributes를 가질 수 있어요. 예를 들어 immutable 메서드 같은 거죠.
인스턴스 메서드는 메서드가 const가 아니라도 rvalue 구조체 인스턴스에서 호출될 수 있어요.
struct S
{
int i;
int f() => ++i;
}
void main()
{
//S().i++; // cannot modify, `S().i` is not an lvalue
assert(S().f() == 1); // OK
}
S().i++;은 S().i가 lvalue가 아니라서 수정할 수 없지만, S().f()처럼 메서드를 호출하는 것은 허용됩니다.
Struct Destructors (구조체 소멸자)
소멸자는 객체가 스코프를 벗어날 때 또는 (기본적으로) 대입 전에 암시적으로 호출됩니다. 그 목적은 구조체 객체가 소유한 자원을 해제하는 거예요.
struct S
{
int i;
~this()
{
import std.stdio;
writeln("S(", i, ") is being destructed");
}
}
void main()
{
auto s1 = S(1);
{
auto s2 = S(2);
// s2 destructor called
}
S(3); // s3 destructor called
// s1 destructor called
}
두 번째 스코프가 끝날 때 s2가, S(3); 임시 객체가, 마지막으로 s1이 소멸되는 순서를 확인할 수 있어요.
구조체에 자기 타입의 소멸자를 가진 필드가 있으면, 그 소멸자는 부모 소멸자의 끝에서 호출됩니다. 부모 소멸자가 없다면 컴파일러가 하나 생성해요. 마찬가지로 소멸자를 가진 구조체 타입의 정적 배열도, 배열이 스코프를 벗어날 때 각 원소의 소멸자가 호출됩니다.
struct S
{
char c;
~this()
{
import std.stdio;
writeln("S(", c, ") is being destructed");
}
}
struct Q
{
S a;
S b;
}
void main()
{
Q q = Q(S('a'), S('b'));
S[2] arr = [S('0'), S('1')];
// destructor called for arr[1], arr[0], q.b, q.a
}
Q의 필드 a, b와 배열 arr의 각 원소가 스코프를 벗어날 때 역순으로 소멸됩니다.
구조체 인스턴스의 소멸자는 destroy를 써서 더 일찍 호출할 수도 있어요. 다만 인스턴스가 스코프를 벗어날 때 소멸자가 다시 한번 호출된다는 점은 알아두세요.
구조체 소멸자는 RAII에 사용됩니다.
Union Field Destruction (공용체 필드 소멸)
공용체는 소멸자를 가진 필드를 가질 수 있어요. 그러나 공용체 자체는 절대 소멸자를 갖지 않습니다. 공용체가 스코프를 벗어나도 그 필드들의 소멸자는 호출되지 않아요. 그 호출이 필요하다면 프로그래머가 명시적으로 삽입해야 합니다.
struct S
{
~this()
{
import std.stdio;
writeln("S is being destructed");
}
}
union U
{
S s;
}
void main()
{
import std.stdio;
{
writeln("entering first scope");
U u = U.init;
scope (exit) writeln("exiting first scope");
}
{
writeln("entering second scope");
U u = U.init;
scope (exit)
{
writeln("exiting second scope");
destroy(u.s);
}
}
}
첫 번째 스코프에서는 u.s가 소멸되지 않지만, 두 번째 스코프에서는 destroy(u.s);를 명시적으로 불러서 소멸을 일으켜요.
Struct Invariants (구조체 불변식)
Invariant:
invariant ( ) BlockStatement
invariant BlockStatement
invariant ( AssertArguments ) ;
구조체 불변식(invariant)은 구조체 인스턴스의 멤버들 사이의 관계를 지정해요. 그 관계는 인스턴스와 공개 인터페이스(public interface) 사이의 어떤 상호작용에서든 유지되어야 합니다.
불변식은 멤버 함수 형태로 주어져요. 불변식 안에서 실행되는 모든 AssertExpression이 성공하면 불변식이 성립하는 것으로 정의됩니다.
struct Date
{
this(int d, int h)
{
day = d; // days are 1..31
hour = h; // hours are 0..23
}
invariant
{
assert(1 <= day && day <= 31);
assert(0 <= hour && hour < 24);
}
private:
int day;
int hour;
}
Date의 day는 1..31, hour는 0..23 범위여야 한다는 불변식을 확인할 수 있어요.
구조체 안에는 불변식이 여러 개 있을 수 있고, 어휘 순서대로 적용됩니다.
구조체 불변식은 (있다면) 구조체 생성자를 빠져나갈 때, 그리고 (있다면) 구조체 소멸자에 들어갈 때 반드시 성립해야 해요.
또한 모든 public이거나 export된 비정적 멤버 함수의 진입과 종료 시에도 성립해야 합니다. 불변식이 적용되는 순서는 다음과 같아요.
- 전제 조건(preconditions)
- 불변식(invariant)
- 함수 본문
- 불변식(invariant)
- 사후 조건(postconditions)
다만 구조체 인스턴스가 기본값(.init)으로 암시적으로 구성된다면 불변식이 성립할 필요는 없어요.
불변식이 성립하지 않으면 프로그램은 유효하지 않은 상태에 들어갑니다.
- 불변식이 런타임에 실행되는지 여부는 보통 컴파일러 스위치로 제어돼요.
- 불변식이 성립하지 않을 때의 동작은 보통 AssertExpression이 실패했을 때와 같습니다.
public이거나 export된 비정적 멤버 함수는 불변식 안에서 호출할 수 없어요.
struct Foo
{
public void f() { }
private void g() { }
invariant
{
f(); // error, cannot call public member function from invariant
g(); // ok, g() is not public
}
}
f()는 public이라 불변식 안에서 호출하면 오류이지만, g()는 private라 호출할 수 있어요.
- 구조체 불변식 안에서 export된/공개 멤버 함수를 간접적으로 호출하지 마세요. 무한 재귀에 빠질 수 있어요.
- 불변식 안에서 부작용에 의존하지 마세요. 불변식은 실행될 수도, 안 될 수도 있기 때문이에요.
- 불변식이 있는 구조체에 가변 public 필드는 두지 않는 게 좋아요. 그러면 불변식이 공개 인터페이스를 검증할 수 없거든요.
Identity Assignment Overload (자기 대입 오버로드)
복사 구성이 같은 타입의 다른 객체에서 객체를 초기화하는 일을 맡는다면, **대입(assignment)**은 소스 객체의 내용을 대상 객체 위에 복사하는 것으로 정의됩니다. 이 과정에서 대상 객체에 소멸자가 있으면 호출해요.
struct S { ... } // S has postblit or destructor
S s; // default construction of s
S t = s; // t is copy-constructed from s
t = s; // t is assigned from s
여기서 S t = s;는 s로부터의 복사 구성이고, t = s;는 s로부터의 대입이에요.
구조체 대입은 의미상 다음과 동등하다고 정의됩니다.
t.opAssign(s);
여기서 opAssign은 S의 멤버 함수예요.
다음 조건 중 하나라도 해당하면 구조체에는 **자기 대입 오버로드(identity assignment overload)**가 필요합니다.
- 소멸자가 있을 때
- postblit이 있을 때
- 자기 대입 오버로드를 가진 필드가 있을 때
자기 대입 오버로드가 필요한데 없으면, 다음처럼 낮춰진(lowering) 형태의 함수를 자동으로 생성해요.
ref S opAssign(ref S s)
{
S tmp = this; // bitcopy this into tmp
this = s; // bitcopy s into this
tmp.__dtor(); // call destructor on tmp
return this;
}
S tmp = this;로 this를 tmp에 비트 복사하고, this = s;로 s를 this에 비트 복사한 뒤, tmp.__dtor();로 tmp의 소멸자를 호출하고 this를 반환합니다. 이 생성 함수가 구현하는 의미는 사용자가 직접 정의한 것과 동등하지만, 사용자 정의 버전이 더 효율적일 수 있어요.
커스텀 opAssign이 더 효율적인 이유 중 하나는 구조체가 지역 버퍼에 대한 참조를 갖고 있을 때예요.
struct S
{
int[] buf;
int a;
ref S opAssign(ref const S s) return
{
a = s.a;
return this;
}
this(this)
{
buf = buf.dup;
}
}
여기 S는 임시 작업 공간 buf[]를 가져요. 컴파일러가 생성한 opAssign은 그것을 쓸데없이 해제하고 재할당하겠지만, 커스텀 opAssign은 기존 저장 공간을 재사용합니다.
Alias This
AliasThis:
alias Identifier this ;
alias this = Identifier ;
AliasThis 선언은 서브타입으로 삼을 멤버를 이름 짓습니다. Identifier가 그 멤버를 가리켜요.
구조체나 공용체 인스턴스는 AliasThis 멤버로 암시적으로 변환될 수 있어요.
struct S
{
int x;
alias x this;
}
int foo(int i) { return i * 2; }
void main()
{
S s;
s.x = 7;
int i = -s;
assert(i == -7);
i = s + 8;
assert(i == 15);
i = s + s;
assert(i == 14);
i = 9 + s;
assert(i == 16);
i = foo(s); // implicit conversion to int
assert(i == 14);
}
alias x this;로 int 멤버 x를 서브타입으로 삼아서, -s, s + 8, foo(s)처럼 구조체를 int처럼 쓸 수 있게 됐어요.
멤버가 클래스나 구조체라면, 정의되지 않은 조회가 AliasThis 멤버로 전달됩니다.
class Foo
{
int baz = 4;
int get() { return 7; }
}
struct Bar
{
Foo foo;
alias foo this;
}
void main()
{
Bar bar = Bar(new Foo());
int i = bar.baz;
assert(i == 4);
i = bar.get();
assert(i == 7);
}
bar.baz와 bar.get()이 foo 멤버로 전달돼서 동작해요.
Identifier가 매개변수가 없는 프로퍼티 멤버 함수를 가리키면, 변환과 정의되지 않은 조회가 그 함수의 반환값으로 전달됩니다.
struct S
{
int x;
@property int get()
{
return x * 2;
}
alias get this;
}
void main()
{
S s;
s.x = 2;
int i = s;
assert(i == 4);
}
alias get this;라서 int i = s;가 s.get()의 반환값(4)으로 변환돼요.
구조체 선언이 opCmp나 opEquals 메서드를 정의하면, 그것이 AliasThis 멤버의 것보다 우선합니다. 다만 opCmp와 달리 opEquals는 사용자가 정의하지 않아도 구조체 선언에 대해 암시적으로 정의된다는 점을 주의하세요. 그래서 AliasThis 멤버의 opEquals를 쓰고 싶다면 그것을 명시적으로 정의해야 해요.
struct S
{
int a;
bool opEquals(S rhs) const
{
return this.a == rhs.a;
}
}
struct T
{
int b;
S s;
alias s this;
}
void main()
{
S s1, s2;
T t1, t2;
assert(s1 == s2); // calls S.opEquals
assert(t1 == t2); // calls compiler generated T.opEquals that implements member-wise equality
assert(s1 == t1); // calls s1.opEquals(t1.s);
assert(t1 == s1); // calls t1.s.opEquals(s1);
}
t1 == t2는 컴파일러가 만든 T.opEquals(멤버 단위 동등 비교)를 부르고, s1 == t1은 s1.opEquals(t1.s)를 부르는 식으로 흘러가요.
struct U
{
int a;
bool opCmp(U rhs) const
{
return this.a < rhs.a;
}
}
struct V
{
int b;
U u;
alias u this;
}
void main()
{
U u1, u2;
V v1, v2;
assert(!(u1 < u2)); // calls U.opCmp
assert(!(v1 < v2)); // calls U.opCmp because V does not define an opCmp method
// so the alias this of v1 is employed; U.opCmp expects a
// paramter of type U, so alias this of v2 is used
assert(!(u1 < v1)); // calls u1.opCmp(v1.u);
assert(!(v1 < u1)); // calls v1.u.opCmp(v1);
}
v1 < v2는 V가 opCmp를 정의하지 않으므로 AliasThis 멤버 u의 opCmp를 씁니다.
AliasThis에는 속성(attributes)이 무시되고, 구조체/공용체는 AliasThis 멤버를 하나만 가질 수 있어요.
Nested Structs (중첩 구조체)
다음 중 하나라도 해당하면 그 구조체는 **중첩 구조체(nested struct)**예요.
- 함수의 스코프 안에서 선언되었거나
- 로컬 함수의 스택 데이터를 가리키는 템플릿 인자를 하나 이상 가진 템플릿 구조체이거나
중첩 구조체는 멤버 함수를 가질 수 있고, **숨겨진 필드를 통해 바깥 스코프의 문맥(context)**에 접근할 수 있어요.
void foo()
{
int i = 7;
struct SS
{
int x,y;
int bar() { return x + i + 1; }
}
SS s;
s.x = 3;
s.bar(); // returns 11
}
SS.bar()가 바깥의 i를 읽어서 x + i + 1을 계산해요.
static 속성을 붙이면 구조체가 중첩되는 것을 막을 수 있어요. 그러면 구조체는 바깥 스코프에 접근할 수 없습니다.
void foo()
{
int i = 7;
static struct SS
{
int x, y;
int bar()
{
return i; // error, SS is not a nested struct
}
}
}
static struct SS는 중첩 구조체가 아니라서 return i;처럼 바깥 변수에 접근하면 오류가 나요.
경고: 중첩 구조체에서는
.init이 기본 초기화(default initialization)와 같지 않습니다.
더 알아보기 (Learn more)
- D 언어 사양 전체 목차: https://dlang.org/spec/
- 클래스(Classes) 챕터: https://dlang.org/spec/class.html
- 공용체·구조체 선언 문법(grm): https://dlang.org/spec/grammar.html
- 표현식(Expressions) — 리터럴·연산자 오버로딩: https://dlang.org/spec/expression.html
- 속성(Attributes) — const/immutable/shared/ref: https://dlang.org/spec/attribute.html
- 함수(Functions) — NRVO, 이동/복사 생성자 관련: https://dlang.org/spec/function.html