형식 한정자
형식 한정자 (Type Qualifiers)
D 언어의 형식 한정자(Type Qualifiers)는 데이터를 "바꿀 수 있는가", "누가 바꿀 수 있는가"를 선언으로 표현하는 방법이에요. 코드 안에서 어떤 데이터가 절대 변하지 않는다는 걸 명시하면, 컴파일러가 그것을 근거로 최적화하거나 실수를 막아줘요. 이번 장에서는 const, immutable, shared, inout 이 네 가지 한정자가 무엇이고, 언제 어떻게 쓰는지를 차근차근 살펴볼게요.
본문
형식 한정자(Type Qualifiers)는 형식 생성자(TypeCtor)를 적용해서 형식을 수정해요. TypeCtor에는 const, immutable, shared, inout 네 가지가 있죠. 각 한정자는 다음 대상에 전이적(transitive)으로 적용돼요:
- 파생 데이터 형식(derived data type)의 모든 구성 요소 형식(component types)
- 집합체(aggregate) 형식의 모든 필드
즉, 한정자를 붙이면 그 형식을 통해 닿을 수 있는 모든 데이터에까지 한정이 미친다고 이해하면 돼요.
Const와 Immutable (Const and Immutable)
데이터 구조나 인터페이스를 검토할 때, 어떤 데이터는 변하지 않을 거라 기대할 수 있고 어떤 데이터는 변할 수 있으며, 또 누가 그 데이터를 바꿀 수 있는지를 쉽게 구분할 수 있다면 아주 유용해요. 이 역할을 언어의 형식 체계(type system)가 해줘요. 데이터는 const나 immutable로 표시할 수 있고, 기본값은 바꿀 수 있는 상태, 즉 가변(mutable) 이에요.
immutable은 절대 변하지 않는 데이터에 적용해요. 불변(immutable) 데이터 값은 한 번 만들어지면 프로그램이 실행되는 내내 그대로 유지되죠. 불변 데이터는 ROM(읽기 전용 메모리)이나 하드웨어가 읽기 전용으로 표시한 메모리 페이지에 둘 수도 있어요. 데이터가 변하지 않기 때문에 프로그램 최적화의 기회가 많아지고, 함수형 프로그래밍 스타일에서도 유용하게 쓰여요.
const는 그 const 참조를 통해서는 데이터를 바꿀 수 없게 만드는 한정자에요. 하지만 같은 데이터에 대한 다른 참조로는 바꿀 수 있죠. const는 주로 "데이터를 수정하지 않겠다"고 약속하는 인터페이스를 통해 데이터를 전달할 때 사용해요.
immutable과 const는 둘 다 전이적(transitive) 이에요. 즉, 불변 참조를 통해 도달할 수 있는 모든 데이터도 불변이고, const 참조를 통해서 도달할 수 있는 데이터도 마찬가지로 const라는 뜻이에요.
Immutable 저장 클래스 (Immutable Storage Class)
가장 단순한 형태는 immutable을 저장 클래스(storage class) 로 쓰는 거예요. 값이 절대 변하지 않는 변수를 선언할 수 있어요.
immutable int x = 3; // x is set to 3
//x = 4; // error, x is immutable
// x is initialized by a compile-time constant,
// and it doesn't change, so the compiler knows its value
static assert(x == 3);
char[x] s;
static assert(s.length == 3);
형식은 초기화 식(initializer)에서 추론할 수도 있어요.
immutable y = 4; // y is of type int
y = 5; // error, y is immutable
초기화 식이 없다면, 불변 변수는 해당하는 shared static constructor에서 초기화할 수 있어요.
immutable int z;
void main()
{
assert(z == 3);
//z = 4; // error, z is immutable
}
shared static this()
{
z = 3; // ok, can initialize immutable variable that doesn't
// have a static initializer
}
지역(local)이 아니거나 static이 아닌 불변 선언의 초기화 식은 컴파일 타임에 평가할 수 있어야 해요.
immutable x = 3 * 4;
static assert(x == 12);
int foo(int f) { return f * 3; }
int i = 5;
//immutable y = i + 1; // error, cannot evaluate `i` at compile time
immutable z = foo(2) + 1; // ok, foo(2) can be evaluated at compile time
static assert(z == 7);
반면 static이 아닌 지역 불변 선언의 초기화 식은 런타임에 평가돼요.
int foo(int f)
{
immutable x = f + 1; // evaluated at run time
x = 3; // error, x is immutable
}
immutable은 전이적이기 때문에, 불변 변수가 가리키는 데이터도 역시 불변이에요.
immutable char[] s = "foo";
s[0] = 'a'; // error, s refers to immutable data
s = "bar"; // error, s is immutable
불변 선언은 lvalue로 나타날 수 있어요. 즉 주소를 취할 수 있고 저장 공간(storage)을 차지한다는 뜻이에요.
참고: 명시 상수 (Manifest Constants).
Const 저장 클래스 (Const Storage Class)
const 선언은 immutable 선언과 거의 똑같은데, 다음 두 가지가 달라요.
const선언이 참조하는 데이터는 그const선언을 통해서는 바꿀 수 없지만, 같은 데이터에 대한 다른 참조로는 바꿀 수 있어요.const선언의 형식 자체가 const예요.
즉, const는 "이 경로로는 못 바꾼다"는 읽기 전용 뷰(view) 를 만드는 것에 가깝다고 볼 수 있어요.
Immutable 형식 (Immutable Type)
절대 값이 변하지 않을 데이터는 immutable 형식으로 지정할 수 있어요. immutable 키워드는 형식 한정자(type qualifier) 로도 쓸 수 있죠.
immutable(char)[] s = "hello";
immutable은 괄호로 감싸진 형식에 적용돼요. 그래서 s 자체에는 새 값을 할당할 수 있지만, s[]의 내용은 바꿀 수 없어요.
s[0] = 'b'; // error, s[] is immutable
s = null; // ok, s itself is not immutable
불변성은 전이적이에요. 즉 불변 형식에서 참조할 수 있는 어떤 것에도 적용된다는 뜻이에요.
immutable(char*)** p = ...;
p = ...; // ok, p is not immutable
*p = ...; // ok, *p is not immutable
**p = ...; // error, **p is immutable
***p = ...; // error, ***p is immutable
immutable을 저장 클래스로 쓰는 것은, 선언의 전체 형식에 대해 immutable을 형식 한정자로 쓰는 것과 같아요.
immutable int x = 3; // x is typed as immutable(int)
immutable(int) y = 3; // y is immutable
불변 데이터 만들기 (Creating Immutable Data)
첫 번째 방법은 이미 불변인 리터럴을 사용하는 거예요. 대표적인 게 문자열 리터럴인데, 문자열 리터럴은 언제나 불변(immutable)이에요.
auto s = "hello"; // s is immutable(char)[5]
char[] p = "world"; // error, cannot implicitly convert immutable
// to mutable
두 번째 방법은 데이터를 immutable로 캐스팅하는 거예요. 이때는 캐스팅 이후에 같은 데이터에 대한 가변 참조로 데이터를 수정하지 않을 것임을 프로그래머가 직접 보장해야 해요.
char[] s = ['a'];
s[0] = 'b'; // ok
immutable(char)[] p = cast(immutable)s; // ok, if data is not mutated
// through s anymore
s[0] = 'c'; // undefined behavior
immutable(char)[] q = cast(immutable)s.dup; // always ok, unique reference
char[][] s2 = [['a', 'b'], ['c', 'd']];
immutable(char[][]) p2 = cast(immutable)s2.dup; // dangerous, only the first
// level of elements is unique
s2[0] = ['x', 'y']; // ok, doesn't affect p2
s2[1][0] = 'z'; // undefined behavior
immutable(char[][]) q2 = [s2[0].dup, s2[1].dup]; // always ok, unique references
.idup 속성은 배열의 불변 복사본을 만들 때 편리하게 쓸 수 있어요.
auto p = s.idup;
p[0] = ...; // error, p[] is immutable
캐스트로 Immutable 또는 Const 제거하기 (Removing Immutable or Const with a Cast)
immutable이나 const 형식 한정자는 캐스트로 제거할 수 있어요.
immutable int* p = ...;
int* q = cast(int*)p;
하지만 그렇다고 데이터를 바꿔도 된다는 뜻은 아니에요.
*q = 3; // allowed by compiler, but result is undefined behavior
immutable 정합성(immutable-correctness)을 캐스트로 벗겨내는 능력이 필요한 경우가 있어요. 예를 들어 정적 형식 체계가 잘못되어 있고 고칠 수 없는 경우, 즉 수정할 수 없는 라이브러리의 코드를 참조할 때가 그렇죠. 캐스팅은 언제나 그렇듯 "무디지만 효과적인" 도구예요. immutable 정합성을 캐스트로 벗겨낼 때는, 컴파일러가 더 이상 정적으로 검증해 주지 않으므로 데이터의 불변성을 보장할 책임을 프로그래머가 직접 짊어져야 해요.
정의되지 않은 동작 (Undefined Behavior): const 한정자를 캐스트로 벗겨낸 다음 데이터를 수정하는 것 — 심지어 참조하는 데이터가 가변(mutable)인 경우에도 그래요. 이는 컴파일러와 프로그래머가 const만을 근거로 가정을 세울 수 있게 하기 위한 규칙이에요. 예를 들어 다음 코드에서는 f가 x를 바꾸지 않는다고 가정할 수 있어요.
void f(const int* a);
void main()
{
int x = 1;
f(&x);
assert(x == 1); // guaranteed to hold
}
Immutable 멤버 함수 (Immutable Member Functions)
불변 멤버 함수는 객체와 this 참조를 통해 참조되는 모든 것이 불변(immutable)이라는 것을 보장해요. 선언은 다음과 같이 해요.
struct S
{
int x;
void foo() immutable
{
x = 4; // error, `x` is immutable
this = S(); // error, `this` is immutable
}
}
여기서 주의할 점이 하나 있어요. 메서드의 왼쪽에 붙은 immutable은 반환 형식에는 적용되지 않아요.
struct S
{
immutable int[] bar() // bar is still immutable, return type is not!
{
}
}
반환 형식을 불변으로 만들고 싶다면 괄호로 감싸면 돼요.
struct S
{
immutable(int[]) bar() // bar is now mutable, return type is immutable.
{
}
}
반환 형식과 메서드 둘 다 불변으로 만들려면 이렇게 써요.
struct S
{
immutable(int[]) bar() immutable
{
}
}
참고: 메서드 속성을 가진 중첩 함수 (Nested Functions with Method Attributes).
Const 형식 (Const Type)
const 형식은 immutable 형식과 비슷한데, 차이는 const가 데이터의 읽기 전용 뷰(view) 를 이룬다는 점이에요. 같은 데이터에 대한 다른 별칭(alias)은 언제든 그 데이터를 바꿀 수 있어요.
Const 멤버 함수 (Const Member Functions)
const 멤버 함수는 멤버 함수의 this 참조를 통해 객체의 어느 부분도 바꿀 수 없는 함수를 말해요.
Inout
매개변수가 가변(mutable)인지, const인지, immutable인지에 따라서만 달라지고, 반환 형식도 그에 대응해 가변·const·immutable인 함수가 있다고 해볼게요. 그런 함수들은 inout 형식 생성자를 사용해 하나의 함수로 합칠 수 있어요. 다음 오버로드 집합을 봐요.
int[] slice(int[] a, int x, int y) { return a[x .. y]; }
const(int)[] slice(const(int)[] a, int x, int y) { return a[x .. y]; }
immutable(int)[] slice(immutable(int)[] a, int x, int y) { return a[x .. y]; }
이 함수들이 생성하는 코드는 서로 완전히 동일해요. inout 형식 생성자를 쓰면 이 셋을 하나로 합칠 수 있어요.
inout(int)[] slice(inout(int)[] a, int x, int y) { return a[x .. y]; }
inout 키워드는 가변, const, immutable, inout, inout const를 대신하는 와일드카드 역할을 해요. 함수를 호출할 때, 반환 형식의 inout 상태는 inout 매개변수로 전달한 인자의 형식 상태에 맞춰 바뀌어요.
inout은 inout으로 선언된 매개변수를 가진 함수 안에서 형식 생성자로도 쓸 수 있어요. inout으로 선언된 형식의 inout 상태는, inout 매개변수로 전달된 인자의 형식 상태에 맞춰 변경되죠.
inout(int)[] asymmetric(inout(int)[] input_data)
{
inout(int)[] r = input_data;
while (r.length > 1 && r[0] == r[$-1])
r = r[1..$-1];
return r;
}
inout 형식은 const나 inout const로는 암시적으로 변환될 수 있지만, 그 외의 형식으로는 변환되지 않아요. 다른 형식이 inout으로 암시적으로 변환될 수도 없죠. @safe 함수에서는 inout으로의 캐스팅이나 inout으로부터의 캐스팅이 허용되지 않아요.
void f(inout int* ptr)
{
const int* p = ptr;
int* q = ptr; // error
immutable int* r = ptr; // error
}
inout 매개변수 맞추기 (Matching an inout Parameter)
inout 매개변수를 가진 함수에 인자 집합이 "일치(match)"한다고 간주되는 경우는, 어떤 inout 인자 형식이 정확히 일치하거나, 또는 다음 조건을 충족할 때예요.
- 어떤 인자 형식도
inout형식으로 구성되어 있지 않다. - 가변,
const,immutable인자 형식이 각각 대응하는 매개변수의inout형식에 맞을 수 있다.
그런 일치가 발생하면, inout은 맞춰진 한정자들의 공통 한정자(common qualifier) 로 간주돼요. 매개변수가 두 개보다 많다면 공통 한정자 계산을 재귀적으로 적용해요.
두 형식 한정자의 공통 한정자(Common qualifier) 테이블은 다음과 같아요.
| mutable | const | immutable | inout | inout const | |
|---|---|---|---|---|---|
| mutable (= m) | m | c | c | c | c |
| const (= c) | c | c | c | c | c |
| immutable (= i) | c | c | i | wc | wc |
| inout (= w) | c | c | wc | w | wc |
| inout const (= wc) | c | c | wc | wc | wc |
그러면 반환 형식의 inout이 맞춰진 inout 한정자에 맞게 다시 쓰여요(re-written).
int[] ma;
const(int)[] ca;
immutable(int)[] ia;
inout(int)[] foo(inout(int)[] a) { return a; }
void test1()
{
// inout matches to mutable, so inout(int)[] is
// rewritten to int[]
int[] x = foo(ma);
// inout matches to const, so inout(int)[] is
// rewritten to const(int)[]
const(int)[] y = foo(ca);
// inout matches to immutable, so inout(int)[] is
// rewritten to immutable(int)[]
immutable(int)[] z = foo(ia);
}
inout(const(int))[] bar(inout(int)[] a) { return a; }
void test2()
{
// inout matches to mutable, so inout(const(int))[] is
// rewritten to const(int)[]
const(int)[] x = bar(ma);
// inout matches to const, so inout(const(int))[] is
// rewritten to const(int)[]
const(int)[] y = bar(ca);
// inout matches to immutable, so inout(int)[] is
// rewritten to immutable(int)[]
immutable(int)[] z = bar(ia);
}
참고: shared 형식은 inout과 맞출 수 없어요.
Shared
여러 스레드가 공유하도록 만들어진 가변(mutable) 데이터는 shared 한정자로 선언해야 해요. 이렇게 하면 데이터에 대한 동기화되지 않은 읽기·쓰기, 즉 데이터 레이스(data race)를 막을 수 있어요. shared 형식 속성은 const나 immutable처럼 전이적이에요.
shared int x;
shared(int)* p = &x;
//int* q = p; // error, q is not shared
기본 데이터 형식에 대해서는 보통 원자적(atomic) 연산으로 읽고 쓸 수 있어요. 이식성을 위해서는 core.atomic을 사용해요.
import core.atomic;
shared int x;
void fun()
{
//x++; // error, use atomicOp instead
x.atomicOp!"+="(1);
}
주의: 기본 설정에서는 shared 데이터에 대한 개별 읽기·쓰기 연산이 아직 오류로 취급되지 않아요. 이를 감지하려면 -preview=nosharedaccess 컴파일러 옵션을 사용해야 해요. 일반적인 초기화는 오류 없이 허용돼요.
import core.atomic;
int y;
shared int x = y; // OK
//x = 5; // write error with preview flag
x.atomicStore(5); // OK
//y = x; // read error with preview flag
y = x.atomicLoad(); // OK
assert(y == 5);
캐스팅 (Casting)
더 큰 형식을 다룰 때는 수동 동기화를 사용할 수 있어요. 그렇게 하려면 상호 배제(mutual exclusion)가 확립된 동안만 shared를 캐스트로 벗겨낼 수 있어요.
struct T;
shared T* x;
void fun()
{
synchronized
{
T* p = cast(T*)x;
// operate on `*p`
}
}
shared가 아닌 참조는, 캐스트 결과의 수명 동안 원본 데이터에 접근하지 않을 경우에만 shared로 캐스팅할 수 있어요.
class C {}
@trusted shared(C) create()
{
auto c = new C;
// work with c without it escaping
return cast(shared)c; // OK
}
전역 공유 변수 (Shared Global Variables)
전역(또는 static) shared 변수는 스레드 간에 접근 가능한 공통 저장 공간(common storage)에 저장돼요. 반면 전역 가변(mutable) 변수는 기본적으로 스레드 지역 저장 공간(thread-local storage) 에 저장돼요.
컴파일러 검사 없이 전역/static 데이터를 여러 스레드에 걸쳐 암시적으로 공유되도록 선언하려면 __gshared를 참고하세요.
한정자 조합하기 (Combining Qualifiers)
하나의 형식에 한정자를 두 개 이상 적용할 수도 있어요. 적용 순서는 중요하지 않아요. 예를 들어 한정되지 않은 형식 T가 있을 때, const shared T와 shared const T는 같은 형식이에요. 이 때문에 이 문서에서는 필요한 경우가 아니면 괄호 없이, 알파벳 순서로 한정자 조합을 표현해요.
이미 그 한정자를 가진 형식에 같은 한정자를 적용하는 것은 허용되지만 아무 효과도 없어요. 예를 들어 한정되지 않은 형식 T에 대해 shared(const shared T)는 const shared T라는 형식을 만들어내요.
immutable 한정자를 어떤 형식(한정됐든 아니든)에 적용하면 결과는 immutable T예요. immutable T에 어떤 한정자를 적용해도 결과는 immutable T이죠. 이렇게 해서 immutable은 한정자 조합의 고정점(fixed point) 이 되고, const(immutable(shared T)) 같은 형식은 아예 만들 수 없게 돼요.
alias SInt = shared int;
alias IInt = immutable int;
static assert(is(immutable(SInt) == IInt));
static assert(is(shared(IInt) == IInt));
T가 한정되지 않은 형식이라고 가정할 때, 아래 그림은 한정자들이 어떻게 조합되는지를 보여줘요 (immutable과의 조합은 생략했어요). 각 노드에 대해, 간선(edge)에 표시된 한정자를 적용하면 그 결과 형식에 도달해요.
원문에서는 이 조합 규칙을 그림(이미지)으로 제시합니다: Qualifier combination rules
암시적 한정자 변환 (Implicit Qualifier Conversions)
가변 간접 참조(mutable indirections)가 없는 값 — 가변 간접 참조가 있는 필드를 포함하지 않는 구조체(struct)도 포함해요 — 은 mutable, const, immutable, const shared, inout, inout shared 사이에서 암시적으로 변환될 수 있어요.
한정된 객체에 대한 참조는 다음 규칙에 따라 암시적으로 변환될 수 있어요.
원문에서는 변환 규칙을 그림(이미지)으로 제시합니다: Qualifier conversion rules
위 그림에서, 어떤 유향 경로(directed path)든 합법적인 암시적 변환이에요. 그림에 나온 한정자 조합 외에는 유효하지 않아요. 두 한정자 집합 사이에 유향 경로가 존재한다면, 그렇게 한정된 형식들을 qualifier-convertible이라 불러요. 같은 정보를 표로 보여주면 다음과 같아요.
참조 형식의 암시적 변환(Implicit Conversion of Reference Types):
| from/to | mutable | const | shared | inout | const shared | const inout | inout shared | const inout shared | immutable |
|---|---|---|---|---|---|---|---|---|---|
| mutable | ✔ | ✔ | |||||||
| const | ✔ | ||||||||
| shared | ✔ | ✔ | |||||||
| inout | ✔ | ✔ | ✔ | ||||||
| const shared | ✔ | ||||||||
| const inout | ✔ | ✔ | |||||||
| inout shared | ✔ | ✔ | ✔ | ||||||
| const inout shared | ✔ | ✔ | |||||||
| immutable | ✔ | ✔ | ✔ | ✔ | ✔ |
유일 표현식 (Unique Expressions)
위 표에 의해 암시적 변환이 허용되지 않는 경우에도, 표현식(Expression)은 다음과 같이 암시적으로 변환될 수 있어요.
- 표현식이 유일(unique) 하고, 그것이 전이적으로 참조하는 모든 표현식이 유일하거나 immutable이라면, mutable 또는 shared에서 immutable로 변환 가능.
- 표현식이 유일하고, 그것이 전이적으로 참조하는 모든 표현식이 유일, immutable 또는 shared라면, mutable에서 shared로 변환 가능.
- 표현식이 유일하다면, immutable에서 mutable로 변환 가능.
- 표현식이 유일하다면, shared에서 mutable로 변환 가능.
유일 표현식(Unique Expression) 은 그 표현식의 값에 대한 다른 참조가 존재하지 않고, 그것이 전이적으로 참조하는 모든 표현식도 역시 유일하거나 immutable인 표현식을 말해요. 예를 들어:
void main()
{
immutable int** p = new int*(null); // ok, unique
int x;
//immutable int** q = new int*(&x); // error, there may be other references to x
immutable int y;
immutable int** r = new immutable(int)*(&y); // ok, y is immutable
}
참고: 순수 팩토리 함수 (Pure Factory Functions).
그 외에 암시적 변환이 허용되지 않는 경우에는 캐스트 표현식(CastExpression)으로 강제 변환을 할 수 있어요. 다만 이는 @safe 코드에서는 할 수 없고, 그 정확성은 프로그래머가 직접 검증해야 해요.
더 알아보기 (Learn more)
- D 언어 사양: 형식 (Types) — 형식 전반과 TypeCtor의 정의
- D 언어 사양: 순수 함수 (Pure Functions) —
inout·불변 데이터와 관련된 순수 팩토리 함수 - D 언어 사양: 속성 (Attributes) —
__gshared같은 저장 클래스 관련 속성 - D 언어 사양: 용어집 (Glossary) — qualifier-convertible 등 용어 정의