배열과 튜플

배열과 튜플

C++에는 고정된 크기의 변수 그룹을 하나의 변수로 묶는 방법이 여러 가지 있습니다.

#C의 배열

C 언어에는 배열을 정의하는 세 가지 주요 방법이 있습니다:

int a[100];

int main() {
    int b[100];
    int *c = new int[100];
    del[] c;
    return 0;
}

이렇게 만들어진 변수들은 기능적으로 동일하지만, 조금씩 차이가 있습니다:

  • 전역으로 정의된 배열 a는 프로그램이 실행되는 동안 내내 미리 할당된 메모리 영역에 놓입니다. 모든 원소는 처음에 기본값(int의 경우 0)으로 채워져 있습니다.
  • 함수 내부에서 정의된 배열 b는 스택에 — 임시 변수를 위한 특별한 메모리 영역에 — 놓이며, 함수(또는 루프 본문이나 if 같은 다른 블록)가 끝나는 즉시 삭제됩니다. 실행 스택의 크기는 제한되어 있으므로, 큰 배열($>10^6$)은 이런 방식으로 할당할 수 없습니다. 처음에 이 배열은 그 시점에 메모리에 남아 있던 임의의 값으로 채워져 있습니다 — 0으로 채우려면 int x[100] = {}와 같이 작성하면 됩니다. 모든 원소를 지정한 값으로 채우려면 int y[5] = {4, 8, 15, 23, 42}와 같이 작성하면 됩니다.
  • new 연산자로 정의한 배열 c는 동적으로 할당됩니다. 이 배열은 del[] 연산자로 명시적으로 삭제하기 전까지 존재합니다. 또한 이 배열은 그 시점에 메모리에 남아 있던 값으로 채워져 있습니다. 앞의 두 방식과 달리, 이 배열은 미리 알 수 없는 크기라도 어떤 크기로든 만들 수 있습니다.

중요. 처음 두 가지 방식에서는 배열의 크기가 컴파일 시점에 알려진 상수여야 합니다. GCC 컴파일러는 int a[n] 형태의 표현식을 컴파일할 수 있으며, 실제로 상수가 아닌 크기의 배열이 할당됩니다. 그래서 IDE가 이를 지적하지 않을 수도 있지만, 이는 표준의 일부가 아닙니다.

배열의 모든 원소는 메모리에 연속적으로 저장되며, 변수 a, b, c 자체는 실제로 배열의 첫 번째 원소를 가리키는 포인터입니다. 대괄호는 단지 “문법적 설탕"일 뿐입니다:

a[k]  <=>  *(a + k)

C에는 초기화와 복사를 위한 두 가지 유용한 함수가 있는데, 각각 memset과 memcpy입니다.

첫 번째 함수는 ‘어디로’ 포인터, ‘어디에서’ 포인터, 그리고 복사해야 할 바이트 수를 받습니다:

memcpy(dest, src, sizeof src);

두 번째 함수는 “어디로"를 가리키는 포인터와 1바이트, 즉 배열 전체에 복사해 채울 값을 받습니다.

memset(arr, 0, sizeof arr);

중요. memset은 int나 float 같은 타입이 아니라 정확히 원시 바이트를 다룹니다. 따라서 memset으로는 정수형 배열을 $0$이나 $-1$처럼 “주기적인” 값으로만 채울 수 있습니다(음의 1은 이진 표기로 111..111처럼 보입니다).

또한 두 함수 모두에서 마지막 인자는 원소의 개수가 아니라 바이트 수라는 점을 기억하는 것이 중요합니다. 크기가 상수가 아닌 배열의 경우에는 타입의 크기에 배열의 크기를 곱하면 됩니다:

memcpy(dest, src, sizeof(int) * n)

여기서 sizeof(int) = 4입니다. 그냥 4라고 쓰는 대신 이렇게 쓰는 것은 코드가 스스로를 설명하도록 하기 위함입니다.

#std::array

C++11에는 고정 크기 배열을 위한 자체 클래스가 추가되었습니다:

// int a[3] = {1, 2, 3};
array<int, 3> a = {1, 2, 3};

이것에 대한 모든 연산은 C 배열과 동일한 방식으로 동작합니다. 주된 차이점은 이것이 STL 컨테이너라는 점, 즉 반복자를 가지고 있으며 표준 라이브러리의 모든 알고리즘을 이것에 사용할 수 있다는 점입니다.

sort(a.begin(), a.end());

하지만 일반 배열에서도 마찬가지입니다. 포인터가 자동으로 반복자로 변환됩니다:

sort(a, a + 3);

STL의 배열은 일반적인 고정 크기 배열과 마찬가지로 반복을 지원합니다:

for (int x : a)
    cout << x << endl;

또한 다음과 같은 문법으로 반복하는 동안 배열의 원소를 수정할 수도 있습니다:

for (int &x : a)
    x *= 2;

STL에는 memset보다 오류에 더 강한 대안인 std::fill도 있습니다:

fill(a.begin(), a.end(), 42);

이 함수는 이미 완전한 타입에 대해서도 동작하지만, 속도는 약간 더 느립니다.

#std::pair와 std::tuple

pair<T1, T2> 타입은 반드시 같은 타입일 필요가 없는 두 변수의 쌍을 저장합니다:

pair<int, int> interval = {0, 42};
pair<int, double> index_and_value = {7, 3.1415};

첫 번째 원소는 .first 필드로, 두 번째 원소는 .second 필드로 접근할 수 있습니다.

이를 일반화한 tuple은 임의 개수의 변수로 이루어진 튜플을 저장합니다:

tuple<int, int, int> coords_xyz = {1, 2, 3};

tuple에서는 .first, .second, .third 등을 사용하는 대신 인덱스를 사용해야 합니다.

페어와 튜플은 함수에서 반환할 때 편리합니다:

typedef tuple<double, double, double> point;

point rotate(point p) {
    return {p[1], p[2], p[0]};
}

또한 이것들로 이루어진 배열은 순회하기도 편리합니다:

point points[100];

for (auto [x, y, z] : points)
    cout << x << y << z << endl;

#struct

가능하면 pair나 tuple 대신 구조체를 선언할 것을 강력히 권장합니다.

struct point {
    double x, y, z;
};

.first와 .second 또는 인덱스를 다루느라 고생하는 대신 이름이 붙은 필드를 얻을 수 있으며, 직접 메서드를 정의하고 연산자를 오버로딩할 수도 있습니다:

point::length() {
    return sqrt(x * x + y * y + z * z);
}

point operator+(point a, point b) {
    return {a.x + b.x, a.y + b.y, a.z + b.z};
}

구조체의 유일한 단점은 페어와 튜플에는 비교 함수와 해싱 함수가 정의되어 있어서 그대로 set이나 unordered_set 같은 STL 자료구조의 키로 바로 넣을 수 있지만, 구조체의 경우에는 이러한 함수들을 따로 작성해야 한다는 점입니다.