C-style 배열
C-style 배열은, C의 초기 시절부터 계속 사용해 온, 같은 타입의 원소를 연속된 메모리 상에 저장하는 집합적 타입을 말합니다.
이러한 배열을 선언하려면 두 가지를 명시해야 합니다.
배열에 담길 원소의 타입과 원소에 개수가 그것들입니다.
다음은 이러한 배열의 예를 보여줍니다.
int int_arr1[5]; // 초기화 하지 않음
int int_arr2[]{ 1, 2, 3, 4, 5 }; // 원소 개수를 추론 가능
int int_arr3[6]; // int [6] 타입
위의 int_arr2와 같이, 선언 시 원소의 개수를 생략할 수도 있는데, 이 경우는 컴파일러가 초기화에 쓰인 원소들로부터 원소의 개수를 추론할 수 있어야 합니다.
그래서, int_arr1을 선언할 때, 원소의 개수 5를 생략하면, 오류가 됩니다.
그리고, int_arr1과 int_arr2의 타입은 int 타입이 아니라, int [5] 타입입니다.
한편, int_arr3의 타입은 int [6] 타입입니다.
즉, 배열의 타입은 원소의 타입과 개수가 달라질 때마다, 다른 타입이 된다는 특징이 있습니다.
이러한 배열의 강점은 배열과 반복문의 협력에서 나온다고 할 수 있습니다.
이러한 상호작용을 얻기 위한 가장 중요한 것의 하나는 배열의 개수를 알아내는 것에 있습니다.
배열의 개수를 알아야, 이를 기초로 반복문의 종료 시점을 알 수 있기 때문입니다.
그래서, 예전에는 배열의 개수를 다음과 같은 방법으로 계산했었습니다.
이를 사용해서, 위의 int_arr2의 원소 개수는 다음과 같이 계산할 수 있습니다.
int size = sizeof( int_arr2 ) / sizeof( int ); // 5
그리고, C++ 17 이후에는 std::size 템플릿 함수를 사용하여 배열의 크기를 구할 수 있습니다.
#include <iterator> // for std::size
int int_arr2[]{ 1, 2, 3, 4, 5 };
int length = std::size( int_arr2 ); // 5
만약, C++17 이전 버전을 사용해야 한다면, 위의 std::size 같은 템플릿 함수를 직접 구현하면 됩니다.
// C-style 배열의 원소 개수를 구하는 함수 템플릿
template< typename T, size_t N >
size_t mySize( const T (&)[N] ){ // 배열의 참조 타입
return N;
}
// ...
cout << mySize(int_arr2) << '\n'; // 5
이러한 템플릿 함수가 작동하는 이유는, 배열의 크기를 지정하는 값이 컴파일 시에 알 수 있는 상수이어야 하기 때문입니다. ( 템플릿 함수는 컴파일 시에 생성됩니다. )
참고로, 위의 매개변수 const T (&)[N]의 코드가 좀 어색할 수도 있는데, 다음을 보면 이해하기 쉬울 것입니다.
int int_arr2[]{ 1, 2, 3, 4, 5 };
int& arr_ref1[5] = int_arr2; // error !!
int (&arr_ref2)[5] = int_arr2; // int [5] 배열의 참조 타입
위의 arr_ref1의 타입이 int 배열의 참조( reference ) 타입처럼 보이지만, 이것이 말하는 것은 int 참조 타입( int& )의 배열입니다.
그리고, 참조 타입은 배열로 만들 수 없습니다.
( 그래서 오류입니다. 아래 관련글 참조 )
이러한 혼동을 막기 위한 문법이 그다음 줄의 int (&)[5] 타입입니다.
이 타입이 int [5] 타입의 참조( reference ) 타입입니다.
정리하자면, 위의 const T (&)[N] 타입은 const T [ N ] 배열 타입의 참조 타입입니다.
배열의 붕괴( array decay )
C-style 배열은 C 언어에서 없어서는 안 될 중요한 요소입니다.
그런데, 이러한 배열도 피해 갈 수 없는 중요한 단점이 있습니다.
그것은 함수에 배열을 전달하는 문제입니다.
// 배열을 전달받는 함수
size_t getArraySize( int arr[1000] ){
return sizeof(arr) / sizeof(int);
}
위의 함수는 arr 배열을 전달받아, 원소의 개수를 반환하는 함수입니다.
그런데, 이 함수는 문제점이 두 개 있습니다.
첫 번째는 원소의 개수가 많아지면, 인수( argument )의 복사의 비용이 비례적으로 비싸진다는 것입니다.
위의 예문에서는 전달되는 배열의 원소 타입이 int이지만, 만약 사용자 객체라면 이러한 비용은 훨씬 높아질 것입니다.
두 번째는 배열의 타입은 원소의 타입뿐 아니라, 원소의 개수에도 영향을 받는다는 것입니다.
그렇기 때문에, 위의 함수는 원소의 개수가 1000개인 배열만 사용할 수 있습니다.
그렇다고, 필요한 모든 배열의 타입에 대하여 함수를 작성한다는 것은 말도 안 되는 일입니다.
그래서, C를 만드는 사람들은 이러한 문제를 해결하기 위해, 배열 타입이 암시적으로 포인터 타입으로 전환되도록 만들었습니다.
그리고, 이렇게 전환된 포인터는 배열의 첫 번째 원소의 주소로 초기화됩니다.
이러한 암시적인 전환을 배열의 붕괴( array decay )라고 합니다.
그리고, 그냥 배열의 참조 타입을 사용하면 되는 것 아닌가?라고 의아해할 수도 있습니다만, 이 이러한 결정을 내릴 시기에는 참조( reference )라는 기능이 존재하지 않았습니다.
다음 코드들을 통해서, 배열이 붕괴되는 것을 직접 볼 수 있습니다.
int main(){
int int_arr[5]{ 1, 2, 3, 4, 5 };
int* pInt = int_arr; // 배열 붕괴
auto pInt2 = int_arr; // 추론에 의한 타입은 ?
cout << typeid(int_arr).name() << '\n';
cout << typeid(pInt2).name() << '\n';
}
▼출력
A5_i
Pi
먼저, 두 번째 줄을 보면, int [5] 타입의 배열 int_arr을, 바로 int 포인터 타입 pInt에 대입하는 것을 볼 수 있습니다.
이러한 문장이 가능한 것은 컴파일러가 배열 타입을 포인터 타입으로 암시적인 전환을 수행하기 때문입니다.
세 번째 문장도 마찬가집니다.
위의 출력에서 A5_i는 int_arr의 타입이 int [5] 배열을 나타냅니다.
( 참고로, 이것은 gcc 컴파일러를 사용했을 때의 결과입니다. )
이러한 int_arr 배열을 pInt2에 대입했을 뿐인데, 컴파일러는 pInt2의 타입을 배열 타입이 아니라, int 포인터 타입으로 추론하는 것을 볼 수 있습니다. ( Pi는 int 포인터 타입을 나타냅니다. )
배열 붕괴를 통한 문제 해결
C는 위에서 얘기했던, 함수에 배열을 전달하는 과정에서 생기는 문제점을 이러한 배열 붕괴( array decay )를 통해서 해결했습니다.
size_t getArraySize( int* pInt ){ // int 포인터 매개변수 사용
return sizeof(pInt) / sizeof(int);
}
int main(){
int int_arr[5]{ 1, 2, 3, 4, 5 };
// 배열 대신 포인터를 전달
int size = getArraySize( int_arr );
}
위의 예문을 보면 배열을 함수에 값으로 전달( call by value )하는 것 같지만, 보이지 않는 곳에서 실제로 생기는 일은, int 포인터를 전달하는 것입니다.
이렇게 함으로써, 먼저 고비용의 복사 문제가 해결됩니다.
그리고, 단지 포인터를 전달하는 것일 뿐이므로, int [7] 타입을 전달할 수 있을 뿐 아니라, 심지어 int [1000] 타입을 전달하는 경우에도 아무런 문제가 발생하지 않습니다.
하지만, 위의 getArraySize 함수의 매개변수 pInt만 보면, 이 매개변수가 int 변수의 포인터인지, int 타입의 원소를 가진 배열을 가리키는 포인터인지 알 수가 없습니다.
그래서, C에서는 이 문제를 해결하기 위해, 다음과 같은 문법을 추가했습니다.
size_t getArraySize( int arr[] ){ // 배열을 가리키는 포인터
return sizeof(arr) / sizeof(int);
}
int arr [ ]은 배열인 매개변수를 나타내는 것 같지만, 이것은 int 포인터를 사용한 것과 동일합니다.
단지, 매개변수인 포인터가 배열을 가리키는 것을 암시하는 것일 뿐입니다.
즉, 이 문법은 단지 표기하는 형태를 통해, 함수 사용자에게 힌트를 주는 것으로, 이러한 문법을 사용한다고 해서, 반드시 배열을 가리키는 포인터를 인수로 전달해야 되는 강제성이 있는 것은 아닙니다.
그리고 만약, 대괄호 [ ] 안에 숫자가 있으면, 이 숫자는 무시됩니다.
배열 붕괴의 단점과 해결책
위의 함수 getArraySize를 보면서, '뭔가 이상한데'라고 느꼈을지 모르겠습니다.
이 함수의 매개변수 arr은 배열이 아니라 포인터라고 얘기했었습니다.
따라서, sizeof( arr )의 값은 배열이 차지하는 메모리의 크기가 아니라, 포인터가 차지하는 크기가 됩니다.
즉, getArraySize 함수로는 전혀 상관없는 값을 얻을 수밖에 없습니다.
같은 이유로 std::size 함수를 사용할 수도 없습니다.
이 함수는 C-style 배열만을 인수로 전달할 수 있기 때문입니다. ( 포인터가 아니라 )
size_t getArraySize( int arr[] ){
return std::size(arr); // error !
}
결국, 이 배열 붕괴를 통해서 배열을 함수에 전달하는 문제는 해결했지만, 정작 중요한 원소의 개수를 구할 수가 없는 또 다른 문제를 만나게 되었습니다.
그리고, 이 문제는 배열의 값에 접근하기 위한 인덱스를 무력하게 만들기 때문에 무척 중요합니다.
( 이것이 붕괴라는 부정적인 단어를 쓰는 이유이기도 합니다. )
이러한 문제를 해결하기 위해서, C를 사용하던 사람들은 두 개의 해결책을 내놓았습니다.
첫 번째는 배열을 전달받는 함수에 배열의 원소 개수도 같이 전달하는 방법입니다.
int getSum( int arr[], int len){ // 함수에 배열의 크기도 전달
int sum = 0;
for( int i = 0; i < len; i++){
sum += arr[i];
}
return sum;
}
두 번째는 배열 내에 특수한 값을 넣고, 그 값을 배열의 끝을 표시하는 데 쓰는 방법입니다.
void toUpper( char arr[] ){
int i = 0;
while( arr[i] != '\0'){
arr[i] = std::toupper(arr[i]);
i++;
}
}
int main(){
char str[5]{ "Test" }; // str[4]의 값은 '\0'
toUpper(str);
cout << str << '\n';
}
▼출력
TEST
위의 toUpper는 char 포인터를 받아서, 배열의 원소들을 대문자로 변경하다가, 특수한 값인 NULL 문자( '\0' )를 만나면, 포인터를 통한 배열의 순환을 종료하는 함수입니다.
그리고, 이 함수가 사용하는 방식이, C-style의 문자열 함수들( strcpy, strstr... )이 사용하는 방법임을 알고 있을 것입니다.
하지만, 이런 방식들은 코드를 복잡하게 만들고, 실수를 유발하기 쉽게 만듭니다.
C++에서는 표준 라이브러리를 통해 배열 기능을 수행하는 std::array 클래스를 지원합니다.
template< typename T, size_t N >
T getSum( const std::array<T, N>& arr){ // 다른 타입으로 전환할 필요가 없음
T sum{};
for( size_t i = 0; i < N; i++){
sum += arr[i];
}
return sum;
}
int main(){
std::array arr{ 1, 2, 3, 4, 5};
int sum = getSum( arr );
cout << sum << '\n';
}
이 std::array 객체를 사용하면, 배열 붕괴( array decay ) 같은 우회적인 방법을 사용할 필요가 없으며, 이 클래스는 자체로 원소의 개수를 구하는 멤버 함수 size를 지원합니다.
게다가, C++은 참조( reference ) 타입을 사용할 수 있습니다.
정리
- 배열의 타입은 원소의 타입과 개수로 정해집니다.
- C-style의 배열은 함수에 포인터 타입으로 전달됩니다.
- void func( int arr [ ] )에서 arr은 배열이 아니라, 포인터( pointer )입니다.
이 글과 관련 있는 글들
좌측값 참조( l-value reference )의 성질
함수 템플릿 ( function template )에 대한 설명 1
'C, C++ > C++ 언어' 카테고리의 다른 글
[C++] 접근 권한을 부여받는 friend 클래스 (2) | 2024.11.11 |
---|---|
[C++] 접근 권한을 부여하는 friend 키워드 (2) | 2024.11.10 |
[C++] 범위 기반 for를 사용하기 위한 사용자 타입의 필요조건 (0) | 2024.11.09 |
[C++] 연관된 기능을 묶기 위한 중첩 타입( nested type ) (0) | 2024.11.08 |
[C++] 값을 반환하기 위한 매개변수( out parameter )에 대하여 (4) | 2024.11.07 |