D 언어 사양: 배열

D 언어 사양: 배열 (Arrays)

배열은 D 언어에서 가장 기본적이면서도 강력한 데이터 구조 중 하나예요. 정적 배열부터 동적 배열, 포인터 배열, 연관 배열까지 종류가 다양하고, 각각이 서로 다른 방식으로 메모리를 다루죠. 이번 장에서는 배열의 선언, 초기화, 접근, 그리고 D 만의 독특한 배열 연산들을 하나씩 살펴볼게요.

출처: https://dlang.org/spec/arrays.html

본문

D 언어에는 네 가지 종류의 배열이 있어요. 각각이 메모리와 길이를 다루는 방식이 다르니, 먼저 이 네 가지를 구분할 줄 알아야 해요.

문법 (Syntax) 설명
type[integer] 정적 배열 (Static arrays)
type[] 동적 배열 (Dynamic arrays)
type* 포인터 배열 (Pointer arrays)
type[type] 연관 배열 (Associative arrays)

정적 배열 (Static Arrays)

int[3] s;

정적 배열은 길이가 컴파일 시점에 고정되는 배열이에요. 실행 중에 길이를 바꿀 수 없죠.

정적 배열 선언에서 초기화 값이 함께 주어지면, 차원을 $ 기호로 쓸 수 있어요. 이 경우 컴파일러가 초기화 값으로부터 차원을 추론해 줘요.

타입 추론(type inference)을 사용할 때(예: auto)에도 $를 쓸 수 있어요. 그러면 차원은 $를 통해 추론되고, 요소 타입은 초기화 값으로부터 추론돼요.

int[$] a = [1, 2, 3];
static assert(is(typeof(a) == int[3]));

int[$][$] m = [[1, 2, 3], [4, 5, 6]];
static assert(is(typeof(m) == int[3][2]));

auto[$] b = [1, 2, 3];
static assert(is(typeof(b) == int[3]));

auto[$][$] c = [[1, 2], [3, 4]];
static assert(is(typeof(c) == int[2][2]));

다차원 정적 배열에서 추론은 가장 바깥쪽 정적 배열 차원부터 시작해요. 안쪽의 정적 배열 차원을 $로 썼다면 그 역시 초기화 값의 모양으로부터 추론 가능해야 해요. 그래야만 성립하지 않으면 오류가 돼요.

정적 배열의 전체 크기는 16Mb를 넘을 수 없어요.

차원이 0인 정적 배열도 허용돼요. 다만 그 경우 메모리 공간은 할당되지 않아요.

정적 배열은 **값 타입(value type)**이에요. 따라서 함수에 전달될 때나 함수에서 반환될 때 값으로 전달돼요.

모범 사례 (Best Practices): 더 큰 배열을 다룰 때는 동적 배열을 사용하세요. 정적 배열은 함수에 값으로 전달되기 때문에, 큰 배열은 스택 공간을 많이 차지할 수 있어요. 그럴 때는 동적 배열을 쓰는 게 낫죠.

동적 배열 (Dynamic Arrays)

int[] a;

동적 배열은 **길이(length)**와 배열 데이터를 가리키는 포인터로 구성돼요. 여러 동적 배열이 배열 데이터의 전체 또는 일부를 공유할 수 있어요.

모범 사례 (Best Practices): 가능한 한 포인터 배열 대신 동적 배열을 사용하세요. 동적 배열의 인덱싱은 경계 검사(bounds check)가 되어 있어서, 버퍼 언더플로나 오버플로 문제를 피할 수 있어요.

포인터 배열 (Pointers)

int* p;

포인터는 메모리에 연속된 여러 값을 조작할 수 있어요. 그러나 하나 이상의 값에 접근하는 것은 포인터 연산을 요구하기 때문에 @safe가 될 수 없어요. 이는 C와의 연동이나 특수한 시스템 작업을 위해 지원되는 거예요. 포인터에는 연관된 길이가 없기 때문에, 컴파일러나 런타임이 그에 대해 경계 검사 등을 할 방법이 없어요.

모범 사례 (Best Practices): 포인터의 대부분의 일반적인 용도는 동적 배열, ref 선언, out 매개변수, 그리고 참조 타입(reference types)으로 대체할 수 있어요.

배열 선언 (Array Declarations)

선언은 선언되는 식별자 앞에 나타나며 오른쪽에서 왼쪽으로 읽어요. 그래서:

int[] a;      // dynamic array of ints
int[4][3] b;  // array of 3 arrays of 4 ints each
int[][5] c;   // array of 5 dynamic arrays of ints.
int*[]*[3] d; // array of 3 pointers to dynamic arrays of pointers to ints
int[]* e;     // pointer to dynamic array of ints

배열 초기화와 리터럴 (Array Initializers & Literals)

ArrayInitializer가 동적 배열이나 정적 배열을 초기화해요:

auto a1 = [1,2,3];  // type is int[], with elements 1, 2, and 3
auto a2 = [1u,2,3]; // type is uint[], with elements 1u, 2u, and 3u
int[2] a3 = [1,2];  // type is int[2], with elements 1, and 2

배열 리터럴은 표현식(expression)이에요:

void f(int[] a);

f([1, 2]); // pass 2 elements
f([]); // pass an empty array

배열 비교 (Array Comparison)

정적 배열과 동적 배열에 대해:

  • .length 프로퍼티는 배열의 요소 수를 반환해요.
  • .ptr 프로퍼티는 배열의 첫 번째 요소를 가리키는 포인터를 반환해요.
  • a == b는 두 배열의 길이와 배열 요소를 모두 비교해요.
  • a is b는 각각의 .ptr과 각각의 .length를 비교해요. 정적 배열은 값 타입이므로 정체성(identity) 검사가 .ptr 필드를 비교하면 안 된다는 점에 주의하세요. 정적 배열에 대해 is를 쓰는 것은 더 이상 권장되지 않아요(deprecated).
int[3] s = [1, 2, 3];
int[] a = [1, 2, 3];

assert(s.length == 3);
assert(a.length == 3);
assert(s == a);
assert(s.ptr != a.ptr); // here `a` is not a slice of `s`
assert(s !is a);

참고: RelExpression.

배열 할당 (Array Assignment)

동적 배열과 포인터 배열에 대해 할 수 있는 연산은 크게 두 종류로 나뉘어요. 배열의 핸들(handle)에 영향을 주는 것배열의 내용(contents)에 영향을 주는 것이에요. 할당(assignment)은 이런 타입에 대해 핸들만 바꿔 줘요.

int* p;
int[3] s;
int[] a;

p = s.ptr; // p points to the first element of the array s.
assert(p !is null);
p = a.ptr;
assert(p is null);

// error, since the length of the array pointed to by p is unknown
//s = p;

//a = p;   // error, length unknown
a = s;     // a points to the elements of s
assert(a.ptr == s.ptr);

int[] b = [1, 2, 3];
a = b;     // a points to the same array as b does
assert(a.ptr == b.ptr);
assert(a is b);

참고: 위의 두 오류 줄은 포인터 슬라이싱을 사용하면 요소를 복사하도록 만들 수 있어요. 그러면 복사할 요소 수를 알 수 있기 때문이죠.

정적 배열 할당 (Static Array Assignment)

정적 배열은 동적 배열로부터 할당받을 수 있어요 — 데이터가 복사돼요. 런타임에 길이가 맞지 않으면 RangeError가 발생해요:

int[3] s;
int[] a;

//s = [1, 2]; // error
s = [1, 2, 3]; // OK
//s = [1, 2, 3, 4]; // error

a = [4, 5, 6];
s = a; // OK
assert(s == a);
assert(s.ptr != a.ptr);

a = [1, 2];
//s = a; // RangeError, length mismatch

a = s;
assert(a.ptr == s.ptr);
//s = a; // RangeError, overlap

동적 배열 데이터는 정적 배열 메모리와 겹치면(overlap) 안 돼요. 복사(Copying)도 참고하세요.

인덱싱 (Indexing)

인덱싱은 배열의 요소에 접근할 수 있게 해줘요:

auto a = [1,2,3];
assert(a[0] == 1);
assert(a[2] == 3);
a[2] = 4;
assert(a[2] == 4);
assert(a == [1,2,4]);
//writeln(a[3]); // runtime error (unless bounds checks turned off)

int[2] b = [1,2];
assert(b[1] == 2);
//writeln(b[2]); // compile-time error, index out of bounds

동적 배열과 정적 배열의 인덱싱은 기본적으로 경계 검사가 적용돼요.

참고: IndexOperation.

포인터 연산 (Pointer Arithmetic)

포인터도 인덱싱할 수 있지만, 경계 검사가 없어요. 배열과 달리 포인터 값은 어떤 산술 표현식에서 또 다른 포인터를 만들어 내는 데 사용될 수도 있어요:

int[] a = [1,2,3];
int* p = a.ptr;

p[2] = 4;
assert(a[2] == 4);
writeln(p[3]); // undefined behaviour

assert(p == &a[0]);
p++; // point to a[1]
assert(*p == 2);

자세한 내용은 AddExpression를 참고하세요.

슬라이싱 (Slicing)

배열을 *슬라이싱(slicing)*한다는 것은 배열의 **부분 배열(subarray)**을 지정한다는 뜻이에요. 이는 두 개의 인덱스 표현식을 제공함으로써 이루어져요. 시작 인덱스부터 끝 인덱스 직전까지의 요소가 선택돼요. 끝 인덱스에 있는 항목은 포함되지 않아요.

배열 슬라이스는 데이터를 복사하지 않아요. 단지 그 데이터에 대한 또 하나의 참조일 뿐이에요. 슬라이싱은 동적 배열을 만들어 내요.

int[3] a = [4, 5, 6]; // static array of 3 ints
int[] b;

b = a[1..3]; // a[1..3] is a 2 element dynamic array consisting of
             // a[1] and a[2]
assert(b == [5, 6]);
assert(b.ptr == a.ptr + 1);

a[2] = 3;
assert(b == [5, 3]);

b = b[1..2];
assert(b == [3]);

슬라이스 표현식의 각 인덱스는 기본적으로 경계 검사가 적용돼요.

Expression[]전체 배열의 슬라이스를 뜻하는 축약형이에요.

슬라이싱은 다른 배열의 일부를 참조하는 데 편리할 뿐 아니라, 포인터를 경계 검사되는 배열로 변환하는 데도 유용해요:

int[10] a = [ 1,2,3,4,5,6,7,8,9,10 ];

int* p = &a[2];
writeln(p[7]);      // 10
writeln(p[8]);      // undefined behaviour

int[] b = p[0..8];  // convert pointer elements to dynamic array
assert(b is a[2..10]);
writeln(b);
writeln(b[7]);      // 10
//writeln(b[8]);    // runtime error (unless bounds checks turned off)

참고: SliceOperation.

배열 길이 (Array Length)

정적 배열이나 동적 배열을 인덱싱하거나 슬라이싱할 때, $ 기호는 배열의 길이를 나타내요.

int[4] foo;
int[] bar;

// These assignments are equivalent:
bar = foo;
bar = foo[];
bar = foo[0 .. 4];
bar = foo[0 .. $];
bar = foo[0 .. foo.length];
assert(bar.length == 4);

int* p = foo.ptr; // a pointer has no length property
bar = p[0 .. 4]; // OK
//bar = p[0 .. $]; // error, '$' is not defined, since p is not an array

int i;
//i = foo[0]+$; // error, '$' is not defined, out of scope of [ ]
i = bar[$-1]; // retrieves last element of the array

배열 복사 (Array Copying)

슬라이스 표현식이 할당 표현식의 왼쪽 피연산자로 올 때, 이는 배열 참조가 아니라 슬라이스의 내용이 할당의 대상이 된다는 뜻이에요. 오른쪽 피연산자는 배열 타입으로 암시적으로 변환 가능해야 해요.

int[3] s, t;
int[] a;

s = t;             // the 3 elements of t are copied into s
s[] = t;           // the 3 elements of t are copied into s
s[] = t[];         // the 3 elements of t are copied into s
s[1..2] = t[0..1]; // same as s[1] = t[0]
s[0..2] = t[1..3]; // same as s[0] = t[1], s[1] = t[2]
//s[0..4] = t[0..4]; // error, only 3 elements in s and t
//s[0..2] = t;       // error, operands have different lengths

a = [1, 2];
s[0..2] = a;
assert(s == [1, 2, 0]);

//a[] = s; // RangeError, lengths don't match
a[0..2] = s[1..3];
assert(a == [2, 0]);

런타임에 요소 수가 일치하지 않으면 RangeError가 발생해요.

근거 (Rationale): 슬라이스의 끝을 넘어 인덱싱하는 것은 프로그래밍 오류예요.

겹치는 복사 (Overlapping Copying)

겹치는 복사는 오류예요:

void main()
{
    int[3] s;

    s[0..2] = s[1..3]; // error, overlapping copy
    s[1..3] = s[0..2]; // error, overlapping copy
}

겹침을 금지하면, C의 직렬(serial) 의미론으로 가능한 것보다 더 공격적인 병렬 코드 최적화를 할 수 있어요.

겹침이 필요하다면 std.algorithm.mutation.copy를 사용하세요:

import std.algorithm;
int[] s = [1, 2, 3, 4];

copy(s[1..3], s[0..2]);
assert(s == [2, 3, 3, 4]);

배열 채우기 (Array Filling)

슬라이스 연산자가 할당 표현식의 왼쪽 피연산자로 오고, 오른쪽 피연산자의 타입이 왼쪽 피연산자의 요소 타입과 같다면, 슬라이스 표현식의 각 요소가 오른쪽의 값으로 할당돼요.

int[3] s;
int[] a;
int* p;

s[] = 3;
assert(s == [3, 3, 3]);

a = s;
a[] = 1;
assert(s == [1, 1, 1]);

p = s.ptr;
p[0..2] = 2;
assert(s == [2, 2, 1]);

배열 연결 (Array Concatenation)

이항 연산자 ~cat 연산자예요. 배열을 연결(concatenate)하는 데 사용돼요:

int[] a = [1, 2];
assert(a ~ 3 == [1, 2, 3]); // concatenate array with a single value

int[] b = a ~ [3, 4];
assert(b == [1, 2, 3, 4]); // concatenate two arrays

근거 (Rationale): 많은 언어가 연결에 + 연산자를 오버로딩해요. 그러면 혼란이 생기죠 — "10" + 3 + 4가 숫자 17이 되어야 할까요, 문자열 "1034"가 되어야 할까요, 아니면 문자열 "107"이 되어야 할까요? 명확하지 않죠. 그래서 언어 설계자들은 이를 명확히 하기 위해 규칙을 조심스럽게 작성하게 되는데, 그 규칙들이 잘못 구현되거나, 간과되거나, 잊히거나, 무시되곤 해요. 차라리 +는 덧셈으로 두고, 배열 연결을 위한 별도의 연산자를 두는 것이 훨씬 낫죠.

연결(concatenation)은 피연산자 중 하나가 길이 0인 배열이더라도 항상 피연산자의 복사본을 만들어요. 그래서:

auto b = [7];
auto a = b;      // a refers to b
assert(a is b);

a = b ~ []; // a refers to a copy of b
assert(a !is b);
assert(a == b);

참고: is 연산자.

배열 추가 (Array Appending)

마찬가지로 ~= 연산자는 **추가(append)**를 의미해요:

a ~= b; // a becomes the concatenation of a and b

추가는 항상 복사본을 만들지는 않아요. 자세한 내용은 동적 배열 길이 설정을 참고하세요.

벡터 연산 (Vector Operations)

많은 배열 연산은 루프로 쓰는 대신 **높은 수준(high level)**으로 표현할 수 있어요. 예를 들어 다음 루프:

T[] a, b;
...
for (size_t i = 0; i < a.length; i++)
    a[i] = b[i] + 4;

a의 요소 각각에 b의 요소를 4씩 더한 값을 할당해요. 이것을 벡터 표기법으로도 표현할 수 있어요:

T[] a, b;
...
a[] = b[] + 4;

벡터 연산은 할당이나 복합 할당(op-assignment) 표현식의 왼쪽 피연산자로 슬라이스 연산자가 오는 것으로 표시돼요. 오른쪽 피연산자는 다음의 특정 조합이 될 수 있어요:

  • 왼쪽 피연산자와 같은 길이·같은 타입인 배열 SliceOperation
  • 왼쪽 피연산자와 같은 요소 타입의 스칼라(scalar) 표현식

다음 연산들이 지원돼요:

  • 단항 (Unary): -, ~
  • 덧셈 (Add): +, -
  • 곱셈 (Mul): *, /, %
  • 비트 (Bitwise): ^, &, |
  • 거듭제곱 (Pow): ^^
int[3] a = 0;
int[] b = [1, 2, 3];

a[] += 10 - (b[] ^^ 2);
assert(a == [9, 6, 1]);

참고: 특히 ConditionalExpression, 논리 표현식, CmpExpression, 연결 ~, 함수 호출을 사용하는 표현식은 벡터 연산이 아니에요.

왼쪽의 슬라이스와 오른쪽의 슬라이스는 겹치면 안 돼요. 배열 슬라이스에 요소가 0개 있더라도 모든 피연산자는 정확히 한 번 평가돼요.

요소 타입이 일치하는 오버로드된 연산자를 정의한다면, 그 메서드들은 pure nothrow @nogc여야 해요.

배열 요소가 계산되는 순서는 **구현 정의(implementation defined)**이며, 심지어 병렬로 일어날 수도 있어요. 애플리케이션은 이 순서에 의존하면 안 돼요.

구현 참고 (Implementation Note): 많은 벡터 연산은 대상 컴퓨터에서 사용할 수 있는 벡터 수학 명령어를 활용할 것으로 기대돼요.

직사각형 배열 (Rectangular Arrays)

FORTRAN 수치 프로그래밍에 경험이 있는 사람들은 행렬 연산 같은 것을 위한 다차원 "직사각형(rectangular)" 배열이, "배열의 포인터 배열" 의미론에서 비롯된 포인터-대-포인터로 접근하는 것보다 훨씬 빠르다는 것을 알고 있어요. 예를 들어 D 문법:

double[][] matrix;

matrix를 배열들에 대한 포인터들의 배열로 선언해요. (동적 배열은 배열 데이터에 대한 포인터로 구현돼요.) 배열들은 (동적으로 크기가 정해지므로) 크기가 다양할 수 있기 때문에, 이를 때때로 "조각난(jagged)" 배열이라고 불러요. 코드 최적화 측면에서 더 나쁜 것은, 배열 행들이 서로를 가리킬 수도 있다는 점이에요! 다행히도 D 정적 배열은 같은 문법을 쓰지만 연속된 메모리 블록 안의 고정된 직사각형 레이아웃으로 구현돼요:

import std.stdio : writeln;

double[6][3] matrix = 0; // Sets all elements to 0.

void main()
{
    writeln(matrix); // [[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0]]
}

차원과 인덱스는 반대 순서로 나타난다는 점에 주의하세요. 선언에서의 차원은 오른쪽에서 왼쪽으로 읽는 반면, 인덱스는 왼쪽에서 오른쪽으로 읽어요:

import std.stdio : writeln;

void main()
{
    double[6][3] matrix = 0;
    matrix[2][5] = 3.14; // Assignment to bottom right element.
    writeln(matrix); // [[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 3.14]]

    static assert(!__traits(compiles, matrix[5][2])); // Array index out of bounds.
}

더 자세한 정보는 Dlang Wiki - Dense Multidimensional Arrays에서 찾을 수 있어요.

배열 프로퍼티 (Array Properties)

정적 배열의 프로퍼티는 다음과 같아요:

void foo(int, int, int) { /* ... */ }

int[3] ia = [1, 2, 3];
foo(ia.tupleof); // same as `foo(1, 2, 3);`

float[3] fa;
//fa = ia; // error
fa.tupleof = ia.tupleof;
assert(fa == [1F, 2F, 3F]);
프로퍼티 설명
.init 배열 요소 타입의 .init 프로퍼티를 각 요소로 하는 배열 리터럴을 반환해요.
.sizeof 배열 길이에 배열 요소당 바이트 수를 곱한 값을 반환해요.
.length 배열의 요소 수를 반환해요. 정적 배열에서는 컴파일 시점에 알려진 고정 값이에요. 타입은 size_t예요.
.ptr 배열의 첫 번째 요소에 대한 포인터를 반환해요.
.dup 같은 크기의 동적 배열을 만들고 배열의 내용을 그 안으로 복사해요. 복사본은 immutability나 const가 벗겨진 상태예요. 이 변환이 유효하지 않으면 호출이 컴파일되지 않아요.
.idup 같은 크기의 동적 배열을 만들고 배열의 내용을 그 안으로 복사해요. 복사본은 immutable로 타입이 지정돼요. 이 변환이 유효하지 않으면 호출이 컴파일되지 않아요.
.tupleof 배열의 각 요소에 대한 lvalue 시퀀스를 반환해요.

동적 배열의 프로퍼티는 다음과 같아요:

프로퍼티 설명
.init []를 반환해요.
.sizeof 동적 배열 참조의 크기를 반환해요. 32비트 빌드에서는 8, 64비트 빌드에서는 16이에요.
.length 배열의 요소 수를 가져오거나 설정해요. 타입은 size_t예요.
.capacity 재할당 없이 배열이 커질 수 있는 길이를 반환해요.
.ptr 배열의 첫 번째 요소에 대한 포인터를 반환해요.
.dup 같은 크기의 동적 배열을 만들고 배열의 내용을 그 안으로 복사해요. 복사본은 immutability나 const가 벗겨진 상태예요. 이 변환이 유효하지 않으면 호출이 컴파일되지 않아요.
.idup 같은 크기의 동적 배열을 만들고 배열의 내용을 그 안으로 복사해요. 복사본은 immutable로 타입이 지정돼요. 이 변환이 유효하지 않으면 호출이 컴파일되지 않아요.

예시:

int* p;
int[3] s;
int[] a;

size_t len;
//len = p.length;   // error, pointer has no length property
enum sl = s.length; // compile time constant
static assert(sl == 3);

len = a.length; // runtime value
assert(len == 0);

//a = p.dup; // error, length not known
a = s.dup;   // allocates an array of 3 elements, copies
             // elements of `s` into it
assert(a == s[]);
assert(a.ptr != s.ptr);

int[] b = a.dup; // allocates a new array of `a.length` elements, copies
                 // elements of `a` into it
assert(b !is a); // b is an independent copy of a

.ptr 프로퍼티 (.ptr Property)

.ptr 프로퍼티는 정적 배열이나 동적 배열의 첫 번째 요소에 대한 포인터를 반환해요. 배열 길이가 0이라면 댕글링 포인터(dangling pointer)가 될 수 있어요. 이런 이유로 .ptr은 @safe 코드에서 접근할 수 없어요.

동적 배열의 .ptr기본 초기화null이 돼요.

길이가 0인 배열도 **null이 아닌 .ptr**을 가질 수 있어요. 그런 경우는:

  • 길이가 0인 동적 배열 a를 슬라이싱하는 경우: a[$..$].
  • 길이가 0인 정적 배열인 경우.
int[] a;
assert(a.ptr is null);

int[0] z;
assert(z.ptr !is null);

동적 배열 길이 설정 (Setting Dynamic Array Length)

동적 배열의 .length 프로퍼티는 = 연산자의 왼쪽 피연산자로 설정할 수 있어요:

array.length = 7;

이렇게 하면 배열이 제자리에서 재할당되고, 기존 내용이 새 배열로 복사돼요. 새 배열 길이가 더 짧으면 배열은 재할당되지 않고 데이터도 복사되지 않아요. 이는 배열을 슬라이싱하는 것과 동등해요:

array = array[0..7];

새 배열 길이가 더 길면, 필요한 경우 배열이 재할당되며 기존 요소는 보존돼요. 새 요소들은 기본 초기화 값(default initializer)으로 채워져요.

배열 키우기 (Growing an Array)

효율을 최대화하기 위해 런타임은 항상 배열을 제자리에서 리사이즈해서 추가 복사를 피하려고 해요. 새 크기가 더 크고 다음 중 하나에 해당할 때는 복사를 할 거예요:

char[] a = new char[20];
char[] b = a[0..10];
char[] c = a[10..20];
char[] d = a;

b.length = 15; // always reallocates because extending in place would
               // overwrite other data in a.
b[11] = 'x';   // a[11] and c[1] are not affected
assert(a[11] == char.init);

d.length = 1;
assert(d.ptr == a.ptr); // unchanged

d.length = 20; // also reallocates, because doing this will overwrite a and c
assert(d.ptr != a.ptr);

c.length = 12; // may reallocate in place if space allows, because nothing
               // was allocated after c.
c[5] = 'y';    // may affect contents of a, but not b or d because those
               // were reallocated.

a.length = 25; // This always reallocates because if c extended in place,
               // then extending a would overwrite c.  If c didn't
               // reallocate in place, it means there was not enough space,
               // which will still be true for a.
a[15] = 'z';   // does not affect c, because either a or c has reallocated.

복사 동작을 보장하려면 .dup 프로퍼티를 사용해 리사이즈할 수 있는 고유한 배열을 확보하세요.

참고: 이러한 문제는 ~= 연산자로 배열을 추가할 때도 동일하게 적용돼요. ~ 연산자를 사용한 연결은 항상 재할당하므로 영향을 받지 않아요.

동적 배열을 리사이즈하는 것은 상대적으로 비용이 큰 연산이에요. 그래서 다음과 같은 방식으로 배열을 채우는 것은:

void fun()
{
    int[] array;
    while (1)
    {
        import core.stdc.stdio : getchar;
        auto c = getchar;
        if (!c)
            break;
        ++array.length;
        array[array.length - 1] = c;
    }
}

동작하긴 하지만 비효율적이에요. 더 실용적인 접근은 리사이즈 횟수를 최소화하는 거예요:

void fun()
{
    int[] array;
    array.length = 100;        // guess
    int i;
    for (i = 0; ; i++)
    {
        import core.stdc.stdio : getchar;
        auto c = getchar;
        if (!c)
            break;
        if (i == array.length)
            array.length *= 2;
        array[i] = c;
    }
    array.length = i;
}

초기 크기 선택은 예상되는 일반적인 사용 사례에 기반하세요. 이는 코드를 계측(instrumenting)하거나, 간단히 좋은 판단을 사용해 결정할 수 있어요. 예를 들어 콘솔에서 사용자 입력을 모을 때는 80자를 넘을 가능성이 낮죠.

capacity와 reserve (capacity and reserve)

capacity 프로퍼티는 동적 배열이 재할당 없이 커질 수 있는 최대 길이를 알려줘요. 배열 a의 여유 용량은 a.capacity - a.length예요. 슬라이스의 용량은 다음 경우에 0이 돼요:

  • GC로 할당된 동적 배열 메모리를 가리키지 않는 경우
  • 기본적으로, 슬라이스 뒤에 요소가 저장된 경우
int[] a;
assert(a.capacity == 0);
a.length = 3; // may allocate spare capacity too
assert(a.capacity >= 3);

auto b = a[1..3];
assert(b.capacity >= 2); // either a or b can append into any spare capacity
b = a[0..2];
assert(b.capacity == 0);

근거 (Rationale): 이 동작은 다른 슬라이스의 요소를 실수로 덮어쓰는 것을 방지하는 데 도움이 돼요. 또한 immutable 요소가 덮어써지는 것을 보호하는 데 필요해요.

reserve 함수는 배열에 대한 최소 용량을 요청해요. 요청한 것보다 많을 수도 있는 새 용량을 반환해요. 여유 용량은 추가 연산자.length 할당으로 사용할 수 있어요.

int[] array;
const size_t cap = array.reserve(10); // request
assert(cap >= 10); // allocated may be more than request
assert(array.ptr != null);
assert(array.length == 0);

int[] copy = array;
assert(copy.capacity == cap); // array and copy have same capacity
array ~= [1, 2, 3, 4, 5]; // grow in place
assert(cap == array.capacity); // array memory was not reallocated
assert(copy.ptr == array.ptr);
assert(copy.capacity == 0);
copy ~= 0; // allocates a new array
assert(copy.ptr != array.ptr);
assert(array[0] == 1);

위에서 copy의 길이는 0으로 남지만 reserve 호출로 할당된 같은 메모리를 가리켜요. 그런 다음 array에 요소가 추가되었기 때문에 copy.ptr + 0은 더 이상 사용되지 않는 메모리를 가리키지 않아요 — 대신 그것이 array[0]의 주소예요. 그래서 copy.capacity는 0이 되어, copy에 대한 어떤 추가도 array의 요소를 덮어쓰지 않도록 해요.

참고: 런타임은 슬라이스의 마지막 요소를 사용해 메모리 할당의 여유 용량 시작을 추적해요.

여유 용량이 있는 배열의 길이가 줄어들거나, 이전 마지막 요소보다 일찍 끝나는 자기 자신의 슬라이스가 할당되면 용량은 0이 돼요.

@system 함수 assumeSafeAppend는 용량을 되찾을 수 있게 해줘요. 하지만 다른 슬라이스에 존재할 수 있는 immutable 요소를 덮어쓰지 않도록 주의해야 해요.

int[] a = [1, 2, 3];
a.length--;
assert(a.capacity == 0);
a.assumeSafeAppend();
assert(a.capacity >= 3);

참고: .capacity에 접근하려면 런타임이 전역 잠금을 획득하고 캐시 조회를 수행해야 할 수도 있어요.

모범 사례 (Best Practices): 성능에 민감한 코드에서 .capacity를 집중적으로 사용하지 마세요. 대신 고유한 참조를 통해 배열을 만들 때 용량을 로컬에서 추적하세요. 예: std.array.Appender.

배열 프로퍼티로서의 함수 (Functions as Array Properties)

Uniform Function Call Syntax (UFCS)를 참고하세요.

배열 경계 검사 (Array Bounds Checking)

0보다 작거나 배열 길이보다 크거나 같은 인덱스로 배열을 인덱싱하는 것은 오류예요. 인덱스가 범위를 벗어나면, 런타임에 감지되면 ArrayIndexError가 발생하고, 컴파일 시점에 감지되면 오류가 발생해요. 프로그램은 배열 경계 검사가 항상 일어난다고 가정해서는 안 돼요. 예를 들어 다음 프로그램은 잘못된 프로그램이에요:

void main()
{
    import core.exception;
    try
    {
        auto array = [1, 2];
        for (auto i = 0; ; i++)
        {
            array[i] = 5;
        }
    }
    catch (ArrayIndexError)
    {
        // terminate loop
    }
}

루프는 이렇게 올바르게 작성해요:

void main()
{
    auto array = [1, 2];
    for (auto i = 0; i < array.length; i++)
    {
        array[i] = 5;
    }
}

구현 참고 (Implementation Note): 컴파일러는 배열 경계 오류를 컴파일 시점에 감지하려고 시도해야 해요. 예:

int[3] foo;
int x = foo[3]; // error, out of bounds

런타임 배열 경계 검사 코드의 삽입은 컴파일 시점 스위치로 켜고 끌 수 있어야 해요.

정의되지 않은 동작 (Undefined Behavior): 범위를 벗어난 메모리 접근은 정의되지 않은 동작을 일으킬 거예요. 따라서 배열 경계 검사는 일반적으로 @safe 함수에서 활성화돼요. 런타임 동작은 언어 의미론의 일부예요.

참고: Safe Functions.

배열 경계 검사 비활성화 (Disabling Array Bounds Checking)

런타임 배열 경계 검사 코드의 삽입은 컴파일러 스위치 -boundscheck로 꺼버릴 수 있어요.

@system 또는 @trusted 코드에서 경계 검사가 비활성화되면, 코드의 정확성은 여전히 코드 작성자가 보장해야 해요.

반면에 @safe 코드에서 경계 검사를 비활성화하면 컴파일러가 보장하는 메모리 안전성이 깨져요. 속도 측정에 근거하지 않는 한 권장되지 않아요.

배열 초기화 (Array Initialization)

기본 초기화 (Default Initialization)

  • 포인터는 null로 초기화돼요.
  • 정적 배열 내용은 배열 요소 타입의 기본 초기화 값으로 초기화돼요.
  • 동적 배열은 요소 0개와 null .ptr을 갖도록 초기화돼요.
  • 연관 배열은 요소 0개를 갖도록 초기화돼요.

길이 초기화 (Length Initialization)

new 표현식은 타입을 지정한 다음 (size) 문법을 사용해 지정된 길이의 동적 배열을 할당할 수 있어요:

int[] i = new int[](5);
i = new int[5]; // same allocation, alternate syntax
assert(i.length == 5);

int[][] j = new int[][](10, 5);
assert(j.length == 10);
assert(j[0].length == 5);

자세한 내용은 NewExpression을 참고하세요.

Void 초기화 (Void Initialization)

Void 초기화는 배열 선언의 Initializervoid일 때 일어나요. 정적 배열에 대해 이는 초기화가 이루어지지 않는다는 뜻이에요. 즉 배열의 내용이 정의되지 않을 거예요. 이는 효율 최적화로 가장 유용해요.

int[256] arr = void; // elements of `arr` have undefined bit-patterns

모범 사례 (Best Practices): Void 초기화는 고급 기법이며, 프로파일링이 그것이 중요함을 보여줄 때만 사용해야 해요.

동적 배열의 요소들을 void 초기화하려면 std.array.uninitializedArray를 사용하세요.

배열 초기화자 (Array Initializers)

ArrayInitializer:
    [ ArrayElementInitializersopt ]

ArrayElementInitializers:
    ArrayElementInitializer
    ArrayElementInitializer ,
    ArrayElementInitializer , ArrayElementInitializers

ArrayElementInitializer:
    NonVoidInitializer
    AssignExpression : NonVoidInitializer

ArrayInitializer[ ]로 둘러싸인 요소 초기화자들의 목록이에요. 각 요소 초기화자에는 선택적으로 인덱스 표현식과 :를 앞에 붙일 수 있어요. 인덱스가 주어지지 않으면 이전 인덱스에 1을 더한 값으로 설정되고, 첫 번째 값이면 0으로 설정돼요. 생략된 모든 요소는 요소 타입의 기본 값으로 초기화돼요.

int[3] a = [ 1:2, 3 ]; // a[0] = 0, a[1] = 2, a[2] = 3
int[3] b = [0, 2, 3];
assert(a == b);

int[3][] s = [[1, 2, 3], [2:7]]; // nested array initializers
assert(s == [[1, 2, 3], [0, 0, 7]]);

이는 배열 인덱스가 열거형(enums)으로 주어질 때 가장 편리해요:

enum Color { red, blue, green }

int[Color.max + 1] value =
  [ Color.blue :6,
    Color.green:2,
    Color.red  :5 ];

assert(value == [5, 6, 2]);

모든 인덱스는 컴파일 시점에 알려져야 해요. 배열 타입이 지정되지 않고 모든 요소가 인덱스를 가진다면, 그것은 연관 배열 리터럴로 추론된다는 점에 주의하세요.

int n = 4;
auto aa = [0:1, 3:n]; // associative array `int[int]`

int[] a = [1, 3:n, 5];
assert(a == [1, 0, 0, n, 5]);

//int[] e = [n:2]; // error, n not known at compile-time

정적으로 할당된 배열의 정적 초기화 (Static Initialization of Statically Allocated Arrays)

정적 배열의 모든 요소는 배열 요소 타입으로 암시적으로 변환되는 특정 값으로 초기화될 수 있어요:

int[4] a = 42; // set all elements of a to 42
int[4][] s = [42, 0]; // set all elements of each static array

void main()
{
    assert(a == [42, 42, 42, 42]);
    assert(s == [a, int[4].init]);
}

이 배열들은 전역 스코프에 나타날 때 정적으로 할당돼요. 그 외에는 정적으로 할당된 배열로 만들기 위해 static 또는 __gshared 저장 클래스로 표시해야 해요.

문자열 리터럴 초기화자 (String Literal Initializers)

문자 타입의 정적 배열은 다른 정적 배열과 동일하게 기본 초기화돼요. 그러나 문자열 리터럴로부터 초기화될 때는:

  • 리터럴이 배열보다 더 짧은 길이일 수 있어요.
  • 나머지 요소들은 \0으로 초기화돼요.
char[10] s1;
assert(s1[9] == '\xFF'); // char.init

char[10] s2 = "hi";
assert(s2[9] == '\0');

근거 (Rationale): 가변 문자 배열의 .ptr 프로퍼티는 (0인 요소가 있다면) C 문자열을 얻는 데 사용될 수 있어요. 그 배열이 더 짧은 문자열 리터럴로 초기화될 때, 이후의 코드가 0 종료(zero-termination)를 필요로 할 가능성이 높아요.

특별한 배열 타입 (Special Array Types)

문자열 (Strings)

문자열은 immutable (읽기 전용) 문자의 배열이에요. 문자열 리터럴은 본질적으로 문자 배열 리터럴을 쓰는 쉬운 방법이에요.

char[] arr;
//arr = "abc";          // error, cannot implicitly convert expression `"abc"` of type `string` to `char[]`
arr = "abc".dup;        // ok, allocates mutable copy

string str1 = "abc";    // ok, same types
//str1 = arr;           // error, cannot implicitly convert expression `arr` of type `char[]` to `string`
str1 = arr.idup;        // ok, allocates an immutable copy of elements
assert(str1 == "abc");

string str2 = str1;     // ok, mutable slice of same immutable array contents

string이라는 이름은 immutable(char)[]의 별칭이에요. 타입 immutable(char)[]는 immutable 문자들의 배열을 나타내요. 그러나 문자열에 대한 참조는 **가변(mutable)**이에요.

immutable(char)[] s = "foo";
s[0] = 'a';  // error, s[0] is immutable
s = "bar";   // ok, s itself is not immutable

문자열에 대한 참조까지 immutable로 만들 필요가 있다면, immutable char[] 또는 immutable string으로 선언할 수 있어요:

immutable char[] s = "foo";
s[0] = 'a';  // error, s refers to immutable data
s = "bar";   // error, s is immutable

문자열은 복사, 비교, 연결, 추가될 수 있어요:

string s1;
immutable s2 = "ello";
s1 = s2;
s1 = "h" ~ s1;
if (s1 > "farro")
    s1 ~= " there";

assert(s1 == "hello there");

이때 배열 의미론(array semantics)이 적용돼요. 생성된 임시 값들은 가비지 컬렉터(또는 alloca() 사용)에 의해 정리돼요. 게다가 이는 특별한 String 배열뿐만 아니라 모든 배열에서 동작해요.

문자열 리터럴 타입 (String Literal Types)

문자열 리터럴의 타입은 컴파일의 의미론(semantic) 단계에서 결정돼요. 타입은 암시적 변환 규칙에 의해 결정돼요. 똑같이 적용 가능한 두 개의 암시적 변환이 있다면 그 결과는 오류예요. 이러한 경우를 명확히 하기 위해 cast 또는 c, w, d의 접미사를 사용할 수 있어요:

cast(immutable(wchar)[]) "abc" // this is an array of wchar characters
"abc"w                         // so is this

접미사 문자가 없고 cast되지 않은 문자열 리터럴은 필요에 따라 string, wstring, dstring(아래 참고) 사이에서 암시적으로 변환될 수 있어요.

void fun()
{
    char c;
    wchar w;
    dchar d;

    c = 'b';     // c is assigned the character 'b'
    w = 'b';     // w is assigned the wchar character 'b'
    //w = 'bc';  // error - only one wchar character at a time
    w = "b"[0];  // w is assigned the wchar character 'b'
    w = "\r"[0]; // w is assigned the carriage return wchar character
    d = 'd';     // d is assigned the character 'd'
}
문자열과 유니코드 (Strings and Unicode)

문자열 데이터는 다음과 같이 인코딩돼요:

별칭 (Alias) 타입 인코딩
string immutable(char)[] UTF-8
wstring immutable(wchar)[] UTF-16
dstring immutable(dchar)[] UTF-32

내장 비교 연산자는 코드 유닛(code unit) 기준으로 동작한다는 점에 주의하세요. 유효한 문자열에 대한 최종 결과는, 두 문자열이 같은 정규화 형식(normalization form)에 있는 한, 코드 포인트(code point) 대 코드 포인트 비교와 동일해요. 정규화는 언어 프리미티브에 적합하지 않은 비용이 큰 연산이므로 사용자가 강제한다고 가정해요.

표준 라이브러리는 인코딩이 섞인 문자열을 비교하는 데 도움을 줘요 (투명하게 디코딩하는 std.algorithm.cmp), 대소문자 무시 비교(icmp), 그리고 정규화를 통해서요.

마지막으로, 원하는 문자열 정렬 순서는 문화와 언어에 따라 다르며, 일반적으로 코드 포인트 대 코드 포인트 비교와는 전혀 달라요. 문자열의 자연스러운 순서는 표준 라이브러리에 구현되어야 하는 유니코드 결합 알고리즘(Unicode collation algorithm)을 적용함으로써 얻을 수 있어요.

문자 포인터와 C 문자열 (Character Pointers and C strings)

문자에 대한 포인터를 생성할 수 있어요:

string str = "abcd";
immutable(char)* p = &str[3]; // pointer to 4th element
assert(*p == 'd');
p = str.ptr; // pointer to 1st element
assert(*p == 'a');

D에서 0으로 종료되는 것은 오직 문자열 리터럴뿐이에요. 일반적으로 문자열 데이터에 대한 포인터를 C로 전달할 때는 종료하는 '\0'을 추가하세요:

string str = "ab";
assert(str.ptr[2] == '\0'); // OK
str ~= "cd";
// str is no longer zero-terminated
str ~= "\0";
assert(str[4] == '\0'); // OK
str.length = 2;
// str is no longer correctly zero-terminated
assert(str.ptr[2] != '\0');

std.string.toStringz 함수도 사용할 수 있어요.

예시: printf (Example: printf)

core.stdc.stdio.printf는 C 함수이며 D의 일부가 아니에요. printf()는 0으로 종료되는 C 문자열을 출력해요. D 문자열과 함께 printf()를 사용하는 방법은 두 가지가 있어요. 첫 번째는 종료 0을 추가하는 것이에요:

str ~= "\0";
printf("the string is '%s'\n", str.ptr);

또는:

import std.string;
printf("the string is '%s'\n", std.string.toStringz(str));

문자열 리터럴은 이미 0이 추가되어 있으므로 직접 사용할 수 있어요:

printf("the string is '%s'\n", "string literal".ptr);

그렇다면 왜 printf에 전달하는 첫 번째 문자열 리터럴은 .ptr이 필요하지 않을까요? 첫 번째 매개변수는 const(char)*로 프로토타입되어 있고, 문자열 리터럴은 const(char)*로 암시적으로 변환될 수 있어요. 그러나 printf의 나머지 인자들은 가변 인자(variadic, ...로 지정됨)이고, immutable(char)[]로 타입이 지정된 문자열 리터럴은 가변 매개변수에 전달될 수 없어요.

두 번째 방법은 정밀도 지정자(precision specifier)를 사용하는 것이에요. 길이가 먼저 오고 포인터가 뒤에 와요:

printf("the string is '%.*s'\n", cast(int)str.length, str.ptr);

가장 좋은 방법은 D 문자열을 처리할 수 있는 std.stdio.writefln을 사용하는 거예요:

import std.stdio;
writefln("the string is '%s'", str);

Void 배열 (Void Arrays)

요소 타입이 void인 특별한 종류의 배열이 있는데, 어떤 종류의 배열이든 담을 수 있어요. Void 배열은 어떤 종류의 배열 데이터를 다루지만 배열 요소의 정확한 타입이 중요하지 않은 저수준 연산에 사용돼요. void 배열의 .length는 원래 타입의 요소 수가 아니라 데이터의 바이트 길이예요. 슬라이싱 연산의 배열 인덱스는 바이트 인덱스로 해석돼요. void 배열은 인덱싱할 수 없어요.

다른 배열은 void 배열의 요소로 복사될 수 있지만, @safe 함수에서는 안 돼요. void 배열이 포인터를 담을 수 있기 때문이에요. void 배열은 채울(filled) 수 없어요.

모든 타입의 배열은 (꼬리 한정된 tail qualified) void 배열로 암시적 변환될 수 있어요 — 컴파일러가 적절한 계산을 삽입해서 결과 배열의 .length가 요소 수가 아닌 바이트 단위 크기가 되도록 해요. Void 배열은 배열 cast를 사용하지 않고는 원래 타입으로 되돌릴 수 없어요. 그리고 요소 크기가 void 배열의 길이를 균등하게 나누지 않는 배열 타입으로 변환하는 것은 오류예요.

void main()
{
    int[] data1 = [1,2,3];

    void[] arr = data1;            // OK, int[] implicit converts to void[].
    assert(data1.length == 3);
    assert(arr.length == 12);      // length is implicitly converted to bytes.

    arr[0..4] = [5];               // Assign first 4 bytes to 1 int element
    assert(data1 == [5,2,3]);

    arr ~= [6];                    // Append the 4 bytes of an int
    //data1 = arr;                 // Error: void[] does not implicitly
                                   // convert to int[].
    int[] data2 = cast(int[]) arr; // OK, can convert with explicit cast.
    assert(data2 is arr);          // both point to the same set of bytes
    assert(data2 == [5,2,3,6]);
}
void main()
{
    void[] arr = new void[12];
    long[] bad = cast(long[]) arr; // Runtime error: long.sizeof == 8, which
                                   // does not divide arr.length, which is 12
                                   // bytes.
}

Void 배열은 길이가 컴파일 시점에 알려진다면 정적 배열일 수 있어요. 이때 길이는 바이트 단위로 지정돼요:

void main()
{
    byte[2] x;
    int[2] y;

    void[2] a = x; // OK, lengths match
    void[2] b = y; // Error: int[2] is 8 bytes long, doesn't fit in 2 bytes.
}

void 배열이 ubyte[]의 화려한 문법처럼 보일 수도 있지만, 미묘한 차이가 있어요. 가비지 컬렉터는 일반적으로 ubyte[] 배열을 포인터에 대해 스캔하지 않아요. ubyte[]는 포인터가 아닌 순수한 바이트 데이터만 담고 있다고 가정되기 때문이에요. 그러나 void[] 배열은 포인터 배열이나 포인터를 포함하는 요소의 배열에서 암시적으로 변환되었을 수 있으므로 포인터에 대해 스캔할 거예요. 포인터를 포함하는 배열을 ubyte[]로 할당하면, 이 포인터들이 그 대상에 대한 유일한 남은 참조라면 GC가 살아 있는 메모리를 수거해버릴 위험이 있어요.

암시적 변환 (Implicit Conversions)

정적 배열 T[dim]은 동적 배열 T[]로 암시적으로 변환될 수 있어요. 동적 배열의 요소는 정적 배열의 수명 밖에서 사용되면 안 돼요. 이는 -preview=dip1000으로 강제할 수 있어요.

클래스 요소 타입을 가진 배열/포인터는 암시적 변환이 있을 수 있어요.

배열 리터럴은 정적 배열 타입으로 암시적으로 변환될 수 있어요. 자세한 내용은 배열 리터럴을 참고하세요.

문자열 리터럴은 정적 배열 타입과 문자 포인터 타입으로 암시적으로 변환될 수 있어요. 자세한 내용은 문자열 리터럴을 참고하세요.

Void 요소 변환 (Void Element Conversion)

포인터 T*는 다음과 같이 void 포인터로 암시적으로 변환될 수 있어요:

동적 배열 T[]는 다음과 같이 void 배열로 암시적으로 변환될 수 있어요:

마찬가지로, 정적 배열 T[dim]은 (호환되게 한정된) void 요소 타입을 가진 정적 배열로 암시적으로 변환될 수 있어요.

void test()
{
    const int* ip;
    //void* vp = ip; // error
    const(void)* vp = ip;

    immutable int[] a;
    //void[] va = a; // error
    const(void)[] va = a;
}

근거 (Rationale): void[]는 요소에 바이트를 복사할 수 있어요. immutable 배열 요소를 가리키는(aliasing) void 배열에 복사하는 것은 cast 없이 immutable 데이터를 위반하게 돼요. void*는 직접 역참조할 수 없지만, 슬라이싱하여 void[]를 얻을 수는 있어요.

더 알아보기 (Learn more)