
C-style array
말 그대로 C언어에서 상속받은 배열을 의미하며 다른 STL 컨테이너 클래스와 달리 C++ 언어에 내장되어 있다 (따로 헤더를 include할 필요가 없음)
유일하게 언어 자체에서 지원하는 배열 타입이다
C-style array는 [ ]를 사용하여 선언한다, 이는 컴파일러에게 [ ]를 사용한 객체가 배열임을 알린다, [ ]에는 배열의 length를 제공하고 마찬가지로 std::size_t 타입 정수값이다
(여기서 [ ]는 operator[ ]가 아니고 그냥 선언 구문의 일부이다)
int testarr[10]{};
C-style array의 length는 최소 1이어야 한다, 0이거나 음수로 들어가면 컴파일 에러를 발생시킨다
(heap에 할당된 C-style 배열은 length가 0이어도 허용됨)
std::array와 마찬가지로 length는 상수 표현식이어야 한다, 또한 operator[ ]로 인덱싱이 가능하다
int testarr[10]{};
testarr[0] = 1;
testarr[1] = 2;
C-style array의 인덱스로는 모든 정수 타입(signed, unsigned)와 enum의 enumerator를 사용할 수 있다
다른 STL 컨테이너 클래스와 마찬가지로 operator[ ]는 bound check를 하지 않기 때문에 out of bound 인덱싱 시 크래시가 발생할 수 있다
std::array와 마찬가지로 C-style array는 aggregate이기 때문에 aggreagte initialization을 사용할 수 있다 ({ })
int testarr[5]{ 1, 2, 3, 4, 5 };
C-style array에 초기화 값을 제공하지 않으면 unitialized되어 쓰레기 값이 들어가게 된다
따라서 { }를 이용하여 값 초기화를 해주는게 좋다
이때 초기화 리스트에 정의된 배열 길이보다 더 많은 초기화 값이 들어가면 컴파일 에러가 발생되고 더 적은 값이 들어가면 나머지는 기본 값 초기화가 된다
int testarr[5]{ 1, 2, 3, 4, 5, 6 }; //error
int testarr[5]{ 1, 2, 3 }; //1, 2, 3, 0 0
C-style array의 단점 중 하나는 클래스 템플릿이 아니기 때문에 CTAD를 이용한 타입 추론이 불가능하여 타입을 반드시 명시적으로 작성해야 한다는 것이다 (auto 사용 불가능)

C-style array는 초기화 값의 개수로부터 length를 추론할 수 있다, 따라서 초기화 값을 제공하고 length는 따로 작성하지 않아도 된다 (선호하는 방식이다, 초기화 값의 변경에 따라 length를 자동으로 제어해주기 때문이다, length와 초기화 값 개수간 불일치 위험이 없다)

이는 초기화 값이 명시적으로 제공될 때만 작동한다
int testarr[]{}; //error
const C-style array
std::array와 마찬가지로 C-style array는 const, constexpr이 가능하다
다른 const변수와 마찬가지로 반드시 선언과 동시에 초기화 되어야 한다
constexpr int testarr[]{ 1, 2, 3 };
testarr[1] = 30; //error, testarr가 constexpr이기 때문에 수정불가
C-style array sizeof()
sizeof()는 객체나 타입의 크기를 byte로 return하는 함수이다, 이 함수를 C-style array에 적용하면 배열 전체가 사용하는 byte를 return한다
constexpr int testarr[]{ 1, 2, 3 };
std::cout << sizeof(testarr) << std::endl; //(본인 아키텍쳐에서)int가 4바이트로 되어있어서 12가 나옴
C-style array length
다른 컨테이너 클래스와 마찬가지로 C++17부터는 iterator헤더에 있는 std::size(), C++20부터는 std::ssize()를 사용할 수 있다
std::size()로 unsigned 정수타입인 std::size_t 타입으로 값을 얻을 수 있고 std::ssize()로는 signed 정수 타입인 std::ptrdiff_t로 값을 얻을 수 있다
#include <iterator>
constexpr int testarr[]{ 1, 2, 3 };
std::cout << std::size(testarr) << std::endl;
std::cout << std::ssize(testarr) << std::endl;
그렇다면 C++14이전에는 어떻게 length를 얻을까?
따로 템플릿 함수를 만들어서 사용해야 한다
template <typename T, std::size_t N>
constexpr std::size_t GetLength(const T(&) [N]) noexcept
{
return N; //길이 반환
}
int main()
{
constexpr int testarr[]{ 1, 2, 3 };
std::cout << GetLength(testarr) << std::endl;
return 0;
}
굉장히 오래된 코드에서는 C-style array의 길이를 전체 배열의 크기에서 배열 element 하나의 크기로 나누는 방식을 사용하는걸 볼 수 있다
constexpr int testarr[]{ 1, 2, 3 };
std::cout << sizeof(testarr) / sizeof(testarr[0]) << std::endl;
return 0;
이러한 방식은 decay된 array에서 의도치 않게 동작할 수 있다 (굳이 이런 방식을 사용할 필요가 없음)
C-style array는 할당을 지원하지 않는다
int testarr[]{ 1, 2, 3 };
testarr[0] = 10;
testarr = { 4, 5, 6 }; //error, 배열 할당 불가능
C-style array는 수정 가능한 lvalue로 간주되지 않기 때문이다, 만약 새로운 값 목록을 할당해야 한다면 std::vector를 사용하자 혹은 std::copy를 사용하여 값을 복사하는 방식이 있다
#include <algorithm>
int main()
{
int testarr[]{ 1, 2, 3 };
int testarr1[]{ 4, 5, 6 };
std::copy(std::begin(testarr), std::end(testarr), std::begin(testarr1)); // std::copy 사용
return 0;
}
testarr의 시작부터 끝까지를 testarr1에 복사한다는 의미이다, 따라서 testarr1도 element가 1, 2, 3으로 들어가게 된다
C-style array decay
void foo(int a)
{
std::cout << a;
}
int main()
{
int a{ 10 };
foo(a);
return 0;
}
위 코드는 a의 값이 복사되어 foo()의 매개변수로 전달되고 10이 출력된다, 단순 int값 하나를 복사하기 때문에 오버헤드가 크게 발생하지 않는다
그렇다면 다른 객체를 element 1000개를 가진 배열을 이와 같은 방식으로 passing한다면 어떨까? 예상하기에는 비용이 아주 많이 발생할 것이다
void foo(int a[1000])
{
std::cout << a[0];
}
int main()
{
int a[1000]{ 5 };
foo(a);
return 0;
}
C 설계자들은 위와 같은 비용 문제로 해결책을 고안하였다
우선 C언어에는 참조 개념이 없기 때문에 pass by ref는 사용할 수 없었다
또한 length가 각각 다른 배열 인자를 받을 수 있는 함수를 사용할 수 있어야 한다는걸 중점으로 설계했다
사실 위 코드는 복사가 발생하지 않는다, 왜 발생하지 않는지 정리해보자
C-style array가 표현식에서 사용될 때 배열은 암시적으로 element 타입의 포인터로 변환되고 0번째 element의 주소로 초기화 된다, 이를 array decay라고 칭한다

auto가 int*로 추론된 걸 확인할 수 있다, 이때 arr는 decay 되었다고 한다
여기서 ptrarr은 단순히 arr배열의 0번째 element의 주소를 가지게 된다
배열이 const라면 const 포인터로 decay된다

C++에서 C-style array가 decay되지 않는 경우가 몇 가지 존재한다
sizeof(), typeid()의 인자로 사용될 때
여기서 typeid()는 typeinfo 헤더가 필요하며 런타임에 타입을 식별하는 함수이다(std::type_info를 return함, name()을 사용하여 이름을 얻을 수 있다)
operator&로 배열의 주소를 가져올때 (&arr)
배열 전체의 시작 주소를 return한다, 배열의 element 0번째를 가리키는 포인터가 아닌 배열 자체를 가리키는 포인터이다 (배열의 크기 정보를 포함한다)
클래스 타입의 멤버에 존재할때
참조로 전달될 때 (void foo(int(&arr)[5])
decay된 배열에는 length 정보를 포함되지 않는다 (T*를 타입으로 가지기 때문)
decay된 배열은 결국 length 정보의 손실을 나타낸다
decay된 배열에도 operator[ ]를 사용하여 인덱싱이 가능하다
const int arr[5]{ 1, 2, 3, 4, 5 };
auto ptrarr{ arr };
std::cout << ptrarr[2] << std::endl;
위에서 설명한 함수의 인자로 배열을 넘기는 상황에서 복사가 발생하지 않는 이유가 바로 이 decay된 배열때문이다 (포인터를 넘기는 방식이 되기 때문)
따라서 int[5]나 int[1000]이나 결국 decay된 array가 넘어가기 때문에(int*) 다른 길이의 배열을 전달 할 수 있는것이다
void foo(const int* ptrarr)
{
std::cout << ptrarr[0];
}
int main()
{
const int arr[5]{ 1, 2, 3, 4, 5 };
const int arr1[1000]{ 1, 2, 3, 4, 5 };
foo(arr); //decay
foo(arr1); //decay
return 0;
}
단 이렇게 함수의 매개변수를 포인터로 작성하면 배열이 아니라 단순히 포인터를 넘기는 의미로 보일 수 있기 때문에 대체 선언 형식인 T arr[]를 사용하는게 좋다
void foo(const int ptrarr[])
{
std::cout << ptrarr[0];
}
컴파일러는 결국 const int ptrarr[]를 const int*와 동일하게 해석한다, 호출자에게 포인터 값이 아닌 decay된 C-style array라는걸 알릴 수 있어서 더 선호하는 방식이다
[ ]사이에는 length 정보가 들어가지 않는다, 들어가도 어차피 사용되지 않기 때문에 무시됨
복사 오버헤드도 없고 굉장히 좋은 방식으로 보이지만 단점이 존재한다
decay 시키지 않기 위해 &를 사용한다면 [ ]에 length를 무조건 넣어야 한다
void foo(int (&inArr)[3])
{
std::cout << sizeof(inArr);
}
void foo(const int ptrarr[])
{
std::cout << sizeof(ptrarr) << std::endl;
}
int main()
{
const int arr[5]{ 1, 2, 3, 4, 5 };
foo(arr); //8
std::cout << "sizeof(arr) : " << sizeof(arr) << std::endl; //20
return 0;
}
일반 배열의 sizeof()는 element의 크기를 전부 합친 값이 byte로 나오지만 decay된 배열은 포인터이기 때문에 포인터 크기로 나오게 된다
이러한 결과 때문에 C-style array에 sizeof()를 사용하는게 위험하다는 것이다, 위에서 length를 얻기 위해 배열의 크기를 배열의 0번째 element 크기로 나누는 방식을 정리했는데 만약 decay된 array의 sizeof()를 사용한다면 의도치 않은 값이 나올 수 있다
void foo(const int ptrarr[])
{
std::cout << ptrarr[3] << std::endl; //길이 정보가 없기 때문에 유효한 인덱싱인지 보장되지 않음
}
길이가 없기 때문에 언제 배열의 끝에 도달했는지 모른다 따라서 순회를 하는데 어려움이 생긴다
이러한 길이 정보 부족 문제를 해결하기 위한 방법이 존재한다
(1) 함수에 길이 정보 전달하기
void foo(const int ptrarr[], int length)
{
assert(length > 3); //함수 매개변수라 static_assert 사용불가
std::cout << ptrarr[3] << std::endl;
}
반드시 배열과 길이 정보를 맞춰주어야 한다, 휴먼 에러 발생 가능성이 높다
또한 assert만 사용이 가능하기 때문에 런타임 체크만 가능하다
(2) 의미 없는 값 하나를 end로 지정하기
의미 없는 값 하나를 element의 끝으로 지정한다면 해당 element를 end로 판단하여 순회가 가능하다
ex) C-style string에서 문자열의 끝을 표시하기 위한 NULL \0과 같은 방식, 따라서 C-style string은 decay되어도 순회가 가능하다
결국 마찬가지로 휴먼에러 발생 가능성이 높아 의도치 않은 동작이 발생할 확률이 존재한다
일반적으로 이러한 decay된 array와 관련된 위험때문에 C-style array는 지양하는게 좋다
std::array, std::vector, std::string std::string_view를 사용하자
C++에서는 C와 다르게 배열을 ref로 전달이 가능하기때문에 배열이 decay되지 않는다, 단 고정된 길이를 가지기 때문에 특정 길이의 배열만 처리가 가능하고 참조를 사용하지 않으면 decay 되기 때문에 휴먼 에러도 발생할 수 있다, 그냥 std::array를 사용하자
포인터 연산, 첨자 연산
포인터에 덧셈, 뺄셈, 증가, 감소 연산을 적용하여 특정 메모리 주소에 접근하는 연산을 포인터 연산이라고 한다
예를들어 ptr이라는 포인터에 +1을 하면 다음 객체의 주소를 return한다, 이때 ptr이 int타입 포인터이고 int가 4byte인 아키텍처라면 ptr + 1은 1이 증가하는게 아니라 4가 증가하게되어 4byte 뒤의 메모리 주소가 return되고 +2는 같은 방식으로 8byte 뒤의 메모리 주소가 return된다
int a{};
const int* ptra{ &a };
std::cout << ptra << ' ' << (ptra + 1) << ' ' << (ptra + 2) << '\n';
//int가 4byte인 아키텍쳐 기준으로 4씩 증가한 메모리 주소를 return한다
많이 사용되지는 않지만 뺄셈도 같은 방식으로 동작한다
포인터가 가리키는 타입에 기반하여 다음/이전 메모리 주소를 return하는게 키포인트이다
++, --도 같은 동작을 하지만 이 연산은 실제 포인터 변수가 가리키는 주소 자체를 변경시킨다
int a{};
const int* ptra{ &a };
++ptra;
std::cout << ptra << std::endl;
--ptra;
std::cout << ptra << std::endl;
//실제 ptra가 가리키는 주소 자체가 변경
C++ 표준에서는 이러한 포인터 연산은 같은 배열 안에서만 안전하세 사용될 수 있다고 정의되어 있다 (배열의 마지막 element 바로 다음까지)
위의 예시로 작성한 단일 변수에서의 포인터 연산은 위험할 수 있으니 조심해야 한다
operator[ ] 첨자 연산은 포인터 연산을 통해 구현된다, operator[ ]는 포인터에 적용할 수 있다
const int arr[]{ 1, 2, 3, 4, 5 };
const int* ptrarr{ arr }; //arr[0]의 주소를 보유한 포인터로 decay된다
std::cout << ptrarr[2] << '\n'; //arr[0]의 주소로부터 +2 된 메모리 주소의 객체를 return한다)
이러한 [ ]연산은 사실 ((ptr) + n))과 동일한 구문이다 (포인터 주소값에 N을 더하고 연산으로 실제 해당 주소의 객체를 얻는다)
int main()
{
const int arr[]{ 3, 2, 1 };
std::cout << &arr[0] << ' ' << &arr[1] << ' ' << &arr[2] << '\n';
std::cout << arr[0] << ' ' << arr[1] << ' ' << arr[2] << '\n';
std::cout << arr << ' ' << (arr + 1) << ' ' << (arr + 2) << '\n';
std::cout << *arr << ' ' << *(arr + 1) << ' ' << *(arr + 2) << '\n';
//같은 동작을 하게 된다
return 0;
}
배열의 element는 메모리에서 순차적이기 때문에 배열의 이름이 0번째 element를 가리키는 포인터이고 + n 을 하면서 n번째 element를 return 하게 된다
여기서 배열이 1이 아닌 0기반 indexing을 하는 주요 이유가 나타난다, 1이라면 + n을 할 때 항상 -1을 해줘야 했을것이다
사실 ptr[n]은 ((ptr) + (n))과 동일하다고 했기 때문에 n[ptr]도 ((n) + (ptr))로 동일하게 동작하게 된다 (실무에서 이렇게 절대 사용하지 말 것, 그냥 작동은 함)
따라서 포인터 연산과 [ ]연산은 상대 주소라는걸 알 수 있다 (0을 기준으로 얼마나 +n하는지에 따라 다른 메모리 주소를 return하기 때문에 상대주소다)
arr[1], arr[2]가 무조건 1번, 2번 element를 가리키는게 아닐수도 있다는 것이다
const int arr[]{ 1, 2, 3, 4, 5 };
const int* ptrarr{ arr };
ptrarr = &arr[2]; //element 3을 가리키는 주소로 재할당
std::cout << ptrarr[1]; //3기준으로 상대주소 + 1이기 때문에 4가 나온다
하지만 배열에서 [ ]연산을 사용할 때 보통은 0을 항상 기준으로 하는게 혼란스럽지 않다, 따라서 상대적인 위치를 지정할때는 포인터 연산을 사용하고 그렇지 않고 0을 기준으로 인덱싱 한다면 [ ]를 사용하는게 좋다
Negative indices
C-style array는 다른 STL 컨테이너 클래스와 달리 인덱스가 signed, unsigned 정수일 수 있다고 정리했다
이는 실제로 C-style array는 음수로 인덱싱이 가능하다는 의미이다
ex) ptr[-1]
이 구문은 곧 *(ptr - 1)이 되고 이전 객체의 주소를 return하게 된다
const int arr[]{ 1, 2, 3, 4, 5 };
const int* ptrarr{ arr };
ptrarr = &arr[2]; //element 3을 가리키는 주소로 재할당
std::cout << ptrarr[-1]; //*(ptr - 1) 연산이기 때문에 3이전의 element인 2를 출력한다
포인터 연산으로 배열 순회
포인터 연산의 가장 일반적인 사용처는 인덱싱 없이 C-style 배열을 순회하는것이다
constexpr int arr[]{ 1, 2, 3, 4, 5 };
const int* begin{ arr }; //배열의 0번째 element의 주소 (decay)
const int* end{ arr + std::size(arr) }; //배열의 마지막 element바로 다음 주소
for (; begin != end; ++begin) //시작과 끝 포인터가 같지 않을때까지 증가
{
std::cout << *begin << '\n'; //순회
}
end 포인터에는 배열의 마지막 element 바로 다음 주소가 들어가있다, 해당 주소에는 valid한 값이 없기때문에 역참조해서는 안된다
이러한 시작과 끝 주소를 가진 포인터를 이용하여 배열을 순회하는것의 장점은 바로 decay된 배열과 decay되지 않은 배열간의 동작 차이에 의한 이슈를 막을 수 있다는 것이다
void print(const int* begin, const int* end)
{
for (; begin != end; ++begin)
{
std::cout << *begin << '\n';
}
}
int main()
{
constexpr int arr[]{ 1, 2, 3, 4, 5 };
const int* begin{ arr };
const int* end{ arr + std::size(arr) };
print(begin, end);
return 0;
}
함수의 인자로 배열을 전달 시 decay되어 원래 배열과 동작 차이가 발생할 수 있지만 위와 같은 코드는 시작, 끝 주소를 가진 포인터만 전달하기 때문에 발생하지 않는다
그렇기 때문에 추후에 다양한 알고리즘에서 begin, end 를 사용하는 함수가 많은것이다
C-style array에 대한 for-each loop는 포인터 연산을 사용하여 구현된다
constexpr int arr[]{ 1, 2, 3, 4, 5 };
for (int i : arr)
{
std::cout << i << '\n';
}
for-each의 내부 구현을 보면 다음과 같다
// begin-expr는 범위의 시작을 나타내는 표현식
auto __begin = begin-expr;
// end-expr는 범위의 끝(바로 다음)을 나타내는 표현식
auto __end = end-expr;
// 내부적으로는 begin/end 포인터를 사용하는 일반 for 루프로 변환됨
for ( ; __begin != __end; ++__begin)
{
// range-declaration은 루프 변수 (예: auto e)
range-declaration = *__begin; // 현재 요소를 루프 변수에 복사
// loop-statement는 루프 본문 (예: std::cout << e << ' ';)
loop-statement;
}
개념 추출 (Concept Extraction)
C-style array의 정의: C언어에서 상속받은 배열이며, STL 컨테이너 클래스와 달리 별도의 헤더 include 없이 언어 자체(built-in)에서 지원하는 유일한 배열 타입이다.
선언 문법: [ ]를 사용하여 선언하며, 이는 컴파일러에게 해당 객체가 배열임을 알린다.
배열 길이(Length) 타입: [ ] 안에 들어가는 길이는 std::size_t 타입의 정수값이어야 한다.
배열 길이 제약조건: 길이는 최소 1이어야 하며, 0이거나 음수일 경우 컴파일 에러가 발생한다. (단, Heap에 할당된 경우는 0 허용)
상수 표현식 요구: std::array와 마찬가지로 배열의 길이는 반드시 컴파일 타임 상수 표현식이어야 한다.
인덱스 타입: 인덱싱에는 operator[ ]를 사용하며, 모든 정수 타입(signed, unsigned)과 enum의 enumerator를 사용할 수 있다.
Bound Check 부재: STL 컨테이너와 달리 Bound Check(경계 검사)를 하지 않아, 범위 밖 인덱싱 시 크래시 등 런타임 에러 위험이 있다.
초기화 (Aggregate Initialization): C-style array는 aggregate이므로 { }를 사용한 초기화가 가능하다.
초기화 미제공 시 문제점: 초기화 값을 주지 않으면 쓰레기 값(garbage value)으로 초기화(uninitialized)된다.
초기화 리스트 규칙: 정의된 길이보다 초기화 값이 많으면 컴파일 에러, 적으면 나머지는 기본값(0)으로 초기화된다.
CTAD 불가능: 클래스 템플릿이 아니므로 CTAD(클래스 템플릿 인자 추론)를 사용할 수 없어, 타입을 명시적으로 작성해야 한다 (auto 사용 불가).
길이 추론 (Length Deduction): 초기화 값을 제공하고 [ ]를 비워두면, 컴파일러가 초기화 값의 개수로 길이를 자동 추론한다. (길이 불일치 오류 방지를 위해 선호되는 방식)
길이 추론의 제약: 초기화 값이 명시적으로 제공되지 않은 경우(int testarr[]{};)는 에러가 발생한다.
const/constexpr 지원: 선언 시 const, constexpr 사용이 가능하며, 이때는 반드시 선언과 동시에 초기화해야 한다. (이후 수정 불가)
sizeof() 동작: 배열에 sizeof()를 사용하면 배열 전체가 차지하는 메모리 크기(byte)를 반환한다.
길이 구하기 (Modern C++): C++17부터는 <iterator>의 std::size() (반환: std::size_t), C++20부터는 std::ssize() (반환: std::ptrdiff_t)를 사용한다.
길이 구하기 (Legacy - Template): C++14 이전에는 배열의 참조를 받는 템플릿 함수를 만들어 길이를 반환받는 방식을 사용했다.
길이 구하기 (Legacy - sizeof division): 아주 오래된 코드에서는 sizeof(arr) / sizeof(arr[0]) 방식을 사용했으나, 이는 Decay된 배열에서 오동작할 위험이 있다.
할당(Assignment) 불가: C-style array는 수정 가능한 lvalue가 아니므로, 선언 이후 다른 배열을 대입(=)할 수 없다.
배열 복사 방법: 값을 복사하려면 std::vector를 쓰거나 <algorithm>의 std::copy(begin, end, dest)를 사용해야 한다.
Array Decay의 개념: 배열이 표현식에서 사용될 때, 암시적으로 '첫 번째 요소(0번 인덱스)의 주소를 가리키는 포인터'로 변환되는 현상이다.
Array Decay의 이유: 함수 인자로 전달 시 배열 전체를 복사하는 비용(오버헤드)을 줄이고, 다양한 길이의 배열을 하나의 함수로 받기 위함이다.
Decay 시 속성 변화: 배열 타입에서 포인터 타입(T*)으로 변하며, const 배열은 const 포인터로 변환된다. 이때 길이 정보(Length)는 손실된다.
Decay가 발생하지 않는 예외: sizeof(), typeid()의 인자, &(주소 연산자) 사용, 참조(&)로 전달, 클래스 멤버일 때는 Decay 되지 않는다.
Decay된 배열의 사용: 포인터로 변했어도 operator[ ]를 통한 인덱싱은 여전히 가능하다.
함수 파라미터 선언 방식: void foo(int* arr)와 void foo(int arr[])는 컴파일러 입장에서 동일하게 처리되지만, arr[] 방식이 배열임을 명시하기에 선호된다. ([ ] 안의 숫자는 무시됨)
참조를 통한 전달 (Decay 방지): void foo(int (&arr)[N]) 처럼 참조로 받으면 Decay가 발생하지 않고 길이 정보도 유지되지만, 고정된 길이의 배열만 받을 수 있다는 단점이 있다.
Decay된 배열의 sizeof 문제: 함수 매개변수로 넘어와 Decay된 배열에 sizeof()를 쓰면 배열 전체 크기가 아니라 포인터의 크기(4 or 8 byte)가 반환되어 의도치 않은 동작을 유발한다.
Decay된 배열의 순회 문제: 길이 정보가 없기 때문에 어디가 끝인지 알 수 없어 순회(Iteration)가 어렵다.
Decay 해결책 1 (길이 전달): 함수 인자로 길이(length)를 함께 전달한다. (단, 런타임 체크만 가능하고 휴먼 에러 가능성 있음)
Decay 해결책 2 (Sentinel): 문자열의 \0처럼 의미 없는 값을 끝 표시(Sentinel)로 사용한다. (유효한 데이터와 혼동될 위험 있음)
일반적인 권장 사항: Decay로 인한 위험성 때문에 C-style array 대신 std::array, std::vector 사용이 권장된다.
포인터 연산 (Pointer Arithmetic): 포인터에 +, -, ++, -- 연산을 하면, 해당 타입의 크기(byte)만큼 주소값이 증감하여 다음/이전 객체를 가리킨다.
포인터 연산의 안전성: 표준에 따르면 포인터 연산은 같은 배열 내부(및 마지막 요소 바로 다음)에서만 안전하게 동작한다. 단일 변수에서의 연산은 위험할 수 있다.
첨자 연산(Subscripting)의 원리: ptr[n]은 내부적으로 *((ptr) + n)과 동일하게 동작한다. (상대 주소 개념)
0-based Indexing의 이유: 배열 이름이 시작 주소를 의미하므로, + n 연산을 통해 n번째 요소에 접근하기 위해 0부터 시작하는 것이 효율적이다.
교환 법칙 (Commutativity): ptr[n]과 n[ptr]은 논리적으로 동일하게 동작하지만, 가독성을 위해 n[ptr] 방식은 사용하지 말아야 한다.
상대 주소 활용: 포인터를 배열의 중간 요소로 재설정하면, 그 포인터 기준의 인덱싱이 가능하다. (예: ptr이 arr[2]를 가리키면 ptr[1]은 arr[3]을 의미)
음수 인덱스 (Negative Indices): C-style array(포인터)는 음수 인덱싱이 가능하다. ptr[-1]은 *(ptr - 1)을 의미하여 현재 주소의 이전 요소를 참조한다.
포인터를 이용한 순회: 인덱스 변수 없이 begin 포인터와 end 포인터를 사용하여 배열을 순회할 수 있다.
End 포인터의 의미: end 포인터는 배열의 '마지막 요소 바로 다음 주소'를 가리키며, 이곳에는 유효한 값이 없으므로 역참조(dereference)해서는 안 된다.
포인터 순회의 장점: 함수 인자로 배열을 전달할 때(Decay 발생 시), 시작과 끝 포인터를 전달하면 원본 배열과 동일한 방식으로 안전하게 순회할 수 있다.
Range-based for loop의 원리: C-style array에 대한 for-each 반복문은 내부적으로 begin, end 포인터를 이용한 포인터 연산 루프로 변환되어 동작한다.
Quiz
Q1. C-style array가 std::vector나 std::array 같은 다른 STL 컨테이너와 구별되는, 언어적 차원에서 지원하는 가장 큰 특징은 무엇인가?
Q2. C-style array를 선언할 때 사용하는 기호는 무엇이며, 이는 컴파일러에게 무엇을 알리는가?
Q3. 배열 선언 시 [ ] 안에 들어가는 길이 값의 데이터 타입은 무엇인가?
Q4. C-style array(Stack 메모리 기준)의 길이에 대한 제약 조건(최소값 등)과 이를 어길 시 발생하는 현상은 무엇인가?
Q5. std::array와 마찬가지로 C-style array의 길이를 지정할 때 값의 성격(런타임 변수 vs 상수 등)은 어떠해야 하는가?
Q6. C-style array의 인덱스로 사용할 수 있는 타입들의 범주를 모두 서술하시오.
Q7. C-style array는 operator[ ] 사용 시 유효한 인덱스인지 확인하는 절차(Bound Check)를 거치는가? 그로 인한 위험성은 무엇인가?
Q8. C-style array는 'aggregate'이다. 따라서 사용할 수 있는 초기화 방식은 무엇인가?
Q9. C-style array 선언 시 초기화 값을 전혀 제공하지 않으면 배열의 요소들은 어떤 값을 가지게 되는가?
Q10. 배열의 크기보다 적은 개수의 초기화 값을 제공했을 때, 나머지 요소들은 어떻게 처리되는가? 반대로 더 많은 값을 제공하면 어떻게 되는가?
Q11. C-style array는 클래스 템플릿이 아니다. 이로 인해 변수 선언 시 사용할 수 없는 키워드와 제약 사항은 무엇인가?
Q12. 배열 선언 시 [ ] 안의 숫자를 생략하고 초기화 값만 제공했을 때, 컴파일러는 길이를 어떻게 결정하는가? 이 방식이 선호되는 이유는?
Q13. int testarr[]{}; 코드가 컴파일 에러를 발생시키는 이유는 무엇인가?
Q14. const 또는 constexpr로 배열을 선언할 때 반드시 지켜야 하는 문법적 규칙은 무엇인가?
Q15. 배열 이름 자체에 sizeof() 연산자를 적용했을 때 반환되는 값의 의미는 무엇인가?
Q16. C++17과 C++20에서 각각 배열의 길이를 구하기 위해 <iterator> 헤더에서 제공하는 함수는 무엇인가?
Q17. C++14 이전 환경에서 배열의 길이를 안전하게 구하기 위해 주로 사용했던 방법(함수 형태)은 무엇인가?
Q18. 아주 오래된 코드에서 sizeof(arr) / sizeof(arr[0]) 방식으로 길이를 구할 때, 이 방식이 위험할 수 있는 특정 상황은 언제인가?
Q19. int arr1[]{1,2}; int arr2[]{3,4}; 일 때 arr1 = arr2;와 같은 대입 연산이 불가능한 이유는 무엇인가?
Q20. C-style array의 데이터를 다른 배열로 복사하고 싶을 때 사용할 수 있는 대표적인 방법 2가지는 무엇인가?
Q21. 'Array Decay'란 무엇이며, 배열이 표현식에서 사용될 때 구체적으로 어떤 값으로 변환되는가?
Q22. C 언어 설계자들이 Array Decay 메커니즘을 고안한 주요 목적(이점) 2가지는 무엇인가?
Q23. Array Decay가 발생했을 때, 배열의 타입과 const 속성은 어떻게 변하며, 이때 손실되는 정보는 무엇인가?
Q24. C++에서 배열이 Decay 되지 않고 배열 그 자체로 취급되는 예외적인 상황들을 나열하시오.
Q25. 배열이 포인터로 Decay 된 상태에서도 여전히 operator[ ]를 사용하여 인덱싱이 가능한가?
Q26. 함수의 매개변수로 배열을 받을 때 int* arr 대신 int arr[] 문법을 사용하는 것이 권장되는 이유는 무엇인가? (실제 동작의 차이는 없음)
Q27. 함수 파라미터에서 배열의 Decay를 막고 길이 정보를 유지하기 위해 사용할 수 있는 선언 방식은 무엇인가? (단, 이 방식의 단점은?)
Q28. 함수 매개변수로 전달받아 Decay 된 배열에 sizeof()를 사용하면 어떤 값이 반환되는가?
Q29. Decay 된 배열(포인터)을 통해 데이터를 순회(Iteration)하려고 할 때 발생하는 근본적인 어려움은 무엇인가?
Q30. Decay 된 배열의 길이 정보 부족 문제를 해결하기 위해, 함수 인자로 길이를 함께 전달하는 방식의 한계점은 무엇인가?
Q31. Decay 된 배열의 끝을 식별하기 위해 'Sentinel value(의미 없는 값)'를 사용하는 방식(예: C-string의 NULL)의 잠재적 위험은 무엇인가?
Q32. Decay 및 길이 관리의 위험성 때문에 현대 C++에서 C-style array 대신 사용을 권장하는 STL 컨테이너들은 무엇인가?
Q33. 포인터 변수에 +1 연산을 수행했을 때, 실제 메모리 주소값은 얼마나 증가하는가? (타입과 관련지어 설명)
Q34. 포인터 덧셈/뺄셈 연산이 C++ 표준상 안전하게 보장되는 범위는 어디까지인가?
Q35. 배열의 인덱싱 연산 ptr[n]은 내부적으로 어떤 포인터 연산 식으로 변환되어 처리되는가?
Q36. 배열의 인덱스가 1이 아닌 0부터 시작하는(0-based indexing) 주된 이유는 포인터 연산 관점에서 무엇인가?
Q37. ptr[n]과 n[ptr]이 동일하게 동작함에도 불구하고, 실무에서 n[ptr]을 사용하지 말아야 하는 이유는 무엇인가?
Q38. 포인터가 배열의 첫 번째 요소가 아닌 중간 요소를 가리키고 있을 때, [ ] 연산은 어떤 기준으로 동작하는가? (상대 주소 개념)
Q39. C-style array(포인터)에서 음수 인덱스(예: ptr[-1])를 사용하는 것은 가능한가? 가능하다면 어떤 의미를 가지는가?
Q40. 인덱스 변수(i)를 사용하지 않고, 포인터만 사용하여 배열을 처음부터 끝까지 순회하려면 어떤 두 가지 포인터가 필요한가?
Q41. 배열 순회 시 사용하는 end 포인터가 가리키는 위치는 정확히 어디이며, 이 포인터를 사용할 때의 주의사항(금기)은 무엇인가?
Q42. 함수에 배열을 전달할 때, 배열 자체 대신 begin과 end 포인터를 전달하는 방식이 가지는 장점은 무엇인가?
Q43. C-style array에 대해 Range-based for loop (for-each 문)를 사용할 때, 컴파일러는 내부적으로 이를 어떻게 구현하는가?
[정답지]
A1. 별도의 헤더 파일 include 없이 언어 자체(built-in)에 내장되어 있다.
A2. [ ] (대괄호)를 사용하며, 이는 해당 객체가 배열임을 알린다.
A3. std::size_t 타입의 정수값.
A4. 길이는 최소 1이어야 한다. 0이거나 음수이면 컴파일 에러가 발생한다. (단, Heap 할당은 0 허용)
A5. 컴파일 타임에 알 수 있는 **상수 표현식(constant expression)**이어야 한다.
A6. 모든 정수 타입(signed, unsigned)과 enum의 enumerator(열거형 상수).
A7. 하지 않는다. 유효하지 않은 인덱스(Out of bound)에 접근하면 메모리 크래시 등 정의되지 않은 동작이 발생할 수 있다.
A8. Aggregate initialization (중괄호 초기화 { })를 사용할 수 있다.
A9. 쓰레기 값(garbage value)으로 초기화(uninitialized)된다.
A10. 초기화 값이 적으면 나머지 요소는 기본값(0)으로 초기화되고, 더 많으면 컴파일 에러가 발생한다.
A11. CTAD(타입 추론)를 사용할 수 없어 auto 키워드로 선언할 수 없으며, 타입을 명시적으로 적어야 한다.
A12. 제공된 초기화 값의 개수를 세어 자동으로 길이를 추론한다. 길이와 초기화 값 개수의 불일치 문제를 방지할 수 있어 선호된다.
A13. 초기화 값이 명시적으로 제공되지 않아(빈 중괄호) 크기가 0인 배열이 되는데, 이는 표준 C++에서 허용되지 않기 때문이다.
A14. 선언과 동시에 반드시 초기화해야 한다.
A15. 배열의 요소 하나의 크기가 아니라, 배열 전체가 차지하는 총 바이트(byte) 크기를 반환한다.
A16. C++17은 std::size(), C++20은 std::ssize().
A17. 배열의 참조를 인자로 받아 길이를 반환하는 템플릿 함수를 만들어 사용했다.
A18. 함수 인자로 전달되어 Decay(포인터로 변환)된 배열에 사용할 경우, 의도치 않은 값(포인터 크기/요소 크기)이 나온다.
A19. C-style array는 '수정 가능한 lvalue(modifiable lvalue)'가 아니므로 할당을 지원하지 않는다.
A20. std::vector를 사용하거나, <algorithm> 헤더의 std::copy 함수를 사용한다.
A21. 배열 이름이 표현식에서 사용될 때 첫 번째 요소(인덱스 0)의 주소를 가리키는 포인터로 암시적 형변환되는 현상.
A22. 1. 배열 전체를 복사하는 비용(오버헤드)을 줄이기 위해(참조 개념이 없었음). 2. 길이가 다른 배열들을 하나의 함수(포인터 매개변수)로 처리하기 위해.
A23. 타입은 T* (포인터)로 변하고, const 배열은 const 포인터가 된다. 이때 배열의 길이(Length) 정보가 손실된다.
A24. sizeof()의 인자, typeid()의 인자, &(주소 연산자) 사용 시, 참조(&)로 전달될 때, 클래스 멤버일 때.
A25. 가능하다. Decay된 포인터에도 [ ] 연산자를 사용할 수 있다.
A26. 컴파일러는 const int*와 동일하게 취급하지만, 호출자나 코드 리더에게 "이것은 단일 포인터가 아니라 배열(decayed array)이다"라는 의도를 명확히 전달할 수 있기 때문이다.
A27. void foo(int (&arr)[N])과 같이 참조로 받는다. 단점은 지정된 길이(N)를 가진 배열만 받을 수 있다는 유연성 부족이다.
A28. 배열 전체 크기가 아니라 포인터 변수의 크기 (일반적으로 4 또는 8바이트)가 반환된다.
A29. 길이 정보가 없기 때문에 배열의 끝이 어디인지 알 수 없어 안전한 순회가 보장되지 않는다.
A30. 길이 정보를 매개변수로 따로 받아야 하므로 길이 불일치 등 휴먼 에러가 발생할 수 있고, assert 등으로 런타임 체크만 가능하다.
A31. 유효한 데이터 값 중 하나가 우연히 Sentinel 값과 같을 경우, 이를 배열의 끝으로 잘못 인식하여 순회를 조기 종료할 위험이 있다.
A32. std::array, std::vector, std::string, std::string_view.
A33. 포인터가 가리키는 데이터 타입의 크기(sizeof(T))만큼 주소값이 증가한다. (단순히 숫자 1이 더해지는 것이 아님)
A34. 같은 배열 내부와 마지막 요소 바로 다음(one past the last element) 위치까지만 안전하게 정의된다.
A35. *((ptr) + n) (포인터에 n만큼 더한 주소의 값을 역참조).
A36. 배열의 이름(포인터)이 첫 번째 요소의 주소를 가리키므로, 첫 번째 요소에 접근하려면 주소에 +0을 해야 하기 때문이다. (오프셋 개념)
A37. 문법적으로는 *(n + ptr)과 같아 동작은 하지만, 코드의 가독성을 심각하게 해치고 직관적이지 않기 때문이다.
A38. 현재 포인터가 가리키는 주소를 기준으로 상대적인 위치를 계산한다. (예: ptr[1]은 현재 위치의 바로 다음 요소)
A39. 가능하다. ptr[-1]은 *(ptr - 1)과 같으므로, 현재 포인터가 가리키는 위치의 바로 이전 요소에 접근한다는 의미이다.
A40. 배열의 시작 주소를 가리키는 begin 포인터와, 배열의 끝(마지막 다음)을 가리키는 end 포인터.
A41. 배열의 마지막 요소 바로 다음 메모리 주소를 가리킨다. 이곳에는 유효한 값이 없으므로 절대 역참조(dereference)해서는 안 된다.
A42. 배열이 Decay 되어 길이 정보를 잃는 문제를 방지하고, 함수 내부에서 원본 배열의 범위만큼 안전하게 순회할 수 있다.
A43. 내부적으로 begin과 end 포인터를 생성하여 비교하고 증가시키는 포인터 연산 루프로 변환하여 실행한다.