std::array 입문이전 16.1 레슨에서 컨테이너와 배열에 대해 처음 배웠었죠.
핵심만 아주 간단하게 다시 짚어볼게요!
std::vector, std::array, 그리고 아주 옛날 방식인 C스타일 배열이죠.그리고 16.10 레슨에서, 배열은 크게 두 가지 종류로 나뉜다고 했어요.
std::array 가 여기에 속해요.std::vector 가 바로 이 동적 배열이랍니다.이전 장에서는 주로 std::vector 에 대해 배웠어요. 빠르고, 쓰기 편하고, 기능도 다양해서 우리가 배열이 필요할 때 가장 먼저 찾는 아주 훌륭한 도구죠.
"그럼 무조건 융통성 있는 동적 배열만 쓰면 안 되나요?"
동적 배열은 확실히 강력하고 편리하지만, 세상 모든 일이 그렇듯 장점이 있으면 살짝 양보해야 하는 부분(Tradeoff)도 있기 마련이에요.
std::vector 는 크기가 딱 고정된 배열보다 속도가 아주 살짝 느립니다. (물론 코드를 비효율적으로 짜서 배열 크기를 계속 늘렸다 줄였다 하는 게 아니라면, 아마 체감하기 힘들 정도로 미미한 차이이긴 해요.)std::vector 는 constexpr (컴파일할 때 값이 영원히 고정되는 완벽한 상수) 기능을 아주 제한적인 상황에서만 쓸 수 있어요.최신 C++(모던 C++)에서는 바로 이 두 번째 이유가 핵심이에요!
constexpr 배열을 사용하면 코드가 훨씬 더 단단해지고 오류가 줄어들며, 컴파일러가 코드를 알아서 엄청나게 최적화해 줄 수 있거든요.
따라서 constexpr 배열을 쓸 수 있는 상황이라면 무조건 써야 하고, 그럴 때 바로 우리가 써야 할 컨테이너가 std::array 인 것입니다!
권장 사항
값이 변하지 않는constexpr배열을 만들 때는std::array를 사용하고, 그 외의 일반적인 경우에는std::vector를 사용하세요.
std::array 만들기std::array 를 쓰려면 <array> 헤더 파일을 불러와야 해요. 애초에 std::vector 와 비슷하게 작동하도록 만들어졌기 때문에, 써보시면 둘이 서로 다른 점보다는 비슷한 점이 훨씬 많다는 걸 알게 되실 거예요.
하지만 처음 만들 때의 모습은 살짝 다릅니다:
#include <array> // std::array를 쓰기 위해 필요해요
#include <vector> // std::vector를 쓰기 위해 필요해요
int main()
{
std::array<int, 5> a {}; // 5개의 int 데이터를 담는 std::array
std::vector<int> b(5); // 5개의 int 데이터를 담는 std::vector (비교용이에요)
return 0;
}
우리가 만든 std::array 의 선언을 보면 꺾쇠 < > 안에 두 가지 정보(템플릿 인자)가 들어갑니다.
첫 번째인 int 는 이 배열 안에 어떤 종류의 데이터 를 넣을지 정해주는 것이고, 두 번째인 5 는 배열의 길이(크기) 를 딱 정해주는 숫자입니다.
std::array 의 길이는 반드시 상수 표현식 이어야 해요프로그램 실행 중에 마음대로 크기를 바꿀 수 있는 std::vector 와는 다르게, std::array 의 길이는 프로그램이 실행되기 전(컴파일 단계)에 이미 확실히 정해져 있는 상수 표현식 이어야 해요.
주로 그냥 숫자(정수 리터럴)를 바로 쓰거나, constexpr 변수, 또는 열거형(enum) 값을 사용해서 길이를 정해줍니다.
#include <array>
int main()
{
std::array<int, 7> a {}; // 평범한 숫자(리터럴)를 사용했어요
constexpr int len { 8 };
std::array<int, len> b {}; // constexpr 변수를 사용했어요
enum Colors
{
red,
green,
blue,
max_colors
};
std::array<int, max_colors> c {}; // 열거형(enum) 값을 사용했어요
#define DAYS_PER_WEEK 7
std::array<int, DAYS_PER_WEEK> d {}; // 매크로를 사용했어요 (이렇게 쓰지 말고 constexpr 변수를 쓰세요!)
return 0;
}
주의할 점은, 실행 중에 값이 바뀔 수 있는 일반 변수나 매개변수 등은 길이로 쓸 수 없다는 거예요!
#include <array>
#include <iostream>
void foo(const int length) // length는 프로그램 실행 중에 값이 결정되는 상수예요
{
std::array<int, length> e {}; // 에러: length는 컴파일러가 미리 알 수 있는 상수 표현식이 아니에요!
}
int main()
{
// const가 아닌 일반 변수를 사용하는 경우
int numStudents{};
std::cin >> numStudents; // 사용자에게 값을 입력받으므로 값이 바뀔 수 있죠
std::array<int, numStudents> {}; // 에러: numStudents는 상수 표현식이 아니에요!
foo(7);
return 0;
}
주의하세요!
의아하게 들릴 수 있지만, 길이가 0인std::array도 만들 수 있어요.
#include <array>
#include <iostream>
int main()
{
std::array<int, 0> arr {}; // 길이가 0인 std::array를 만들어요
std::cout << arr.empty(); // arr의 길이가 0이면 true가 나옵니다
return 0;
}
길이가 0인 std::array 는 데이터가 아예 없는 아주 특별한 케이스예요. 만약 빈 배열인데 실수로 안의 데이터에 접근하려고 시도하면(예: arr[0]) 프로그램이 꼬여버리는 치명적인 오류(미정의 동작)가 발생합니다. 배열이 텅 비었는지 확인하고 싶을 때는 empty() 라는 함수를 쓰면 안전하게 확인할 수 있어요.
std::array 값 채워넣기 (초기화)조금 특이하게도 std::array 에는 '생성자'라는 복잡한 기능이 없어요. 대신 데이터를 담는 순수한 바구니(집합체, Aggregate) 취급을 받기 때문에 집합체 초기화 라는 단순한 방식을 씁니다.
어렵게 생각할 것 없이, 중괄호 {} 안에 쉼표로 값을 쭉 나열해서 한 번에 초기화하는 방식이에요.
#include <array>
int main()
{
std::array<int, 6> fibonnaci = { 0, 1, 1, 2, 3, 5 }; // 중괄호를 사용한 복사 리스트 초기화 방식
std::array<int, 5> prime { 2, 3, 5, 7, 11 }; // 중괄호를 사용한 직접 리스트 초기화 방식 (이 방식을 추천해요!)
return 0;
}
이렇게 하면 배열의 0번째 칸부터 순서대로 우리가 적은 값들이 쏙쏙 들어갑니다.
만약 아무 값도 안 넣어주면 어떻게 될까요? 기본값으로 알아서 비워지는데, 문제는 컴퓨터 메모리 특성상 이전에 쓰던 '쓰레기값'이 남아있을 수 있다는 거예요.
그래서 안전하게 처음부터 모든 칸을 깔끔하게 '0'으로 비우고 싶다면, 텅 빈 중괄호 {} 를 써서 '값 초기화'를 해주는 것이 가장 좋습니다.
#include <array>
#include <vector>
int main()
{
std::array<int, 5> a; // 기본 초기화 (int 자리들에 쓰레기값이 남아있을 수 있어요)
std::array<int, 5> b{}; // 값 초기화 (모든 int 자리가 깔끔하게 0으로 채워져요) (이 방식을 추천해요!)
std::vector<int> v(5); // 값 초기화 (모든 int 자리가 깔끔하게 0으로 채워져요) (비교용이에요)
return 0;
}
배열 크기보다 숫자를 너무 많이 적어 넣으면 컴파일러가 에러를 냅니다. 반대로 크기보다 숫자를 적게 넣으면, 우리가 적어준 것만 채우고 남은 빈자리는 알아서 모두 0으로 깔끔하게 채워준답니다!
#include <array>
int main()
{
std::array<int, 4> a { 1, 2, 3, 4, 5 }; // 컴파일 에러: 값을 너무 많이 넣었어요!
std::array<int, 4> b { 1, 2 }; // 남은 자리인 b[2]와 b[3]은 알아서 0으로 채워져요
return 0;
}
std::arraystd::array 는 내용을 바꿀 수 없는 const 로 만들 수 있어요:
#include <array>
int main()
{
const std::array<int, 5> prime { 2, 3, 5, 7, 11 };
return 0;
}
안에 들어있는 데이터 하나하나에 일일이 const 를 붙여주지 않아도, 배열 자체가 통째로 const 이기 때문에 안의 내용물도 자동으로 절대 바꿀 수 없는 상태가 됩니다.
그리고 std::array 는 완벽한 상수인 constexpr 기능도 100% 완벽하게 지원합니다.
#include <array>
int main()
{
constexpr std::array<int, 5> prime { 2, 3, 5, 7, 11 };
return 0;
}
이 완벽한 constexpr 지원이 바로 우리가 std::array 를 사용해야 하는 가장 핵심적인 이유입니다.
권장 사항
가능하다면 여러분의std::array는 항상constexpr로 만드세요. 만약constexpr로 만들 수 없는 상황이라면, 차라리std::vector를 쓰는 게 나을지 고민해 보는 것이 좋습니다.
C++17 버전부터는 '클래스 템플릿 인자 추론(CTAD)' 이라는 똑똑한 기능이 생겼어요. 우리가 괄호 안에 적어준 값들을 보고, 컴파일러가 눈치껏
"아~ 데이터 타입은 이거고, 길이는 이만큼이구나!" 하고 알아서 맞춰주는 기능이에요.
#include <array>
#include <iostream>
int main()
{
constexpr std::array a1 { 9, 7, 5, 3, 1 }; // 값들을 보고 컴파일러가 알아서 std::array<int, 5> 라고 추론해요
constexpr std::array a2 { 9.7, 7.31 }; // 값들을 보고 컴파일러가 알아서 std::array<double, 2> 라고 추론해요
return 0;
}
이 방식이 코드를 훨씬 깔끔하게 만들어주기 때문에 사용할 수 있다면 아주 적극적으로 추천합니다. (단, 오래된 컴파일러를 쓴다면 데이터 타입과 길이를 예전처럼 직접 다 적어주셔야 해요.)
권장 사항
컴파일러가 알아서 타입과 길이를 눈치채도록 CTAD 기능을 적극 활용하세요.
하지만 주의할 점! C++23 기준으로, 타입과 길이 중 하나만 쏙 빼고 하나만 적어주는 건 불가능해요. 둘 다 생략해서 컴파일러에게 전적으로 맡기거나, 아니면 둘 다 꼼꼼히 적어줘야 합니다.
#include <iostream>
int main()
{
constexpr std::array<int> a2 { 9, 7, 5, 3, 1 }; // 에러: 정보가 부족해요 (길이를 안 적음)
constexpr std::array<5> a2 { 9, 7, 5, 3, 1 }; // 에러: 정보가 부족해요 (타입을 안 적음)
return 0;
}
std::to_array 를 써서 길이만 생략하기 - C++20 이상"타입은 내가 정하고 싶은데, 길이는 일일이 세기 귀찮아!" 할 때가 있죠? C++20부터는 std::to_array 라는 도우미 함수를 쓰면 길이만 생략하는 꼼수를 쓸 수 있어요.
#include <array>
#include <iostream>
int main()
{
constexpr auto myArray1 { std::to_array<int, 5>({ 9, 7, 5, 3, 1 }) }; // 타입과 크기를 모두 지정해요
constexpr auto myArray2 { std::to_array<int>({ 9, 7, 5, 3, 1 }) }; // 타입만 지정하고, 크기는 알아서 추론하게 해요
constexpr auto myArray3 { std::to_array({ 9, 7, 5, 3, 1 }) }; // 타입과 크기를 모두 알아서 추론하게 해요
return 0;
}
하지만 이 기능은 너무 남발하면 안 돼요. std::to_array 는 임시 배열을 하나 더 만들었다가 옮겨 담는 과정을 거치기 때문에, 그냥 std::array 를 만드는 것보다 컴퓨터가 일을 조금 더 무겁게 해야 하거든요.
반복문 안에서 계속 만들어내야 하거나 속도가 중요한 곳에서는 피하는 게 좋습니다.
꼭 필요한 상황에서만 가끔 쓰는 걸 추천해요.
예를 들면, C++에서는 숫자 뒤에 꼬리표를 붙여서 short 타입이라고 명시하는 방법이 없어요. 이럴 때 일일이 길이를 세서 적지 않고 short 배열을 만들고 싶다면 아주 유용하겠죠.
#include <array>
#include <iostream>
int main()
{
constexpr auto shortArray { std::to_array<short>({ 9, 7, 5, 3, 1 }) };
std::cout << sizeof(shortArray[0]) << '\n';
return 0;
}
operator[] 로 배열 안의 내용 꺼내 보기std::vector 와 마찬가지로, std::array 에서도 대괄호 [] 기호를 사용하는 게 안의 데이터를 꺼내보는 가장 흔하고 친숙한 방법이에요:
#include <array> // std::array를 쓰기 위해 필요해요
#include <iostream>
int main()
{
constexpr std::array<int, 5> prime{ 2, 3, 5, 7, 11 };
std::cout << prime[3]; // 인덱스 번호 3번 자리에 있는 값(7)을 화면에 출력해요
std::cout << prime[9]; // 잘못된 인덱스 번호예요! (미정의 동작 발생)
return 0;
}
여기서 꼭 기억해야 할 아주 중요한 점이 있어요! [] 기호는 우리가 배열 범위를 벗어났는지 절대 확인해주지 않는다는 거예요. 위 코드처럼 5칸짜리 배열인데 9번 자리를 달라고 요구하면, 프로그램이 죽거나 이상한 쓰레기값을 가져오는 끔찍한 일(미정의 동작)이 벌어집니다. 항상 내가 가진 배열 크기 안에서만 번호를 부르도록 조심해야 합니다!
우리는 앞서 std::vector를 배우며 표준 라이브러리 컨테이너들이 길이나 위치를 나타낼 때 '부호 없는 정수(unsigned)'를 사용한다는 사실을 알게 되었습니다. 안타깝게도 std::array 역시 같은 표준 컨테이너 가족이기 때문에, 부호 있는 정수(int)와 함께 사용할 때 발생하는 데이터 타입 불일치 문제를 그대로 안고 있습니다.
이번 레슨에서는 std::array의 길이를 확인하고 특정 위치의 값을 찾는(인덱싱) 방법들을 정리해 보려 합니다. 전체적인 사용법은 std::vector와 매우 비슷해서 익숙한 느낌이 드실 텐데, 여기서 주목해야 할 std::array만의 독보적인 장점은 바로 constexpr을 완벽하게 지원한다는 점입니다.
std::array는 프로그램이 실행되기 전인 컴파일 단계에서 이미 모든 크기와 값이 고정되는 '컴파일 타임 상수'로 다룰 수 있습니다. 여기서 우리가 기억해야 할 중요한 규칙이 하나 등장합니다. 원래 부호가 있는 값을 부호 없는 값으로 변환할 때는 데이터가 잘려 나갈 위험 때문에 컴파일러가 엄격하게 굴지만, 그 값이 constexpr일 때는 예외적으로 너그러워집니다.
즉, 컴파일러가 미리 계산해 보고 "이 값은 데이터 손실 위험이 없는 안전한 양수다"라고 확신할 수 있다면, 우리가 복잡한 형변환을 거치지 않아도 훨씬 유연하게 코드를 작성할 수 있게 해줍니다. 이 특성을 잘 활용하면 std::array를 다룰 때 발생하는 번거로운 인덱스 문제들을 훨씬 스마트하게 해결할 수 있습니다.
std::array 는 템플릿 구조체라는 형태로 만들어져 있고, 대략 이렇게 생겼습니다:
template<typename T, std::size_t N> // N은 타입이 아닌(non-type) 템플릿 매개변수입니다
struct array;
보시다시피, 배열의 길이(N)를 나타내는 매개변수는 std::size_t 타입을 사용해요.
이제는 잘 아시겠지만, std::size_t 는 아주 커다란 '부호 없는 정수(unsigned)' 타입이랍니다.
따라서 우리가 std::array 를 만들 때, 길이를 나타내는 값은 반드시 std::size_t 타입이거나 std::size_t 로 변신할 수 있는 값이어야 해요. 다행히 이 길이는 무조건 constexpr (컴파일 시점에 미리 확정되는 상수)이어야만 합니다. 그래서 우리가 평범한 정수(음수/양수 모두 되는 signed)를 적어 넣더라도, 컴파일러가 알아서 아무런 에러 없이 컴파일 시점에 std::size_t 로 안전하게 둔갑시켜 줍니다. 데이터 손실 경고 같은 건 걱정 안 하셔도 돼요!
참고로 알아두면 좋은 팁...
C++23 이전에는 C++에std::size_t를 콕 집어 표현하는 숫자 꼬리표(리터럴 접미사)가 아예 없었어요. 보통은 일반 정수(int)를 써도 컴파일러가 알아서std::size_t로 잘 바꿔주기 때문에 굳이 필요가 없었거든요.
이 꼬리표는 주로 타입을 자동으로 추론할 때 쓰라고 추가된 거예요. 예를 들어constexpr auto x { 0 }이라고 쓰면 컴퓨터는 이걸std::size_t가 아니라 평범한int로 생각해 버려요. 이럴 때 굳이 길고 복잡하게static_cast를 쓰지 않고도, 그냥0(int) 과0UZ(std::size_t) 를 예쁘게 구분해서 쓸 수 있게 해주는 아주 유용한 기능이랍니다.
std::vector 와 마찬가지로, std::array 안에는 size_type 이라는 별명이 숨어 있어요. 이 별명은 배열의 길이나 위치(인덱스)를 나타낼 때 쓰이는 타입을 부르는 말이에요. std::array 의 경우, 이 size_type 은 언제나 std::size_t 를 가리키는 별명입니다.
눈여겨볼 점은, std::array 의 길이를 정의하는 템플릿 부분에는 size_type 이 아니라 std::size_t 라고 대놓고 적혀 있다는 거예요. 왜냐하면 size_type 은 std::array 가 만들어지고 나서야 그 안에 생기는 별명인데, 템플릿을 정의하는 저 시점에서는 아직 그 별명이 존재하지 않거든요. 딱 저곳에서만 std::size_t 를 직접 쓰고, 나머지 모든 곳에서는 size_type 을 사용한답니다.
std::array 의 길이를 알아내는 데는 보통 3가지 흔한 방법이 쓰여요.
첫 번째, std::array 객체에게 직접 size() 함수를 써서 길이를 물어보는 방법이에요.
(부호 없는 size_type 으로 길이를 알려줍니다.)
#include <array>
#include <iostream>
int main()
{
constexpr std::array arr { 9.0, 7.2, 5.4, 3.6, 1.8 };
std::cout << "length: " << arr.size() << '\n'; // 길이를 size_type 타입 (std::size_t의 별명) 으로 반환합니다
return 0;
}
출력 결과는 다음과 같아요:
length: 5
참고로, 글자 길이를 잴 때 length() 와 size() 라는 똑같은 기능의 함수를 두 개나 가지고 있는 문자열(std::string) 친구들과는 달리, std::array
(그리고 대부분의 다른 C++ 컨테이너들)는 오직 size() 하나만 가지고 있답니다.
두 번째, C++17부터는 컨테이너 밖에서 쓰는 std::size() 라는 일반 함수를 쓸 수 있어요. (이것도 결국 내부적으로는 첫 번째 방법을 호출해서 부호 없는 size_type 으로 돌려줍니다.)
#include <array>
#include <iostream>
int main()
{
constexpr std::array arr{ 9, 7, 5, 3, 1 };
std::cout << "length: " << std::size(arr); // C++17 지원, 길이를 size_type 타입 (std::size_t의 별명) 으로 반환합니다
return 0;
}
마지막으로 세 번째! C++20부터는 std::ssize() 라는 일반 함수를 쓸 수 있게 되었어요. 이 녀석은 길이를 '부호가 있는(signed) 아주 큰 정수 타입'(보통 std::ptrdiff_t)으로 알려주는 기특한 함수입니다.
#include <array>
#include <iostream>
int main()
{
constexpr std::array arr { 9, 7, 5, 3, 1 };
std::cout << "length: " << std::ssize(arr); // C++20 지원, 길이를 부호가 있는(signed) 큰 정수 타입으로 반환합니다
return 0;
}
세 가지 방법 중에서 길이를 '부호가 있는 정수(음수 표현 가능)'로 돌려주는 함수는 이 녀석이 유일해요!
std::array 의 길이는 프로그램이 실행되기 전에 이미 고정되는 constexpr 이기 때문에, 방금 배운 세 가지 함수를 쓰면 언제나 길이를 constexpr 값으로 얻어낼 수 있어요!
(심지어 배열 자체를 constexpr 로 만들지 않았어도 가능하답니다!)
이 말은 즉, 이 함수들을 고정된 값이 필요한 곳(상수 표현식)에 마음껏 써도 된다는 뜻이고, 여기서 얻은 길이를 평범한 int 에 집어넣어도 데이터 손실 경고가 뜨지 않는다는 놀라운 장점이 있어요.
#include <array>
#include <iostream>
int main()
{
std::array arr { 9, 7, 5, 3, 1 }; // 참고: 이 예제에서는 constexpr이 아닙니다
constexpr int length{ std::size(arr) }; // 성공: 반환값이 constexpr std::size_t 이며, int로 변환되어도 데이터 손실(narrowing conversion)이 아닙니다
std::cout << "length: " << length << '\n';
return 0;
}
Visual Studio 사용자 분들을 위한 안내
Visual Studio에서는 위 코드를 작성하면 C4365라는 경고를 잘못 띄우는 버그가 있어요. 이 문제는 이미 마이크로소프트에 보고되어 있으니 무시하셔도 괜찮습니다.
주의하세요! (경고)
C++ 언어 자체의 작은 결함 때문에,std::array를 함수의 매개변수로 넘길 때 참조(const reference) 방식을 사용하면 안타깝게도 위 함수들이 constexpr 값을 돌려주지 못하고 에러를 냅니다.void printLength(const std::array<int, 5> &arr) { constexpr int length{ std::size(arr) }; // 컴파일 에러! std::cout << "length: " << length << '\n'; } int main() { std::array arr { 9, 7, 5, 3, 1 }; constexpr int length{ std::size(arr) }; // 아주 잘 작동합니다 std::cout << "length: " << length << '\n'; printLength(arr); return 0; }이 성가신 결함은 C++23에서 고쳐졌지만, 아직 이 최신 기능을 완벽하게 지원하는 컴파일러가 많지 않아요.
지금 당장 쓸 수 있는 해결책은 함수 자체를 '템플릿'으로 만들어서, 배열의 길이를 매개변수로 직접 넘겨받는 거랍니다. (이 방법은 다음 17.3 레슨에서 자세히 다룰 테니 가벼운 마음으로 넘어가세요!)
template `<auto lenth>` void printLength(const std::array<int, Length> &arr) { std::cout << "length: " << Length << '\n'; }
이전 17.1 레슨에서, std::array 안의 값을 꺼낼 때 가장 많이 쓰는 방법은 대괄호 [] (첨자 연산자)를 쓰는 거라고 배웠죠. 이 방법은 엄청나게 빠르지만, 우리가 배열 크기를 벗어난 엉뚱한 번호를 넣었을 때 컴퓨터가 막아주지 않아요(범위 검사를 안 함). 만약 없는 번호를 부르면 프로그램이 미쳐 날뛰게 될 겁니다(Undefined behavior).
그래서 std::vector 처럼 std::array 에도 at() 이라는 안전장치 멤버 함수가 있어요.
이 함수는 프로그램이 실행되는 도중에 우리가 제대로 된 번호를 불렀는지 안전하게 검사해 줍니다. 하지만 전문가들은 이 함수 사용을 별로 추천하지 않아요. 보통은 값을 꺼내기 '전에' 미리 번호가 맞는지 우리가 직접 확인하거나, 아예 프로그램이 실행되기도 전인 컴파일 시점에 검사하는 것을 훨씬 선호하거든요.
참고로 [] 나 at() 모두 우리가 부르는 번호(인덱스)가 부호 없는 size_type (std::size_t) 타입일 거라고 기대하고 있어요.
만약 우리가 넣는 번호가 constexpr 값이라면, 컴파일러가 알아서 에러 없이 예쁘게 std::size_t 로 변환해 줍니다. 데이터 손실 같은 건 없으니 부호 문제로 머리 아플 일은 없어요.
하지만, 만약 우리가 넣는 번호가 constexpr 이 아닌 일반적인 정수라면 이야기가 달라집니다. 이때는 std::size_t 로 변환될 때 데이터 손실 위험이 있다고 판단해서 컴파일러가 짜증 섞인 경고를 뱉어낼 수도 있어요. (이 상황에 대해서는 16.3 레슨에서 std::vector 를 예로 들어 아주 자세히 다뤘었죠!)
std::array 의 길이는 프로그램 실행 전(컴파일 시점)에 이미 정해진 constexpr 이라고 거듭 강조했죠? 그렇다면, 우리가 꺼내려는 번호(인덱스)도 마침 constexpr 이라면, 컴파일러가 코드를 번역할 때 "어라? 이 번호가 배열 크기 안에 잘 들어맞나?" 하고 미리 검사해 줄 수 있을 거예요! (만약 범위를 벗어난 번호를 불렀다면, 아예 컴파일을 멈춰서 에러를 내주면 가장 완벽하겠죠.)
하지만 앞서 말했듯 [] 는 애초에 그런 검사를 안 하고, at() 은 프로그램이 실행될 때 야 비로소 검사를 합니다. 함수의 매개변수들은 애초에 constexpr 로 넘길 수도 없고요. 그럼 도대체 컴파일 시점에 미리 번호 검사를 받으려면 어떻게 해야 할까요?
정답은 바로 std::get() 이라는 특별한 함수 템플릿을 쓰는 겁니다!
이 녀석은 꺼내려는 번호를 꺾쇠괄호(< >) 안에 템플릿 인자로 넣어서 사용해요:
#include <array>
#include <iostream>
int main()
{
constexpr std::array prime{ 2, 3, 5, 7, 11 };
std::cout << std::get<3>(prime); // 인덱스 3에 있는 요소의 값을 출력합니다
std::cout << std::get<9>(prime); // 유효하지 않은 인덱스 (컴파일 에러)
return 0;
}
std::get() 의 코드를 살짝 들여다보면, 내부적으로 static_assert 라는 아주 엄격한 검사관이 들어있어요. 이 검사관은 우리가 꺾쇠괄호 안에 적은 번호가 배열의 전체 길이보다 작은지 확인하고, 만약 벗어났다면 가차 없이 컴파일을 중단시키고 에러를 뿜어냅니다.
꺾쇠괄호 안에 들어가는 템플릿 인자는 무조건 constexpr 이어야만 하기 때문에, 이 std::get() 은 우리가 부르려는 번호가 상수(constexpr)일 때만 사용할 수 있다는 점을 꼭 기억해 두세요!
std::array 전달하고 반환하기std::array 타입의 객체도 일반적인 다른 데이터들처럼 함수에 쏙 집어넣을 수 있습니다.
만약 데이터를 통째로 복사해서 넘겨주는 '값에 의한 전달(pass by value)' 방식을 쓰면, 컴퓨터가 배열 전체를 복사하느라 힘을 빼게 됩니다(성능이 떨어집니다). 그래서 복사하는 과정을 피하기 위해 보통 상수 참조(const reference) 방식을 사용해서 원본을 가리키기만 하는 형태로 함수에 전달합니다.
std::array 를 사용할 때는 배열의 길이 와 안에 들어가는 데이터의 타입 이 모두 배열의 '신분증' 같은 역할을 합니다. 따라서 함수에서 이 배열을 넘겨받을 때는, 데이터 타입과 길이를 꼭 명확하게 적어주어야 합니다.
#include <array>
#include <iostream>
void passByRef(const std::array<int, 5>& arr) // 여기에 <int, 5>를 명확하게 적어주어야 합니다
{
std::cout << arr[0] << '\n';
}
int main()
{
std::array arr{ 9, 7, 5, 3, 1 }; // CTAD(클래스 템플릿 인수 추론)가 알아서 std::array<int, 5> 타입을 알아냅니다
passByRef(arr);
return 0;
}
안타깝게도 CTAD(컴파일러가 코드를 보고 알아서 타입을 눈치껏 맞춰주는 편리한 기능)는 함수의 매개변수에서는 아직 작동하지 않습니다. 그래서 함수 괄호 안에 그냥 std::array 라고만 적어두고 컴파일러에게 알아서 맞춰달라고 할 수는 없습니다.
std::array 전달하기만약 들어가는 데이터가 int 든 double 이든 상관없이, 그리고 길이가 5든 100이든 상관없이 모두 받아줄 수 있는 '만능 함수'를 만들고 싶다면 어떻게 해야 할까요?
바로 함수 템플릿 을 만들면 됩니다. 데이터 타입과 길이를 들어갈 수 있게 빈칸(매개변수)으로 뚫어놓으면, C++ 컴파일러가 알아서 실제 쓰일 때 그 빈칸을 채워 진짜 함수들을 만들어냅니다.
관련 내용
함수 템플릿에 대한 자세한 내용은 '11.6 -- 함수 템플릿' 강의에서 다룹니다.
std::array 는 원래 이렇게 생겼습니다:
template<typename T, std::size_t N> // N은 타입이 아닌 템플릿 매개변수(숫자 값 등)입니다
struct array;
우리는 이 모양을 그대로 흉내 내서 함수 템플릿을 만들 수 있습니다:
#include <array>
#include <iostream>
template <typename T, std::size_t N> // 참고: 이 템플릿 매개변수 선언은 std::array의 선언과 모양이 똑같습니다
void passByRef(const std::array<T, N>& arr)
{
static_assert(N != 0); // 길이가 0인 std::array가 들어오면 실패(오류) 처리합니다
std::cout << arr[0] << '\n';
}
int main()
{
std::array arr{ 9, 7, 5, 3, 1 }; // CTAD를 이용해 std::array<int, 5>로 유추합니다
passByRef(arr); // 성공: 컴파일러가 passByRef(const std::array<int, 5>& arr) 함수를 만들어냅니다
std::array arr2{ 1, 2, 3, 4, 5, 6 }; // CTAD를 이용해 std::array<int, 6>으로 유추합니다
passByRef(arr2); // 성공: 컴파일러가 passByRef(const std::array<int, 6>& arr) 함수를 만들어냅니다
std::array arr3{ 1.2, 3.4, 5.6, 7.8, 9.9 }; // CTAD를 이용해 std::array<double, 5>로 유추합니다
passByRef(arr3); // 성공: 컴파일러가 passByRef(const std::array<double, 5>& arr) 함수를 만들어냅니다
return 0;
}
위 예제에서 우리는 passByRef() 라는 함수 템플릿을 딱 하나만 만들었습니다.
여기서 T 와 N 은 첫째 줄의 template <typename T, std::size_t N> 에서 정의되었습니다. T 는 데이터의 타입을 결정할 수 있게 해주고, N 은 배열의 길이를 결정할 수 있게 해주는 특별한 매개변수입니다.
주의!
std::array의 길이를 나타내는 템플릿 매개변수(N)의 타입은int가 아니라 반드시std::size_t여야 합니다! 템플릿은 자동으로 타입을 변환해주지 않기 때문에int를 쓰면 짝이 맞지 않아 컴파일러가 에러를 뿜어냅니다.
그래서 우리가 main() 에서 passByRef(arr) 를 부르면, 컴파일러가 알아서 void passByRef(const std::array<int, 5>& arr) 라는 맞춤형 함수를 찍어내서 실행합니다. arr2 와 arr3 에 대해서도 똑같이 알아서 만들어줍니다.
결론적으로, 단 하나의 만능 함수 템플릿으로 어떤 타입, 어떤 길이의 std::array 도 모두 처리할 수 있게 된 것입니다!
원한다면 둘 중 하나만 빈칸(템플릿)으로 뚫어놓을 수도 있습니다. 아래 예제에서는 배열의 길이는 아무거나 들어올 수 있게 템플릿으로 만들었지만, 데이터 타입은 무조건 int 만 받도록 고정했습니다.
#include <array>
#include <iostream>
template <std::size_t N> // 참고: 여기서는 길이만 템플릿으로 만들었습니다
void passByRef(const std::array<int, N>& arr) // 데이터 타입을 int로 고정해두었습니다
{
static_assert(N != 0); // 길이가 0인 std::array가 들어오면 실패(오류) 처리합니다
std::cout << arr[0] << '\n';
}
int main()
{
std::array arr{ 9, 7, 5, 3, 1 }; // CTAD를 이용해 std::array<int, 5>로 유추합니다
passByRef(arr); // 성공: 컴파일러가 passByRef(const std::array<int, 5>& arr) 함수를 만들어냅니다
std::array arr2{ 1, 2, 3, 4, 5, 6 }; // CTAD를 이용해 std::array<int, 6>으로 유추합니다
passByRef(arr2); // 성공: 컴파일러가 passByRef(const std::array<int, 6>& arr) 함수를 만들어냅니다
std::array arr3{ 1.2, 3.4, 5.6, 7.8, 9.9 }; // CTAD를 이용해 std::array<double, 5>로 유추합니다
passByRef(arr3); // 오류: 컴파일러가 일치하는 함수를 찾을 수 없습니다 (double 타입이라서 튕겨냅니다)
return 0;
}
auto 를 이용한 비타입 템플릿 매개변수템플릿을 만들 때마다 std::size_t 처럼 길이를 나타내는 정확한 타입을 외워서 적는 건 꽤나 귀찮은 일입니다.
C++20부터는 auto 키워드를 쓰면, 컴파일러가 알아서 눈치껏 타입을 맞춰주기 때문에 훨씬 편해졌습니다.
#include <array>
#include <iostream>
template <typename T, auto N> // 이제 auto를 사용해 N의 타입을 알아서 유추합니다
void passByRef(const std::array<T, N>& arr)
{
static_assert(N != 0); // 길이가 0인 std::array가 들어오면 실패(오류) 처리합니다
std::cout << arr[0] << '\n';
}
int main()
{
std::array arr{ 9, 7, 5, 3, 1 }; // CTAD를 이용해 std::array<int, 5>로 유추합니다
passByRef(arr); // 성공: 컴파일러가 passByRef(const std::array<int, 5>& arr) 함수를 만들어냅니다
std::array arr2{ 1, 2, 3, 4, 5, 6 }; // CTAD를 이용해 std::array<int, 6>으로 유추합니다
passByRef(arr2); // 성공: 컴파일러가 passByRef(const std::array<int, 6>& arr) 함수를 만들어냅니다
std::array arr3{ 1.2, 3.4, 5.6, 7.8, 9.9 }; // CTAD를 이용해 std::array<double, 5>로 유추합니다
passByRef(arr3); // 성공: 컴파일러가 passByRef(const std::array<double, 5>& arr) 함수를 만들어냅니다
return 0;
}
여러분의 컴파일러가 C++20을 지원한다면, 아주 유용하게 쓸 수 있는 방법입니다.
위에서 본 것과 비슷한 아래의 함수를 한번 살펴볼까요?
#include <array>
#include <iostream>
template <typename T, std::size_t N>
void printElement3(const std::array<T, N>& arr)
{
std::cout << arr[3] << '\n';
}
int main()
{
std::array arr{ 9, 7, 5, 3, 1 };
printElement3(arr);
return 0;
}
이 코드는 지금 당장은 아무 문제 없이 잘 돌아가지만, 초보 프로그래머가 실수하기 딱 좋은 엄청난 버그 폭탄을 숨기고 있습니다. 찾으셨나요?
위 프로그램은 배열의 3번 인덱스(네 번째 값)를 화면에 보여주는 역할을 합니다. 배열 안에 값이 4개 이상 들어있다면 문제가 없겠죠. 하지만 배열 길이가 2밖에 안 되는데 3번 인덱스를 찾으려고 하면, 컴파일러는 아무 경고 없이 그냥 넘어가 버립니다.
#include <array>
#include <iostream>
template <typename T, std::size_t N>
void printElement3(const std::array<T, N>& arr)
{
std::cout << arr[3] << '\n'; // 유효하지 않은 인덱스입니다 (에러 발생 위치)
}
int main()
{
std::array arr{ 9, 7 }; // 2개의 요소만 있는 배열입니다 (유효한 인덱스는 0과 1뿐입니다)
printElement3(arr);
return 0;
}
이러면 프로그램이 알 수 없는 이상한 행동(정의되지 않은 동작)을 하게 됩니다. 이런 실수를 했을 때 컴파일러가 "너 잘못 짰어!" 하고 미리 알려주면 얼마나 좋을까요?
일반 함수의 매개변수와 달리, 템플릿 매개변수는 컴파일 타임 상수 (프로그램을 실행하기도 전에 이미 그 값이 확정됨)라는 엄청난 장점이 있습니다. 이 장점을 활용하면 문제를 미리 예방할 수 있습니다.
첫 번째 해결책은 operator[] (대괄호) 대신 std::get() 을 쓰는 것입니다. 대괄호는 길이를 넘었는지 검사하지 않지만, std::get() 은 프로그램을 실행하기도 전(컴파일할 때)에 길이를 검사해 줍니다.
#include <array>
#include <iostream>
template <typename T, std::size_t N>
void printElement3(const std::array<T, N>& arr)
{
std::cout << std::get<3>(arr) << '\n'; // 컴파일 시점에 인덱스 3이 유효한지 안전하게 검사합니다
}
int main()
{
std::array arr{ 9, 7, 5, 3, 1 };
printElement3(arr); // 성공
std::array arr2{ 9, 7 };
printElement3(arr2); // 컴파일 오류가 발생하여 실수를 미리 막아줍니다
return 0;
}
컴파일러가 printElement3(arr2) 코드를 확인하면 길이가 2인 배열 전용 함수를 만드는데, 그 안에서 std::get<3>(arr) 을 발견하고는 "어? 길이 2짜리 배열에서 3번 인덱스를 달라고? 안 돼!"라며 에러를 띄워줍니다.
두 번째 해결책은 우리가 직접 static_assert 라는 기능을 이용해 "배열 길이는 무조건 3보다 커야 해!"라고 단단히 못을 박아두는 것입니다.
#include <array>
#include <iostream>
template <typename T, std::size_t N>
void printElement3(const std::array<T, N>& arr)
{
// 전제 조건: 3번 요소가 존재하려면 배열 길이가 3보다 커야 합니다
static_assert (N > 3);
// 이 지점부터는 배열 길이가 무조건 3보다 크다고 확신하고 안심할 수 있습니다
std::cout << arr[3] << '\n';
}
int main()
{
std::array arr{ 9, 7, 5, 3, 1 };
printElement3(arr); // 성공
std::array arr2{ 9, 7 };
printElement3(arr2); // 컴파일 오류가 발생합니다
return 0;
}
마찬가지로 배열 길이가 2인 arr2 가 들어오면, 컴퓨터가 static_assert (2 > 3) 라는 수학적으로 말이 안 되는 수식을 보고 에러를 뿜어내어 우리의 실수를 막아줍니다.
std::array 반환하기 (함수에서 밖으로 내보내기)std::array 를 함수에 집어넣는 건 참조 방식을 쓰면 되니까 아주 쉬웠습니다.
그런데 함수에서 결과물로 std::array 를 밖으로 뱉어낼(반환할) 때는 조금 복잡해집니다. std::vector 와 달리 std::array 는 가볍게 '이동(move)'시키는 게 불가능해서, 배열 전체를 하나하나 복사해서 반환해야 하거든요.
상황에 따라 주로 두 가지 방법을 씁니다.
1. 값으로 반환하기 (Return by value)
다음 조건이 모두 맞다면 통째로 복사해서 반환해도 괜찮습니다.
복사를 하느라 컴퓨터가 살짝 힘을 쓰긴 하겠지만, 코드가 가장 자연스럽고 깔끔해집니다.
#include <array>
#include <iostream>
#include <limits>
// 값으로 반환하는 함수 템플릿
template <typename T, std::size_t N>
std::array<T, N> inputArray() // 값으로 반환하기
{
std::array<T, N> arr{};
std::size_t index { 0 };
while (index < N)
{
std::cout << "Enter value #" << index << ": ";
std::cin >> arr[index];
if (!std::cin) // 잘못된 입력 처리하기
{
std::cin.clear();
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
continue;
}
++index;
}
return arr;
}
int main()
{
std::array<int, 5> arr { inputArray<int, 5>() };
std::cout << "The value of element 2 is " << arr[2] << '\n';
return 0;
}
<int, 5> 라고 직접 다 적어줘야 합니다.2. 출력 매개변수(Out parameter)로 반환하기
복사하는 게 너무 무겁고 아깝다면 이 방법을 씁니다. 함수를 부르는 쪽에서 빈 배열을 미리 준비해서 함수에 던져주면(참조 방식), 함수가 그 빈 배열의 속을 꽉꽉 채워주는 방식입니다.
#include <array>
#include <limits>
#include <iostream>
template <typename T, std::size_t N>
void inputArray(std::array<T, N>& arr) // 상수가 아닌 참조로 전달하기 (원본을 수정할 수 있도록 넘김)
{
std::size_t index { 0 };
while (index < N)
{
std::cout << "Enter value #" << index << ": ";
std::cin >> arr[index];
if (!std::cin) // 잘못된 입력 처리하기
{
std::cin.clear();
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
continue;
}
++index;
}
}
int main()
{
std::array<int, 5> arr {};
inputArray(arr); // 함수가 arr의 속을 채워줍니다
std::cout << "The value of element 2 is " << arr[2] << '\n';
return 0;
}
std::vector 쓰면 안 되나요?사실 가장 좋은 꿀팁입니다. std::vector 는 '이동(move)'이라는 기술이 가능해서 통째로 반환해도 복사본을 만들지 않고 쌩하고 날아갑니다. 만약 std::array 를 통째로 반환해야 하는 상황이 생긴다면, 굳이 무거운 복사를 참아가며 std::array 를 고집하기보다는 유연하고 빠른 std::vector 를 대신 사용하는 것을 강력히 추천합니다!
std::array 에는 숫자(int)나 문자(char) 같은 단순하고 기본적인 타입만 넣을 수 있는 게 아닙니다. 구조체(struct)나 클래스(class)처럼 우리가 직접 만든 복잡한 덩어리들도 얼마든지 담을 수 있어요! 즉, 포인터들을 모아둔 std::array 나, 구조체들을 모아둔 std::array 를 만들 수 있다는 뜻입니다.
하지만 초보자분들이 구조체나 클래스를 담은 std::array 를 처음 만들 때(초기화할 때) 에러가 나면서 많이들 당황하시곤 합니다. 그래서 이번 레슨에서는 이 부분을 아주 속 시원하고 알기 쉽게 풀어보겠습니다.
작성자의 참고 노트
이번 레슨에서는 이해를 돕기 위해 '구조체(struct)'를 예시로 사용할 거예요. 하지만 여기서 배우는 모든 원리는 '클래스(class)'에도 똑같이 적용되니 걱정하지 마세요!
먼저 아주 간단한 House (집) 구조체를 만들어 볼까요?
struct House{
int number{};
int stories{};
int roomsPerStory{};
};
이 House 구조체들을 담는 std::array 를 만들고, 나중에 값을 하나씩 집어넣는 건 우리가 흔히 아는 방식 그대로 잘 작동합니다.
#include <array>
#include <iostream>
struct House{
int number{};
int stories{};
int roomsPerStory{};
};
int main(){
std::array<House, 3> houses{};
houses[0] = { 13, 1, 7 };
houses[1] = { 14, 2, 5 };
houses[2] = { 15, 2, 4 };
for (const auto& house : houses)
{
std::cout << "House number " << house.number
<< " has " << (house.stories * house.roomsPerStory)
<< " rooms.\n";
}
return 0;
}
위 코드를 실행하면 예상대로 다음처럼 잘 출력됩니다.
House number 13 has 7 rooms.
House number 14 has 10 rooms.
House number 15 has 8 rooms.
배열을 만들 때 값을 한 번에 쏙쏙 집어넣는 것도 잘 작동합니다.
단, 어떤 타입 인지 명확하게 적어준다면요!
#include <array>
#include <iostream>
struct House{
int number{};
int stories{};
int roomsPerStory{};
};
int main(){
constexpr std::array houses { // CTAD(템플릿 인자 추론)를 사용해 <House, 3> 이라는 걸 컴파일러가 눈치채게 합니다
House{ 13, 1, 7 },
House{ 14, 2, 5 },
House{ 15, 2, 4 }
};
for (const auto& house : houses)
{
std::cout << "House number " << house.number
<< " has " << (house.stories * house.roomsPerStory)
<< " rooms.\n";
}
return 0;
}
위 코드에서는 CTAD(컴파일러가 타입을 스스로 유추하는 똑똑한 기능)를 써서 이 배열이 std::array<House, 3> 이라는 걸 알아내게 했습니다. 그리고 House{ ... } 처럼 각 줄마다 House 라고 친절하게 적어줬기 때문에 아무 문제 없이 작동합니다.
위 코드에서 값을 넣을 때마다 House 라고 일일이 적어주는 게 조금 번거롭게 느껴지지 않나요?
constexpr std::array houses {
House{ 13, 1, 7 }, // 여기에 House라고 썼고
House{ 14, 2, 5 }, // 여기도 썼고
House{ 15, 2, 4 } // 여기도 썼네요
};
생각해 보면 아까 배열을 만들고 나중에 값을 대입할 때는 굳이 House 라고 적지 않아도 알아서 잘 들어갔거든요.
// 컴파일러는 houses 배열의 각 칸이 House라는 걸 이미 알고 있습니다.
// 그래서 오른쪽의 숫자를 알아서 House 형태로 바꿔서 넣어줍니다.
houses[0] = { 13, 1, 7 };
houses[1] = { 14, 2, 5 };
houses[2] = { 15, 2, 4 };
그래서 아마 여러분은 "처음 배열을 만들 때도 House 라는 글자를 빼고 숫자만 적어도 알아서 들어가지 않을까?" 하고 이렇게 시도해 볼 수 있습니다.
// 작동하지 않습니다!
constexpr std::array<House, 3> houses { // 컴파일러에게 각 칸이 House라고 말해줬지만...
{ 13, 1, 7 }, // 정작 값 앞에는 House를 빼버렸습니다.
{ 14, 2, 5 },
{ 15, 2, 4 }
};
놀랍게도 이 코드는 에러가 납니다. 왜 안 되는지 그 속사정을 아주 쉽게 파헤쳐 볼까요?
사실 std::array 는 진짜 순수한 배열이 아닙니다. 진짜 C스타일 배열 하나를 몰래 품고 있는 '포장지(구조체)' 에 불과합니다. 속살은 이렇게 생겼어요.
template<typename T, std::size_t N>
struct array{
T implementation_defined_name[N]; // 타입 T를 N개 담을 수 있는 '숨겨진 C스타일 배열'입니다
};
그래서 우리가 아까처럼 코드를 쓰면, 바보 같은 컴파일러는 우리의 의도와 다르게 완전히 오해를 해버립니다.
// 작동하지 않습니다!
constexpr std::array<House, 3> houses { // 1. 자, houses 포장지(구조체) 초기화 시작!
{ 13, 1, 7 }, // 2. 어? 안에 있는 '숨겨진 C스타일 배열'의 첫 번째 칸(0번 칸)에 이걸 넣으라는 거구나!
{ 14, 2, 5 }, // 3. 엥? 자리가 없는데? 이건 뭐야? (?)
{ 15, 2, 4 } // 4. 이것도 뭐야? 에러!! (?)
};
즉, 컴파일러는 첫 번째 줄 { 13, 1, 7 } 전체를 "숨겨진 내부 배열 3칸 중 첫 번째 칸에 몽땅 넣어라" 는 뜻으로 착각해 버립니다. 그리고 나머지 두 줄을 보고는 "더 이상 넣을 데가 없는데 값이 너무 많아!" 라며 에러를 뱉어내는 거죠.
이 오해를 풀고 코드를 정상적으로 작동하게 만드는 올바른 방법은, 중괄호를 한 겹 더 씌워주는 것 입니다.
// 예상대로 아주 잘 작동합니다!
constexpr std::array<House, 3> houses { // 1. houses 포장지(구조체) 초기화 시작!
{ // 2. 여기서부터가 '숨겨진 C스타일 배열'에 들어갈 진짜 값들이야! 라고 알려주는 추가 중괄호
{ 13, 4, 30 }, // 배열의 0번 칸에 들어갈 값
{ 14, 3, 10 }, // 배열의 1번 칸에 들어갈 값
{ 15, 3, 40 }, // 배열의 2번 칸에 들어갈 값
}
};
바깥쪽 중괄호 {} 는 std::array 라는 포장지를 위한 것이고, 안쪽 중괄호 {} 가 바로 그 안에 숨어있는 진짜 배열을 위한 것입니다. 이렇게 해주면 각각의 방에 값들이 예쁘게 쏙쏙 들어갑니다.
핵심 포인트
구조체, 클래스, 배열 등을 담은std::array를 만들 때, 값 앞에 일일이House{...}처럼 이름을 적어주기 귀찮다면 반드시 바깥에 중괄호를 한 번 더{{ }}씌워주세요! 그래야 컴파일러가 헷갈리지 않습니다.
완성된 코드는 이렇습니다:
#include <array>
#include <iostream>
struct House{
int number{};
int stories{};
int roomsPerStory{};
};
int main(){
constexpr std::array<House, 3> houses {{ // 이중 중괄호 {{ }} 에 주목하세요!
{ 13, 1, 7 },
{ 14, 2, 5 },
{ 15, 2, 4 }
}};
for (const auto& house : houses)
{
std::cout << "House number " << house.number
<< " has " << (house.stories * house.roomsPerStory)
<< " rooms.\n";
}
return 0;
}
"어라? 숫자만 넣을 때는 중괄호 한 번만 써도 잘 됐는데요?" 라고 생각하셨다면 아주 예리하십니다.
#include <array>
#include <iostream>
int main(){
constexpr std::array<int, 5> arr { 1, 2, 3, 4, 5 }; // 단일 중괄호 (어? 왜 이건 되죠?)
for (const auto n : arr)
std::cout << n << '\n';
return 0;
}
물론 여기도 원래는 이중 중괄호 {{ 1, 2, 3, 4, 5 }} 를 쓰는 게 원칙상 맞습니다.
하지만 C++은 우리를 편하게 해주려고 중괄호 생략 (Brace elision) 이라는 마법을 부립니다. 배열 안에 들어가는 게 단순한 숫자 하나씩일 때, 또는 아까처럼 House{13,1,7} 처럼 이름을 명확히 적어줬을 때는 컴파일러가 눈치껏 "아, 굳이 중괄호 두 번 안 쳐도 내가 찰떡같이 알아먹지!" 하고 한 겹을 생략할 수 있게 허락해 주는 것입니다.
초보자를 위한 꿀팁: 언제 생략해도 되는지 헷갈린다면? 고민하지 말고 무조건 이중 중괄호 {{ }} 를 쓰시면 마음이 편안해집니다. 아니면 일단 중괄호를 한 번만 써보고, 컴파일러가 빨간 줄(에러)을 띄우면 그때 쓱 한 겹 더 씌워주면 됩니다!
마지막으로 Student 라는 구조체를 담은 배열을 어떻게 만들었는지 살펴볼까요?
여기서는 각 값 앞에 Student 라고 친절하게 이름을 적어주었기 때문에 중괄호를 한 번만(생략해서) 썼습니다.
#include <array>
#include <iostream>
#include <string_view>
// 각 학생은 id(번호)와 name(이름)을 가집니다
struct Student{
int id{};
std::string_view name{};
};
// 3명의 학생을 담은 배열입니다. (각 값 앞에 Student라고 명시했기 때문에 중괄호를 한 번만 썼습니다)
constexpr std::array students{ Student{0, "Alex"}, Student{ 1, "Joe" }, Student{ 2, "Bob" } };
const Student* findStudentById(int id){
// 모든 학생을 하나씩 확인합니다
for (auto& s : students)
{
// 입력한 번호(id)와 일치하는 학생을 찾으면 그 학생의 주소를 반환합니다
if (s.id == id) return &s;
}
// 일치하는 번호를 못 찾았다면 빈 값(nullptr)을 반환합니다
return nullptr;
}
int main(){
constexpr std::string_view nobody { "nobody" };
const Student* s1 { findStudentById(1) };
std::cout << "You found: " << (s1 ? s1->name : nobody) << '\n';
const Student* s2 { findStudentById(3) };
std::cout << "You found: " << (s2 ? s2->name : nobody) << '\n';
return 0;
}
출력 결과:
You found: Joe
You found: nobody
참고로, 위 코드에서 std::array students 가 수정 불가능한 상수(constexpr)로 만들어졌기 때문에, 이 학생 정보를 찾아주는 함수 findStudentById() 도 반드시 수정 불가능한 const 포인터를 반환해야 합니다. 따라서 main() 함수에서 결과값을 받을 때도 const Student* 로 받아야 한다는 점 잊지 마세요!
이전 레슨(16.9)에서는 배열과 열거형(enum)에 대해 이야기해 보았죠. 이제 우리가 코드 작성 시 아주 유용한 도구인 constexpr std::array 를 가지게 되었으니, 이 이야기를 조금 더 발전시켜서 몇 가지 재미있는 꿀팁을 알려드릴게요.
C++에서 constexpr std::array 를 만들 때 안의 값들(초기값)만 쏙쏙 넣어주면, 똑똑한 컴파일러가 "아, 이 배열은 이만큼의 길이가 필요하겠구나!" 하고 스스로 크기를 알아맞히는 기능이 있어요.
하지만 만약 우리가 실수로 값을 원래 있어야 할 개수보다 적게 넣으면 어떻게 될까요? 배열의 길이가 생각했던 것보다 짧아지게 되고, 나중에 빈 공간에 접근하려고 하면 프로그램이 엉뚱하게 작동하는 문제(정의되지 않은 동작)가 발생할 수 있습니다.
예를 들어볼게요:
#include <array>
#include <iostream>
enum StudentNames
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
max_students // 5
};
int main()
{
constexpr std::array testScores { 78, 94, 66, 77 }; // 앗, 값이 4개밖에 없네요
std::cout << "Cartman got a score of " << testScores[StudentNames::cartman] << '\n'; // 잘못된 인덱스 접근으로 인해 정의되지 않은 동작 발생
return 0;
}
이렇게 배열 안에 들어가는 값의 개수가 정확한지 확인해야 할 때는 static_assert 라는 기능을 사용해서 아주 안전하게 검사할 수 있어요. static_assert 는 프로그램이 만들어질 때(컴파일될 때) "이 조건이 맞는지 꼭 확인해!" 라고 명령을 내리는 든든한 문지기 역할을 합니다.
#include <array>
#include <iostream>
enum StudentNames
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
max_students // 5
};
int main()
{
constexpr std::array testScores { 78, 94, 66, 77 };
// 시험 점수의 개수가 학생 수와 똑같은지 확인합니다
static_assert(std::size(testScores) == max_students); // 컴파일 에러: static_assert 조건 실패
std::cout << "Cartman got a score of " << testScores[StudentNames::cartman] << '\n';
return 0;
}
이렇게 코드를 짜두면 참 편리해요. 나중에 새로운 학생(열거형 값)을 추가해 놓고 시험 점수를 깜빡 잊고 안 적었더라도, 프로그램이 아예 실행조차 되지 않고 에러를 내뿜기 때문에 실수를 바로잡을 수 있거든요. 이 방법은 두 개의 서로 다른 constexpr std::array 가 똑같은 길이를 가졌는지 확인할 때도 사용할 수 있습니다.
이전 레슨(13.5)에서 열거형(enum)의 이름을 화면에 출력하거나 입력받는 방법을 배웠어요. 그때는 열거형을 문자열로 바꾸거나, 문자열을 다시 열거형으로 바꿔주는 '도우미 함수'를 직접 길게 만들었죠. 그런데 이 함수들은 똑같은 단어들을 중복해서 가지고 있어야 했고, 일일이 조건문을 확인해야 해서 무척 번거로웠어요.
constexpr std::string_view getPetName(Pet pet)
{
switch (pet)
{
case cat: return "cat";
case dog: return "dog";
case pig: return "pig";
case whale: return "whale";
default: return "???";
}
}
constexpr std::optional<Pet> getPetFromString(std::string_view sv)
{
if (sv == "cat") return cat;
if (sv == "dog") return dog;
if (sv == "pig") return pig;
if (sv == "whale") return whale;
return {};
}
이 방식의 귀찮은 점은, 나중에 새로운 동물을 추가할 때마다 이 함수들도 잊지 않고 일일이 수정해 줘야 한다는 거예요.
이제 이 함수들을 훨씬 더 똑똑하게 고쳐볼까요? 대부분의 열거형은 값이 0부터 시작해서 차례대로 1씩 커집니다. 이 특징을 이용하면, 배열 하나에 열거형의 이름들을 순서대로 담아두고 사용할 수 있어요!
이렇게 하면 두 가지 마법 같은 일이 가능해집니다:
#include <array>
#include <iostream>
#include <string>
#include <string_view>
namespace Color
{
enum Type
{
black,
red,
blue,
max_colors
};
// sv 접미사를 사용하여 std::array가 타입을 std::string_view로 추론하게 합니다
using namespace std::string_view_literals; // sv 접미사를 사용하기 위함
constexpr std::array colorName { "black"sv, "red"sv, "blue"sv };
// 모든 색상에 대해 문자열이 잘 정의되었는지 확인합니다
static_assert(std::size(colorName) == max_colors);
};
constexpr std::string_view getColorName(Color::Type color)
{
// 열거형 값을 인덱스로 사용해서 열거형의 이름을 바로 가져올 수 있습니다
return Color::colorName[static_cast<std::size_t>(color)];
}
// operator<< 에게 Color를 어떻게 출력하는지 알려줍니다
// std::ostream은 std::cout의 타입입니다
// 반환 타입과 매개변수 타입은 참조(reference)입니다 (불필요한 복사를 막기 위해서요)!
std::ostream& operator<<(std::ostream& out, Color::Type color)
{
return out << getColorName(color);
}
// operator>> 에게 이름을 통해 Color를 어떻게 입력받는지 알려줍니다
// 함수가 값을 수정할 수 있도록 const가 아닌 참조로 color를 전달합니다
std::istream& operator>> (std::istream& in, Color::Type& color)
{
std::string input {};
std::getline(in >> std::ws, input);
// 이름 목록을 쭉 확인하면서 일치하는 이름이 있는지 찾습니다
for (std::size_t index=0; index < Color::colorName.size(); ++index)
{
if (input == Color::colorName[index])
{
// 일치하는 이름을 찾았다면, 그 인덱스를 사용해 열거형 값을 얻을 수 있습니다
color = static_cast<Color::Type>(index);
return in;
}
}
// 일치하는 것을 못 찾았다면 잘못된 입력이라는 뜻입니다
// 따라서 입력 스트림을 실패(fail) 상태로 설정합니다
in.setstate(std::ios_base::failbit);
// 추출에 실패하면, operator>> 는 기본 타입들을 0으로 초기화합니다
// 이 연산자도 똑같이 동작하게 만들려면 아래 줄의 주석을 해제하세요
// color = {};
return in;
}
int main()
{
auto shirt{ Color::blue };
std::cout << "Your shirt is " << shirt << '\n';
std::cout << "Enter a new color: ";
std::cin >> shirt;
if (!std::cin)
std::cout << "Invalid\n";
else
std::cout << "Your shirt is now " << shirt << '\n';
return 0;
}
이 코드의 실행 결과는 다음과 같습니다:
Your shirt is blue
Enter a new color: red
Your shirt is now red
가끔 열거형 안에 있는 값들을 처음부터 끝까지 한 바퀴 쫙 돌면서(반복하면서) 작업하고 싶을 때가 있어요. 숫자 인덱스를 쓰는 일반적인 for 문을 사용해서 할 수도 있지만, 이렇게 하면 숫자를 열거형 타입으로 억지로 변환해 주는 귀찮은 작업(static casting)을 코드에 많이 적어야 합니다.
#include <array>
#include <iostream>
#include <string_view>
namespace Color
{
enum Type
{
black,
red,
blue,
max_colors
};
// sv 접미사를 사용하여 std::array가 타입을 std::string_view로 추론하게 합니다
using namespace std::string_view_literals; // sv 접미사를 사용하기 위함
constexpr std::array colorName { "black"sv, "red"sv, "blue"sv };
// 모든 색상에 대해 문자열이 잘 정의되었는지 확인합니다
static_assert(std::size(colorName) == max_colors);
};
constexpr std::string_view getColorName(Color::Type color)
{
return Color::colorName[color];
}
// operator<< 에게 Color를 어떻게 출력하는지 알려줍니다
// std::ostream은 std::cout의 타입입니다
// 반환 타입과 매개변수 타입은 참조(reference)입니다 (불필요한 복사를 막기 위해서요)!
std::ostream& operator<<(std::ostream& out, Color::Type color)
{
return out << getColorName(color);
}
int main()
{
// 일반 for문을 사용해 모든 색상을 순회합니다
for (int i=0; i < Color::max_colors; ++i )
std::cout << static_cast<Color::Type>(i) << '\n';
return 0;
}
그렇다면 코드가 훨씬 깔끔해지는 범위 기반 for문 을 쓰면 어떨까요? 안타깝게도 C++에서는 이 편리한 방법을 열거형에 직접 사용할 수는 없게 되어 있어요.
#include <array>
#include <iostream>
#include <string_view>
namespace Color
{
enum Type
{
black,
red,
blue,
max_colors
};
// sv 접미사를 사용하여 std::array가 타입을 std::string_view로 추론하게 합니다
using namespace std::string_view_literals; // sv 접미사를 사용하기 위함
constexpr std::array colorName { "black"sv, "red"sv, "blue"sv };
// 모든 색상에 대해 문자열이 잘 정의되었는지 확인합니다
static_assert(std::size(colorName) == max_colors);
};
constexpr std::string_view getColorName(Color::Type color)
{
return Color::colorName[color];
}
// operator<< 에게 Color를 어떻게 출력하는지 알려줍니다
// std::ostream은 std::cout의 타입입니다
// 반환 타입과 매개변수 타입은 참조(reference)입니다 (불필요한 복사를 막기 위해서요)!
std::ostream& operator<<(std::ostream& out, Color::Type color)
{
return out << getColorName(color);
}
int main()
{
for (auto c: Color::Type) // 컴파일 에러: 열거형은 순회(traverse)할 수 없습니다
std::cout << c < '\n';
return 0;
}
이 문제를 해결하기 위해 많은 프로그래머들이 창의적인 방법들을 만들어냈지만, 가장 직관적이고 쉬운 해결책은 바로 우리가 방금 배운 constexpr std::array 를 활용하는 겁니다!
배열에는 범위 기반 for문 을 마음껏 쓸 수 있거든요. 열거형의 값들을 차례대로 담은 배열을 하나 만들어두고, 그 배열을 반복하도록 만들면 끝입니다. (단, 이 방법은 열거형 값들이 서로 중복되지 않고 고유할 때만 제대로 작동해요!)
#include <array>
#include <iostream>
#include <string_view>
namespace Color
{
enum Type
{
black, // 0
red, // 1
blue, // 2
max_colors // 3
};
using namespace std::string_view_literals; // sv 접미사를 사용하기 위함
constexpr std::array colorName { "black"sv, "red"sv, "blue"sv };
static_assert(std::size(colorName) == max_colors);
constexpr std::array types { black, red, blue }; // 모든 열거형 값들을 담고 있는 std::array 입니다
static_assert(std::size(types) == max_colors);
};
constexpr std::string_view getColorName(Color::Type color)
{
return Color::colorName[color];
}
// operator<< 에게 Color를 어떻게 출력하는지 알려줍니다
// std::ostream은 std::cout의 타입입니다
// 반환 타입과 매개변수 타입은 참조(reference)입니다 (불필요한 복사를 막기 위해서요)!
std::ostream& operator<<(std::ostream& out, Color::Type color)
{
return out << getColorName(color);
}
int main()
{
for (auto c: Color::types) // 성공: std::array에는 범위 기반 for문을 사용할 수 있습니다
std::cout << c << '\n';
return 0;
}
위의 예제 코드를 보면 Color::types 배열에 들어있는 요소들의 타입이 바로 Color::Type 입니다. 따라서 반복문을 돌 때 변수 c 의 타입도 자연스럽게 우리가 원하는 Color::Type 으로 알아서 맞춰집니다. 아주 깔끔하죠!
출력 결과는 다음과 같습니다:
black
red
blue
이제 std::vector 와 std::array 를 배웠으니, 배열의 마지막 종류인 C 스타일 배열도 마저 알아보겠습니다.
16.1 수업 컨테이너와 배열 소개 에서 말했듯이, C 스타일 배열은 C 언어에서 물려받은 기능입니다. 그리고 다른 배열 종류와 달리, 이것은 C++ 표준 라이브러리 클래스가 아니라
C++ 언어 자체에 기본으로 들어 있는 기능 입니다.
그래서 C 스타일 배열을 쓰기 위해서는 #include 로 헤더 파일을 추가할 필요가 없습니다.
참고로
C++ 언어가 원래 직접 지원하는 배열은 C 스타일 배열뿐입니다. 그래서 표준 라이브러리의 배열 컨테이너인std::array나std::vector도 내부적으로는 보통 C 스타일 배열을 바탕으로 만들어집니다.
C 스타일 배열은 언어 자체 기능이기 때문에, 전용 선언 문법이 따로 있습니다.
C 스타일 배열을 선언할 때는 대괄호 [] 를 써서, 이 변수가 C 스타일 배열이라고 컴파일러에게 알려줍니다.
대괄호 안에는 배열의 길이(length)를 넣을 수 있습니다. 이 길이는 std::size_t 형의 정수값이며, 배열 안에 원소가 몇 개 들어 있는지를 컴파일러에게 알려줍니다.
다음 정의는 testScore 라는 이름의 C 스타일 배열 변수를 만들고, 그 안에 int 형 원소 30개를 넣습니다.
int main()
{
int testScore[30] {}; // testScore라는 이름의 C 스타일 배열을 정의한다. int 원소 30개가 값 초기화된다(헤더 포함 불필요)
// std::array<int, 30> arr{}; // 비교용: 값 초기화된 int 원소 30개를 가진 std::array 예시(<array>를 #include 해야 함)
return 0;
}
C 스타일 배열의 길이는 최소 1 이상이어야 합니다.
배열 길이가 0이거나, 음수이거나, 정수가 아니면 컴파일러가 오류를 냅니다.
심화 내용
힙(heap)에 동적으로 할당한 C 스타일 배열은 길이가 0인 것도 허용됩니다.
std::array 와 마찬가지로, C 스타일 배열을 선언할 때 배열 길이는 상수 표현식 이어야 합니다. 즉, 컴파일하기 전에 값이 이미 확정되어 있어야 합니다. 형식은 std::size_t 여야 하지만, 대부분의 경우 이 점은 크게 신경 쓰지 않아도 됩니다.
팁
일부 컴파일러는 C99의 가변 길이 배열(VLA, variable-length array) 기능과 호환되도록,constexpr가 아닌 길이도 허용할 수 있습니다.하지만 가변 길이 배열은 정식 C++ 문법이 아닙니다 .
그러므로 C++ 프로그램에서는 사용하지 않는 것이 좋습니다.만약 여러분의 컴파일러가 이런 배열을 허용한다면, 아마도 컴파일러 확장 기능을 끄지 않은 것일 가능성이 큽니다.
(0.10 수업 컴파일러 설정: 컴파일러 확장 기능 참고)
std::array 처럼, C 스타일 배열도 첨자 연산자 operator[] 로 원소에 접근할 수 있습니다.
#include <iostream>
int main()
{
int arr[5]; // int 값 5개를 담는 배열 정의
arr[1] = 7; // 첨자 연산자를 사용해 배열의 1번 원소에 접근
std::cout << arr[1]; // 7 출력
return 0;
}
표준 라이브러리 컨테이너 클래스들은 보통 인덱스로 std::size_t 같은 부호 없는 정수형 만 사용합니다. 하지만 C 스타일 배열의 인덱스는 부호 있는 정수든, 부호 없는 정수든, 어떤 정수형이든 사용할 수 있고, 범위 없는 열거형(unscoped enum)도 사용할 수 있습니다.
즉, 표준 라이브러리 컨테이너에서 자주 생기는 부호 변환 관련 인덱스 문제를 C 스타일 배열은 덜 겪습니다.
#include <iostream>
int main()
{
const int arr[] { 9, 8, 7, 6, 5 };
int s { 2 };
std::cout << arr[s] << '\n'; // 부호 있는 인덱스를 사용해도 괜찮음
unsigned int u { 2 };
std::cout << arr[u] << '\n'; // 부호 없는 인덱스를 사용해도 괜찮음
return 0;
}
팁
C 스타일 배열은 부호 있는 인덱스, 부호 없는 인덱스, 범위 없는 열거형 인덱스를 모두 받을 수 있습니다.하지만
operator[]는 범위 검사를 하지 않습니다.
그래서 배열 범위를 벗어난 인덱스를 넣으면 정의되지 않은 동작이 발생합니다.
참고로
배열을 선언할 때int arr[5]처럼 쓰는[]는 첨자 연산자operator[]를 호출하는 것이 아닙니다.
그것은 그냥 배열 선언 문법의 일부 입니다.
std::array 와 마찬가지로, C 스타일 배열도 집합체입니다.
그래서 집합체 초기화를 사용할 수 있습니다.
간단히 다시 말하면, 집합체 초기화는 집합체의 멤버를 중괄호 목록으로 한 번에 직접 초기화하는 방법 입니다.
즉, 중괄호 {} 안에 쉼표로 구분된 값들을 넣어서 초기화합니다.
int main()
{
int fibonnaci[6] = { 0, 1, 1, 2, 3, 5 }; // 중괄호 목록을 사용한 복사 리스트 초기화
int prime[5] { 2, 3, 5, 7, 11 }; // 중괄호 목록을 사용한 리스트 초기화(권장)
return 0;
}
이 두 방식 모두 배열 원소를 0번부터 차례대로 초기화합니다.
만약 C 스타일 배열에 초기값을 주지 않으면, 원소들은 기본 초기화 됩니다.
대부분의 경우 이것은 원소들이 초기화되지 않은 상태로 남는다 는 뜻입니다.
보통은 원소가 제대로 초기화되길 원하므로, 초기값 없이 정의할 때는 빈 중괄호 {} 를 사용해서 값 초기화 하는 것이 좋습니다.
int main()
{
int arr1[5]; // 멤버가 기본 초기화됨(int 원소들은 초기화되지 않은 채로 남음)
int arr2[5] {}; // 멤버가 값 초기화됨(int 원소들은 0으로 초기화됨)(권장)
return 0;
}
초기화 목록에 들어 있는 값의 개수가 배열 길이보다 많으면 컴파일러가 오류를 냅니다.
반대로 값의 개수가 배열 길이보다 적으면, 남은 원소들은 값 초기화됩니다.
int main()
{
int a[4] { 1, 2, 3, 4, 5 }; // 컴파일 오류: 초기값이 너무 많음
int b[4] { 1, 2 }; // arr[2]와 arr[3]는 값 초기화됨
return 0;
}
C 스타일 배열의 단점 중 하나는, 원소 타입을 반드시 직접 써야 한다는 점입니다.
C 스타일 배열은 클래스 템플릿이 아니기 때문에 CTAD(클래스 템플릿 인수 추론)가 동작하지 않습니다.
또한 auto 를 사용해서 초기화 목록으로부터 배열 원소 타입을 추론하게 하는 것도 불가능합니다.
int main()
{
auto squares[5] { 1, 4, 9, 16, 25 }; // 컴파일 오류: C 스타일 배열에는 타입 추론을 사용할 수 없음
return 0;
}
다음 배열 정의에는 살짝 중복되는 정보가 있습니다. 보이시나요?
int main()
{
const int prime[5] { 2, 3, 5, 7, 11 }; // prime의 길이는 5
return 0;
}
우리는 컴파일러에게 배열 길이가 5라고 직접 말했고, 동시에 초기값도 5개 넣었습니다.
즉, 같은 정보를 두 번 말한 셈입니다.
C 스타일 배열을 초기화 목록으로 초기화할 때는, 배열 정의에서 길이를 생략할 수 있습니다. 그러면 컴파일러가 초기값 개수를 보고 배열 길이를 알아서 계산해 줍니다.
다음 두 배열 정의는 똑같이 동작합니다.
int main()
{
const int prime1[5] { 2, 3, 5, 7, 11 }; // prime1은 길이 5라고 직접 지정
const int prime2[] { 2, 3, 5, 7, 11 }; // prime2는 컴파일러가 길이 5라고 추론
return 0;
}
이 방법은 모든 배열 원소에 대해 초기값을 명시했을 때만 동작합니다.
int main()
{
int bad[] {}; // 오류: 컴파일러는 이것을 길이 0 배열로 추론하는데, 길이 0 배열은 허용되지 않음
return 0;
}
초기화 목록으로 C 스타일 배열의 모든 원소를 초기화할 때는, 배열 길이를 생략하고 컴파일러가 계산하게 하는 편이 더 좋습니다 .
그렇게 하면 나중에 초기값을 추가하거나 제거해도 배열 길이가 자동으로 맞춰집니다.
또한, 직접 적어 둔 배열 길이와 실제 초기값 개수가 서로 안 맞는 실수도 피할 수 있습니다.
권장 사항
모든 배열 원소를 명시적으로 초기화할 때는, C 스타일 배열의 길이를 생략하는 것을 권장합니다.
std::array 와 마찬가지로, C 스타일 배열도 const 또는 constexpr 로 만들 수 있습니다.
다른 const 변수와 마찬가지로, const 배열은 반드시 초기화해야 하고, 한 번 정한 뒤에는 원소 값을 바꿀 수 없습니다.
#include <iostream>
namespace ProgramData
{
constexpr int squares[5] { 1, 4, 9, 16, 25 }; // constexpr int로 이루어진 배열
}
int main()
{
const int prime[5] { 2, 3, 5, 7, 11 }; // const int로 이루어진 배열
prime[0] = 17; // 컴파일 오류: const int는 바꿀 수 없음
return 0;
}
sizeof이전 수업에서 sizeof() 연산자는 객체나 타입의 크기를 바이트 단위 로 구한다고 배웠습니다.
C 스타일 배열에 sizeof() 를 적용하면, 배열 전체가 차지하는 바이트 수 를 반환합니다.
#include <iostream>
int main()
{
const int prime[] { 2, 3, 5, 7, 11 }; // 컴파일러가 prime의 길이를 5로 추론함
std::cout << sizeof(prime); // 20 출력(단, int가 4바이트라고 가정)
return 0;
}
int 가 4바이트라고 가정하면, 위 프로그램은 20을 출력합니다.
prime 배열은 int 원소 5개를 가지고 있고, 각 원소가 4바이트이므로 5 * 4 = 20 바이트입니다.
여기에는 별도의 추가 정보(overhead)가 없습니다.
C 스타일 배열 객체는 원소들만 가지고 있고, 그 외의 추가 데이터는 없습니다 .
C++17에서는 <iterator> 헤더에 있는 std::size() 를 사용할 수 있습니다.
이 함수는 배열 길이를 부호 없는 정수값(std::size_t) 으로 돌려줍니다.
C++20에서는 std::ssize() 도 사용할 수 있는데, 이것은 배열 길이를 부호 있는 정수값 으로 돌려줍니다. (보통 std::ptrdiff_t 같은 큰 부호 있는 정수형입니다.)
#include <iostream>
#include <iterator> // std::size와 std::ssize용
int main()
{
const int prime[] { 2, 3, 5, 7, 11 }; // 컴파일러가 prime의 길이를 5로 추론함
std::cout << std::size(prime) << '\n'; // C++17, 부호 없는 정수값 5를 반환
std::cout << std::ssize(prime) << '\n'; // C++20, 부호 있는 정수값 5를 반환
return 0;
}
팁
std::size()와std::ssize()의 정식 헤더는<iterator>입니다.
하지만 이 함수들이 워낙 자주 쓰이다 보니,<array>나<vector>같은 다른 헤더에서도 함께 사용할 수 있는 경우가 많습니다.다만 C 스타일 배열에
std::size()또는std::ssize()를 쓸 때는, 다른 헤더를 이미 포함하지 않았을 수도 있습니다.
그럴 때는 보통<iterator>를 포함하는 것이 관례입니다.이 함수들을 제공하는 전체 헤더 목록은 cppreference의 size 함수 문서에서 볼 수 있습니다.
C++17 이전에는 C 스타일 배열의 길이를 구하는 표준 라이브러리 함수가 없었습니다.
만약 C++11 또는 C++14를 쓰고 있다면, 대신 다음 함수를 사용할 수 있습니다.
#include <cstddef> // std::size_t용
#include <iostream>
template <typename T, std::size_t N>
constexpr std::size_t length(const T(&)[N]) noexcept
{
return N;
}
int main() {
int array[]{ 1, 1, 2, 3, 5, 8, 13, 21 };
std::cout << "The array has: " << length(array) << " elements\n";
return 0;
}
이 코드는 함수 템플릿을 사용합니다.
이 함수는 C 스타일 배열을 참조(reference) 로 받고, 그 배열 길이를 나타내는 비타입 템플릿 매개변수 N 을 그대로 반환합니다.
아주 오래된 코드에서는, 배열 전체 크기를 원소 하나의 크기로 나누어서 길이를 구하는 방법도 자주 보입니다.
#include <iostream>
int main()
{
int array[8] {};
std::cout << "The array has: " << sizeof(array) / sizeof(array[0]) << " elements\n";
return 0;
}
이 코드는 다음을 출력합니다.
The array has: 8 elements
이게 왜 될까요?
먼저, 배열 전체 크기는 다음과 같습니다.
배열 전체 크기 = 배열 길이 × 원소 하나의 크기
짧게 쓰면:
배열 크기 = 길이 × 원소 크기
이 식을 길이에 대해 정리하면:
길이 = 배열 크기 / 원소 크기
보통 원소 크기는 sizeof(array[0]) 로 구합니다.
그래서 다음 식이 나옵니다.
length = sizeof(array) / sizeof(array[0])
가끔은 이것을 다음처럼 쓰는 경우도 있습니다.
sizeof(array) / sizeof(*array)
이것도 같은 뜻입니다.
하지만 다음 수업에서 보겠지만, 이 공식은 배열이 decay된 경우 쉽게 깨질 수 있습니다.
그러면 프로그램이 예상치 못하게 망가질 수 있습니다.
반면 C++17의 std::size() 와 위에서 보여 준 length() 함수 템플릿은 이런 경우 컴파일 오류를 내기 때문에 더 안전합니다 .
관련 내용
배열 decay는 다음 수업 17.8 C 스타일 배열 decay 에서 다룹니다.
조금 의외일 수 있지만, C++ 배열은 배열 전체에 대한 대입을 지원하지 않습니다.
int main()
{
int arr[] { 1, 2, 3 }; // 괜찮음: 초기화는 가능
arr[0] = 4; // 개별 원소에 대한 대입은 가능
arr = { 5, 6, 7 }; // 컴파일 오류: 배열 전체 대입은 불가능
return 0;
}
기술적으로 말하면, 대입 연산의 왼쪽 피연산자는 수정 가능한 lvalue 여야 합니다.
그런데 C 스타일 배열은 그런 대상으로 취급되지 않기 때문에 이런 대입이 불가능합니다.
만약 C 스타일 배열에 새로운 값 목록을 통째로 넣고 싶다면, 가장 좋은 방법은 std::vector 를 쓰는 것입니다.
또는 C 스타일 배열의 각 원소를 하나씩 바꾸거나, std::copy 를 사용해서 다른 C 스타일 배열의 내용을 복사할 수도 있습니다.
#include <algorithm> // std::copy용
int main()
{
int arr[] { 1, 2, 3 };
int src[] { 5, 6, 7 };
// src를 arr로 복사
std::copy(std::begin(src), std::end(src), std::begin(arr));
return 0;
}
C 스타일 배열은 “같은 타입의 값을 여러 개 붙여서 저장하는, C++ 기본 내장 배열” 입니다.
다만 편리한 기능이 적고 실수하기 쉬워서, 현대 C++ 에서는 보통 std::array 나 std::vector 를 더 많이 씁니다.
C 언어를 만든 사람들은 한 가지 문제를 해결해야 했습니다.
먼저 아래처럼 아주 단순한 프로그램을 봅시다.
#include <iostream>
void print(int val)
{
std::cout << val;
}
int main()
{
int x { 5 };
print(x);
return 0;
}
print(x)를 호출하면, 인수 x의 값 5가 매개변수 val로 복사됩니다.
함수 안에서는 val의 값 5를 화면에 출력합니다.
int 하나 복사하는 건 부담이 거의 없으니, 여기서는 아무 문제가 없습니다.
이제 비슷한 예제를 보겠습니다.
이번에는 int 하나가 아니라, 원소가 1000개인 C 스타일 int 배열을 사용합니다.
#include <iostream>
void printElementZero(int arr[1000])
{
std::cout << arr[0]; // 첫 번째 원소의 값을 출력
}
int main()
{
int x[1000] { 5 }; // 원소가 1000개인 배열 정의, x[0]은 5로 초기화
printElementZero(x);
return 0;
}
이 프로그램도 잘 컴파일되고, 예상대로 5를 출력합니다.
겉으로 보기에는 앞의 예제와 비슷하지만, 실제로는 예상과 조금 다르게 동작합니다.
왜냐하면 C 언어 설계자들이 두 가지 큰 문제를 동시에 해결하려고 특별한 방법을 만들었기 때문입니다.
첫 번째 문제는 이것입니다.
함수를 호출할 때마다 원소 1000개짜리 배열을 통째로 복사하면 비용이 큽니다.
원소 타입 자체도 복사 비용이 큰 타입이라면 더 부담스럽습니다.
그래서 복사를 피하고 싶었습니다.
그런데 C에는 참조(reference)가 없어서, 참조 전달로 복사를 피할 수 없었습니다.
두 번째 문제도 있습니다.
길이가 다른 배열들도 하나의 함수로 받고 싶었습니다.
예를 들어 위의 printElementZero()는 배열 길이가 몇이든, arr[0]만 있으면 되니 아무 길이의 배열이든 받는 게 이상적입니다.
배열 길이마다 함수를 하나씩 만드는 건 너무 불편합니다.
그런데 C에는 “길이가 아무거나인 배열”을 직접 표현하는 문법도 없고, 템플릿도 없고, 길이가 다른 배열끼리 자동 변환도 안 됩니다.
그래서 C 언어 설계자들은 아주 영리한 해결책을 만들었습니다.
이 방식은 호환성 때문에 C++에도 그대로 들어왔습니다.
#include <iostream>
void printElementZero(int arr[1000]) // 복사본을 만들지 않음
{
std::cout << arr[0]; // 첫 번째 원소의 값을 출력
}
int main()
{
int x[7] { 5 }; // 원소가 7개인 배열 정의
printElementZero(x); // somehow works!
return 0;
}
이상하게도 위 코드는 원소 7개짜리 배열을, 원소 1000개짜리 배열을 받는 것처럼 보이는 함수에 넘기고도 잘 동작합니다.
게다가 배열 복사도 일어나지 않습니다.
이번 단원에서는 이것이 어떻게 가능한지 살펴보겠습니다.
또한 C 설계자들이 고른 이 해결책이 왜 위험할 수 있는지, 그리고 왜 현대 C++에서는 그다지 좋은 방식이 아닌지도 함께 보겠습니다.
하지만 그 전에, 먼저 두 가지 하위 주제를 알아야 합니다.
대부분의 경우, C 스타일 배열이 식(expression) 안에서 사용되면, 배열은 자동으로 원소 타입을 가리키는 포인터로 바뀝니다.
이 포인터는 배열의 첫 번째 원소(인덱스 0)의 주소로 초기화됩니다.
이 현상을 보통 array decay(줄여서 decay)라고 부릅니다.
다음 프로그램을 보면 확인할 수 있습니다.
#include <iomanip> // std::boolalpha용
#include <iostream>
int main()
{
int arr[5]{ 9, 7, 5, 3, 1 }; // 우리 배열의 원소 타입은 int
// 먼저, arr가 int* 포인터로 decay된다는 것을 확인해 보자
auto ptr{ arr }; // 값을 평가하면 arr가 decay됨, 타입 추론은 int*를 추론해야 함
std::cout << std::boolalpha << (typeid(ptr) == typeid(int*)) << '\n'; // ptr의 타입이 int*이면 true 출력
// 이제 이 포인터가 배열의 첫 번째 원소 주소를 가지고 있다는 것도 확인해 보자
std::cout << std::boolalpha << (&arr[0] == ptr) << '\n';
return 0;
}
작성자의 컴퓨터에서는 다음이 출력되었습니다.
true
true
배열이 decay되어 만들어진 포인터는 특별한 포인터가 아닙니다.
그냥 첫 번째 원소의 주소를 담고 있는 일반 포인터입니다.
마찬가지로, const 배열(예: const int arr[5])은 pointer-to-const, 즉 const int*로 decay됩니다.
팁
C++에서 C 스타일 배열이 decay되지 않는 흔한 경우는 몇 가지 있습니다.
sizeof()나typeid()의 인수로 사용될 때operator&로 배열 자체의 주소를 구할 때- 클래스 타입의 멤버로 전달될 때
- 참조로 전달될 때
C 스타일 배열은 대부분의 경우 포인터로 decay되기 때문에, “배열은 곧 포인터다”라고 착각하기 쉽습니다.
하지만 이건 사실이 아닙니다.
배열 객체는 원소들이 순서대로 들어 있는 덩어리이고,
포인터 객체는 주소 하나를 저장하는 변수일 뿐입니다.
배열 타입과 decay된 배열의 타입 정보도 다릅니다.
위 예제에서 arr의 타입은 int[5]이고, decay된 후의 타입은 int*입니다.
중요한 차이는 이것입니다.
int[5]에는 길이 정보가 들어 있음int*에는 길이 정보가 없음핵심 통찰
decay된 배열 포인터는, 자신이 가리키는 배열의 길이를 알지 못합니다.
“decay”라는 말은 바로 이 길이 정보가 사라진다는 뜻도 함께 담고 있습니다.
[])를 쓰면, 사실 decay된 포인터에 operator[]를 적용하는 것이다C 스타일 배열은 값으로 평가될 때 포인터로 decay되므로, 배열에 첨자 []를 붙이는 것도 사실은 decay된 포인터에 대해 첨자를 쓰는 것입니다.
#include <iostream>
int main()
{
const int arr[] { 9, 7, 5, 3, 1 };
std::cout << arr[2]; // decay된 배열에 첨자를 써서 2번 원소를 얻음, 5 출력
return 0;
}
포인터에도 직접 operator[]를 사용할 수 있습니다.
그 포인터가 첫 번째 원소의 주소를 가지고 있다면, 결과는 똑같습니다.
#include <iostream>
int main()
{
const int arr[] { 9, 7, 5, 3, 1 };
const int* ptr{ arr }; // arr가 포인터로 decay됨
std::cout << ptr[2]; // ptr에 첨자를 써서 2번 원소를 얻음, 5 출력
return 0;
}
이게 왜 편리한지는 조금 뒤에 보게 됩니다.
그리고 이것이 정확히 어떻게 동작하는지, 또 포인터가 첫 번째 원소가 아닌 다른 곳을 가리키면 어떻게 되는지는 다음 단원 17.9 -- Pointer arithmetic and subscripting에서 더 자세히 다룹니다.
array decay는 이 단원 처음에 나온 두 가지 문제를 한 번에 해결합니다.
C 스타일 배열을 함수 인수로 넘기면, 배열은 포인터로 decay되고,
그 배열의 첫 번째 원소 주소를 담은 포인터가 함수로 전달됩니다.
즉, 겉으로는 배열을 값으로 전달하는 것처럼 보여도, 실제로는 주소를 전달하는 것입니다.
그래서 배열 전체 복사본을 만들지 않아도 됩니다.
핵심 통찰
C 스타일 배열은, 겉보기와 달리 값 전달이 아니라 주소 전달됩니다.
이제 같은 원소 타입이지만 길이가 다른 두 배열을 생각해 봅시다.
예를 들어 int[5]와 int[7]은 서로 다른 타입이라서 원래는 호환되지 않습니다.
하지만 둘 다 decay되면 똑같이 int*가 됩니다.
즉, 길이 정보가 사라지기 때문에 길이가 다른 배열도 같은 방식으로 전달할 수 있게 됩니다.
핵심 통찰
원소 타입이 같고 길이만 다른 두 C 스타일 배열은, decay되면 같은 포인터 타입으로 바뀝니다.
다음 예제에서는 두 가지를 보여 줍니다.
(const) 포인터로 선언할 수 있다는 것#include <iostream>
void printElementZero(const int* arr) // const 주소 전달
{
std::cout << arr[0];
}
int main()
{
const int prime[] { 2, 3, 5, 7, 11 };
const int squares[] { 1, 4, 9, 25, 36, 49, 64, 81 };
printElementZero(prime); // prime은 const int* 포인터로 decay됨
printElementZero(squares); // squares는 const int* 포인터로 decay됨
return 0;
}
이 예제는 잘 동작하고, 다음을 출력합니다.
2
1
main() 안에서 printElementZero(prime)를 호출하면, prime은 const int[5] 배열에서 const int* 포인터로 decay됩니다.
이 포인터는 prime의 첫 번째 원소 주소를 담고 있습니다.
마찬가지로 printElementZero(squares)를 호출하면, squares는 const int[8]에서 const int*로 decay됩니다.
그리고 역시 첫 번째 원소의 주소를 담습니다.
함수에 실제로 전달되는 것은 바로 이런 const int* 포인터들입니다.
그래서 printElementZero() 함수의 매개변수도 같은 포인터 타입인 const int*여야 합니다.
이 함수 안에서는 그 포인터에 첨자 []를 써서 원하는 배열 원소에 접근합니다.
C 스타일 배열은 주소로 전달되기 때문에, 함수는 복사본이 아니라 원본 배열에 직접 접근합니다.
따라서 함수가 배열 원소를 바꿀 수도 있습니다.
그래서 함수가 배열을 수정할 생각이 없다면, 매개변수에 const를 붙이는 것이 좋습니다.
매개변수를 int* arr처럼 선언하면, arr가 “정수 하나를 가리키는 포인터”인지, “배열의 첫 번째 원소를 가리키는 포인터”인지 한눈에 잘 드러나지 않습니다.
그래서 C 스타일 배열을 받을 때는, 보통 대체 문법인 int arr[]를 사용하는 편이 더 읽기 쉽습니다.
#include <iostream>
void printElementZero(const int arr[]) // const int*와 똑같이 처리됨
{
std::cout << arr[0];
}
int main()
{
const int prime[] { 2, 3, 5, 7, 11 };
const int squares[] { 1, 4, 9, 25, 36, 49, 64, 81 };
printElementZero(prime); // prime은 포인터로 decay됨
printElementZero(squares); // squares는 포인터로 decay됨
return 0;
}
이 프로그램은 바로 앞 예제와 완전히 똑같이 동작합니다.
컴파일러는 함수 매개변수 const int arr[]를 const int*와 같은 의미로 해석하기 때문입니다.
하지만 arr[] 문법에는 장점이 있습니다.
이 매개변수가 “값 하나를 가리키는 포인터”가 아니라, decay된 C 스타일 배열을 받기 위한 것이라는 뜻을 더 잘 전달해 줍니다.
대괄호 안에는 길이 정보를 쓸 필요가 없습니다.
어차피 사용되지 않기 때문입니다.
길이를 써도 무시됩니다.
권장 사항
C 스타일 배열을 기대하는 함수 매개변수는 포인터 문법(
int* arr)보다 배열 문법(int arr[])을 사용하는 것이 좋습니다.
다만 이 문법의 단점도 있습니다.
겉으로는 배열처럼 보여서, 사실은 이미 decay되어 포인터가 되었다는 점이 덜 눈에 띕니다.
그래서 decay된 배열에서는 기대대로 동작하지 않는 일을 실수로 하기가 더 쉽습니다.
이제 그런 문제들을 보겠습니다.
array decay는 길이가 다른 C 스타일 배열을 복사 없이 함수에 넘길 수 있게 해 준 영리한 해결책이었습니다. 하지만 배열 길이 정보가 사라지기 때문에, 여러 종류의 실수가 생기기 쉽습니다.
첫 번째 문제는 sizeof()입니다.
배열과 decay된 배열(즉 포인터)에 대해 sizeof()는 서로 다른 값을 돌려줍니다.
#include <iostream>
void printArraySize(int arr[])
{
std::cout << sizeof(arr) << '\n'; // 4 출력 (주소가 32비트라고 가정)
}
int main()
{
int arr[]{ 3, 2, 1 };
std::cout << sizeof(arr) << '\n'; // 12 출력 (int가 4바이트라고 가정)
printArraySize(arr);
return 0;
}
즉, C 스타일 배열에 sizeof()를 쓰는 것은 꽤 위험할 수 있습니다.
지금 내가 다루는 것이 진짜 배열 객체인지, 아니면 이미 decay된 포인터인지를 확실히 알아야 하기 때문입니다.
이전 단원 17.7 -- Introduction to C-style arrays에서는, 예전부터 sizeof(arr) / sizeof(*arr)를 C 스타일 배열 길이를 구하는 꼼수처럼 써 왔다고 말했습니다.
하지만 이 방식은 위험합니다.
arr가 이미 decay되었다면, sizeof(arr)는 배열 크기가 아니라 포인터 크기를 반환합니다.
그러면 배열 길이를 잘못 계산하게 되고, 프로그램이 오작동할 가능성이 큽니다.
다행히 C++17의 std::size()(그리고 C++20의 std::ssize())는 포인터를 넘기면 컴파일 자체를 막아 줍니다.
#include <iostream>
int printArrayLength(int arr[])
{
std::cout << std::size(arr) << '\n'; // 컴파일 오류: std::size()는 포인터에서 동작하지 않음
}
int main()
{
int arr[]{ 3, 2, 1 };
std::cout << std::size(arr) << '\n'; // 3 출력
printArrayLength(arr);
return 0;
}
두 번째 문제는, 어쩌면 더 중요할 수 있는데, 리팩터링을 어렵게 만든다는 점입니다.
즉, 긴 함수를 더 짧고 깔끔한 함수들로 나누는 과정에서 문제가 생기기 쉽습니다.
원래는 일반 배열로 잘 동작하던 코드가, 함수로 분리되면서 decay된 배열을 다루게 되면:
세 번째 문제는 길이 정보가 없어서 생기는 여러 실전 문제들입니다.
배열 길이를 모르면, 그 배열이 충분히 긴지 확인하기가 어렵습니다.
사용자는 함수가 기대하는 길이보다 짧은 배열을 넘길 수도 있고, 심지어 배열이 아니라 값 하나의 주소를 넘길 수도 있습니다.
그런데 함수가 아무 검증 없이 arr[2] 같은 접근을 해 버리면, 정의되지 않은 동작(undefined behavior)이 발생할 수 있습니다.
#include <iostream>
void printElement2(int arr[])
{
// arr에 원소가 최소 3개 있는지 어떻게 보장할까?
std::cout << arr[2] << '\n';
}
int main()
{
int a[]{ 3, 2, 1 };
printElement2(a); // ok
int b[]{ 7, 6 };
printElement2(b); // 컴파일은 되지만 정의되지 않은 동작 발생
int c{ 9 };
printElement2(&c); // 컴파일은 되지만 정의되지 않은 동작 발생
return 0;
}
배열 길이를 모르면, 배열을 처음부터 끝까지 순회하는 것도 어렵습니다.
어디까지 가야 끝인지 알 수 없기 때문입니다.
물론 이런 문제를 피하는 방법은 있습니다.
하지만 그런 방법들은 프로그램을 더 복잡하게 만들고, 더 약하게(깨지기 쉽게) 만들기도 합니다.
전통적으로 프로그래머들은 배열 길이 정보가 없다는 문제를 두 가지 방식 중 하나로 해결해 왔습니다.
첫 번째 방법은 배열과 배열 길이를 따로따로 넘기는 것입니다.
#include <cassert>
#include <iostream>
void printElement2(const int arr[], int length)
{
assert(length > 2 && "printElement2: Array too short"); // length에는 static_assert를 쓸 수 없음
std::cout << arr[2] << '\n';
}
int main()
{
constexpr int a[]{ 3, 2, 1 };
printElement2(a, static_cast<int>(std::size(a))); // ok
constexpr int b[]{ 7, 6 };
printElement2(b, static_cast<int>(std::size(b))); // assert 발생
return 0;
}
하지만 이 방법도 여전히 여러 문제가 있습니다.
std::size()나 std::size_t를 반환하는 길이 함수와 함께 쓰면, 부호(sign) 변환 문제가 생길 수 있습니다.assert는 실제 실행 중 그 코드가 지나갈 때만 잡힙니다.constexpr 배열 길이를 컴파일 타임에 검사하려고 static_assert를 쓰고 싶지만, 함수 매개변수는 constexpr가 될 수 없어서(심지어 constexpr 또는 consteval 함수 안에서도) 쉽지 않습니다.두 번째 방법은, 의미상 유효하지 않은 특별한 값을 배열 끝 표시로 쓰는 것입니다.
예를 들어 시험 점수에 -1은 말이 안 된다고 하면, -1을 “여기가 끝”이라고 표시하는 식입니다.
이렇게 하면 배열 길이는 시작부터 그 끝 표시 원소까지 세어서 구할 수 있습니다.
배열 순회도 끝 표시가 나올 때까지 하면 됩니다.
이 방법의 좋은 점은, 암묵적인 함수 호출에서도 동작한다는 것입니다.
핵심 통찰
C 스타일 문자열도 C 스타일 배열이며, 문자열 끝을 표시하기 위해 널 종료 문자를 사용합니다.
그래서 문자열이 decay되더라도 끝을 찾으면서 순회할 수 있습니다.
하지만 이 방법도 문제점이 많습니다.
C 스타일 배열은 전달 방식도 일반적이지 않습니다.
겉보기에는 값 전달 같지만 실제로는 주소 전달입니다.
그리고 decay가 일어나면 길이 정보도 사라집니다.
이런 이유들 때문에 C 스타일 배열은 점점 덜 쓰이게 되었습니다.
가능하면 피하는 것을 권장합니다.
권장 사항
가능하다면 C 스타일 배열은 피하세요.
- 읽기 전용 문자열(문자열 리터럴 심볼 상수, 문자열 매개변수)에는
std::string_view를 우선 사용하세요.- 수정 가능한 문자열에는
std::string을 우선 사용하세요.- 전역이 아닌
constexpr배열에는std::array를 우선 사용하세요.constexpr가 아닌 배열에는std::vector를 우선 사용하세요.전역
constexpr배열에는 C 스타일 배열을 사용해도 괜찮습니다. 이것은 바로 아래에서 설명합니다.
참고로...
C++에서는 배열을 참조로 전달할 수 있습니다.
이 경우 함수에 넘길 때 배열이 decay되지 않습니다.
(다만 배열에 대한 참조도, 값을 평가하면 결국 decay될 수 있습니다.)하지만 이 방식을 항상 빠짐없이 적용해야 하는데, 한 번이라도 참조를 빼먹으면 decay가 발생합니다.
게다가 배열 참조 매개변수는 길이가 고정되어 있어야 하므로, 함수가 특정 길이의 배열 하나만 처리할 수 있습니다.길이가 다른 배열도 처리하려면 함수 템플릿까지 써야 합니다.
그런데 그렇게까지 해서 C 스타일 배열을 “고칠” 바에는, 그냥std::array를 쓰는 편이 낫습니다.
현대 C++에서 C 스타일 배열은 보통 두 경우에 사용됩니다.
첫 번째는 constexpr 전역 데이터(또는 constexpr static local 데이터)를 저장할 때입니다.
이런 배열은 프로그램 어디서든 직접 접근할 수 있어서, 함수로 전달할 일이 적고, 따라서 decay 문제도 피하기 쉽습니다.
또한 C 스타일 배열 문법이 std::array보다 약간 덜 번거롭게 느껴질 수도 있습니다.
더 중요한 점은, 이런 배열에 인덱싱할 때 표준 라이브러리 컨테이너들처럼 부호 변환 문제를 덜 겪는다는 것입니다.
두 번째는, 함수나 클래스가 constexpr이 아닌 C 스타일 문자열 인수를 직접 처리하고 싶을 때입니다.
즉, 굳이 std::string_view로 바꾸라고 요구하지 않는 경우입니다.
이유는 두 가지가 있을 수 있습니다.
첫째, constexpr이 아닌 C 스타일 문자열을 std::string_view로 바꾸려면, 문자열 길이를 알아내기 위해 문자열을 한 번 끝까지 훑어야 합니다.
만약 함수가 성능이 매우 중요한 코드 구간에 있고, 길이 정보가 딱히 필요 없다면(예: 어차피 함수가 직접 문자열을 끝까지 순회할 예정이라면), 이 변환을 생략하는 것이 도움이 될 수 있습니다.
둘째, 그 함수나 클래스가 내부적으로 또 다른 C 스타일 문자열을 기대하는 함수들을 호출한다면,
굳이 std::string_view로 바꿨다가 다시 C 스타일 문자열 방식으로 맞추는 과정이 비효율적일 수 있습니다.
(물론 std::string_view를 꼭 써야 할 다른 이유가 있다면 이야기는 달라집니다.)
이전 레슨 '16.1 -- 컨테이너와 배열 소개'에서 배열은 메모리 상에 순서대로 나란히 저장된다고 배웠습니다. 이번 레슨에서는 배열의 인덱스(몇 번째 요소인지 나타내는 번호)가 수학적으로 어떻게 작동하는지 조금 더 깊이 파헤쳐 보겠습니다.
앞으로의 레슨에서 이 인덱싱 수학을 직접 쓸 일은 많지 않겠지만, 이번 내용을 알아두면 범위 기반 for 문(range-based for loops)이 실제로 어떻게 작동하는지 이해하는 데 큰 도움이 됩니다. 또한 나중에 '반복자(iterators)'라는 개념을 배울 때 아주 유용하게 쓰일 거예요!
포인터 연산이란 포인터(메모리 주소를 담는 변수)에 덧셈, 뺄셈, 1 증가(++), 1 감소(--) 같은 간단한 수학 계산을 해서 새로운 메모리 주소를 만들어내는 기능입니다.
어떤 포인터 ptr이 있다고 해볼게요. ptr + 1을 하면 단순히 주소 숫자에 1이 더해지는 게 아니라, 메모리 상에 있는 바로 다음 데이터의 주소를 알려줍니다.
(포인터가 가리키는 데이터의 종류에 따라 몇 바이트를 건너뛸지 결정됩니다)
예를 들어, ptr이 정수형 포인터(int*)이고 정수(int)가 4바이트 크기라면, ptr + 1은 ptr의 주소에서 딱 4바이트 뒤에 있는 메모리 주소를 반환합니다. 마찬가지로 ptr + 2는 8바이트 뒤의 메모리 주소를 알려주겠죠.
#include <iostream>
int main()
{
int x {};
const int* ptr{ &x }; // int가 4바이트라고 가정해 봅시다
std::cout << ptr << ' ' << (ptr + 1) << ' ' << (ptr + 2) << '\n';
return 0;
}
원문 작성자의 컴퓨터에서는 이 코드가 다음과 같이 출력되었습니다.
00AFFD80 00AFFD84 00AFFD88
출력된 주소들을 잘 보면, 이전 주소보다 정확히 4바이트씩 커졌다는 것을 알 수 있습니다.
자주 쓰이진 않지만, 포인터 연산은 뺄셈도 가능합니다. 포인터 ptr이 있을 때, ptr - 1은 메모리 상에 있는 바로 앞 데이터의 주소를 알려줍니다.
#include <iostream>
int main()
{
int x {};
const int* ptr{ &x }; // int가 4바이트라고 가정해 봅시다
std::cout << ptr << ' ' << (ptr - 1) << ' ' << (ptr - 2) << '\n';
return 0;
}
원문 작성자의 컴퓨터에서는 다음과 같이 출력되었습니다.
00AFFD80 00AFFD7C 00AFFD78
이 경우에는 주소가 4바이트씩 작아진 것을 볼 수 있습니다.
(참고로 C는 16진수에서 12를, 8은 8을 의미하므로 차이가 4입니다!)
핵심 통찰
포인터 연산은 단순히 주소 숫자에서 1을 더하거나 빼는 것이 아닙니다. 포인터가 가리키는 자료형의 크기를 기준으로, 다음/이전 데이터 객체의 주소를 반환하는 것입니다.
포인터에 증가 연산자(++)나 감소 연산자(--)를 붙이면 앞서 본 덧셈/뺄셈과 똑같이 작동합니다. 다만, 차이점은 포인터 변수 자체가 가지고 있는 주소 값을 실제로 변경한다는 것입니다.
정수 x가 있을 때 ++x는 x = x + 1을 짧게 쓴 것이죠? 마찬가지로 포인터 ptr이 있을 때 ++ptr은 ptr = ptr + 1을 짧게 쓴 것입니다. 포인터 연산을 한 다음 그 결과를 다시 ptr에 집어넣는 거죠.
#include <iostream>
int main()
{
int x {};
const int* ptr{ &x }; // int가 4바이트라고 가정해 봅시다
std::cout << ptr << '\n';
++ptr; // ptr = ptr + 1
std::cout << ptr << '\n';
--ptr; // ptr = ptr - 1
std::cout << ptr << '\n';
return 0;
}
원문 작성자의 컴퓨터에서는 이렇게 출력되었습니다.
00AFFD80 00AFFD84 00AFFD80
주의 사항
엄밀히 말해서, 위 코드는 '정의되지 않은 동작'입니다. C++ 표준에 따르면 포인터 연산은 포인터와 그 결과값이 같은 배열 안에 속해 있을 때(혹은 배열의 마지막 요소 바로 다음을 가리킬 때)만 안전하게 작동한다고 정의되어 있습니다. 하지만 요즘 나오는 대부분의 C++ 컴파일러들은 이 규칙을 강제로 막지 않아서, 배열 바깥에서 포인터 연산을 해도 에러를 띄우지는 않는 경우가 많습니다. (그래도 조심하는 게 좋습니다!)
이전 레슨 (17.8 -- C 스타일 배열 붕괴)에서 우리는 배열의 인덱스 기호(operator[])를 포인터에도 사용할 수 있다고 배웠습니다.
#include <iostream>
int main()
{
const int arr[] { 9, 7, 5, 3, 1 };
const int* ptr{ arr }; // 0번째 요소의 주소를 담고 있는 일반적인 포인터
std::cout << ptr[2]; // ptr의 인덱스 2에 접근하여 요소를 가져옵니다. 5가 출력됩니다.
return 0;
}
여기서 무슨 일이 일어나고 있는지 조금 더 자세히 들여다볼게요.
알고 보면 ptr[n] 이라는 인덱스 방식은, *((ptr) + (n)) 이라는 복잡한 코드를 아주 짧고 예쁘게 줄여 쓴 것에 불과합니다. 모양만 다를 뿐 사실 포인터 연산이라는 뜻이죠!
괄호는 연산이 올바른 순서로 되도록 막아준 것이고, 앞에 붙은 별표(*)는 그 주소에 있는 실제 값을 꺼내오는(역참조) 역할을 합니다.
과정을 쉽게 설명해 드릴게요:
ptr에 arr을 넣어 초기화합니다. 배열 arr이 초기화에 사용되면, 배열은 '0번째 요소의 주소를 담은 포인터'로 슬쩍 변신(붕괴)합니다. 그래서 이제 ptr은 0번째 요소의 주소를 가지게 됩니다.ptr[2]를 출력합니다. ptr[2]는 *((ptr) + (2))와 같고, 이는 *(ptr + 2)와 같습니다. ptr + 2는 ptr에서 2칸 떨어진 데이터의 주소, 즉 '인덱스 2번' 요소의 주소를 찾아줍니다. 그리고 앞에 붙은 * 덕분에 그 주소 안에 있는 진짜 값(여기서는 5)이 뿅 하고 나타나는 것입니다.다른 예시를 한 번 볼까요?
#include <iostream>
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;
}
작성자의 컴퓨터에서는 이렇게 출력되었습니다.
00AFFD80 00AFFD84 00AFFD88
3 2 1
00AFFD80 00AFFD84 00AFFD88
3 2 1
arr이 주소 00AFFD80을 가지고 있다면, (arr + 1)은 4바이트 뒤의 주소를, (arr + 2)는 8바이트 뒤의 주소를 반환한다는 것을 알 수 있습니다. 이 주소들 앞에 *를 붙이면(역참조) 그 위치에 있는 값(3, 2, 1)을 얻을 수 있습니다.
배열 요소들은 메모리상에 항상 나란히 붙어있기 때문에, arr이 배열의 0번째 요소를 가리키는 포인터라면 *(arr + n)은 자연스럽게 배열의 n번째 요소가 됩니다.
이것이 바로 배열의 순서가 1이 아니라 0부터 시작하는 진짜 이유입니다! 0부터 시작해야 컴퓨터가 인덱스를 계산할 때 매번 1을 빼지 않아도 되어서 수학적으로 훨씬 효율적이거든요.
참고로...
재미있는 사실 하나 알려드릴게요! 컴파일러가ptr[n]을*((ptr) + (n))으로 바꿔서 계산한다고 했죠? 그렇기 때문에 덧셈의 순서를 바꿔서n[ptr]이라고 적어도 똑같이 작동합니다! 컴파일러가 이를*((n) + (ptr))로 바꾸기 때문이죠. 하지만 코드를 읽는 사람을 엄청나게 헷갈리게 만드니까 절대 이렇게 쓰지는 마세요!
지난 레슨에서 C 스타일 배열의 인덱스에는 양수(부호 없는 정수)뿐만 아니라 음수(부호 있는 정수)도 쓸 수 있다고 말씀드렸습니다. 단순한 편의를 위해서가 아닙니다. C 스타일 배열에서는 실제로 음수 인덱스를 쓰는 것이 가능하기 때문입니다. 이상하게 들리겠지만, 원리를 알면 말이 됩니다.
방금 *(ptr+1)이 메모리에서 다음 데이터를 가져오고, ptr[1]이 그것과 똑같은 기능이라고 배웠죠?
그렇다면 레슨 맨 처음에 배운 *(ptr-1)은 메모리에서 이전 데이터를 가져온다는 사실도 기억하시나요? 이걸 인덱스로 짧게 쓰면 어떻게 될까요? 맞습니다. 바로 ptr[-1]이 됩니다!
#include <array>
#include <iostream>
int main()
{
const int arr[] { 9, 8, 7, 6, 5 };
// ptr이 3번째 요소를 가리키도록 설정합니다
const int* ptr { &arr[3] };
// ptr이 3번째 요소를 가리키고 있다는 것을 증명해 봅시다
std::cout << *ptr << ptr[0] << '\n'; // 66 출력
// 놀랍게도 ptr[-1]은 2번째 요소입니다!
std::cout << *(ptr-1) << ptr[-1] << '\n'; // 77 출력
return 0;
}
포인터 연산이 가장 많이 쓰이는 곳 중 하나는, 인덱스 번호를 쓰지 않고 C 스타일 배열의 처음부터 끝까지 쭉 훑어보는(순회하는) 작업입니다. 다음 코드가 어떻게 작동하는지 보세요.
#include <iostream>
int main()
{
constexpr int arr[]{ 9, 7, 5, 3, 1 };
const int* begin{ arr }; // begin은 시작 요소를 가리킵니다
const int* end{ arr + std::size(arr) }; // end는 배열의 '마지막 요소 바로 다음 칸'을 가리킵니다
for (; begin != end; ++begin) // begin부터 end에 도달하기 전까지 반복합니다
{
std::cout << *begin << ' '; // 현재 요소를 가져오기 위해 포인터를 역참조합니다
}
return 0;
}
위의 예제에서, 우리는 begin 포인터가 가리키는 요소(배열의 0번째 요소)부터 탐색을 시작합니다. begin과 end의 주소가 아직 다르므로(begin != end), 루프 안의 코드가 실행됩니다. 루프 안에서는 *begin을 통해 현재 위치의 값을 꺼내옵니다. 한 바퀴가 끝나면 ++begin을 실행해서 포인터가 다음 요소를 가리키도록 한 칸 전진시킵니다. 이 작업은 begin과 end가 완전히 같아질 때까지 계속 반복됩니다.
따라서 위 코드는 다음과 같이 출력됩니다.
9 7 5 3 1
여기서 end 포인터가 배열의 '마지막 요소 바로 다음 칸(one-past-the-end)'을 가리키도록 설정된 것에 주목하세요. 그 위치에 실제 데이터가 없더라도, 그 주소를 가지고만 있는 것은 문제가 되지 않습니다 (단, 그 주소에서 값을 꺼내오려고(*end) 하면 안 됩니다!). 이렇게 설정하면 계산이나 조건 비교가 아주 깔끔해져서 어디서 +1이나 -1을 억지로 해줄 필요가 없습니다.
팁
C 스타일 배열을 가리키는 포인터에서 포인터 연산을 할 때, 계산된 주소값이 유효한 배열 요소의 주소이거나 마지막 요소 바로 다음 칸이라면 안전합니다. 하지만 이 범위를 벗어난 주소를 계산하게 되면, (그 주소의 값을 읽으려 시도하지 않더라도) 정의되지 않은 동작(undefined behavior)이 발생하므로 주의해야 합니다.
이전 레슨 '17.8 -- C 스타일 배열 붕괴'에서, 배열이 포인터로 변해버리면(붕괴하면) 배열의 전체 크기를 구하는 std::size 같은 기능이 작동하지 않아서 코드를 수정하기 어렵다고 했습니다. 하지만 이렇게 시작(begin)과 끝(end) 포인터로 배열을 순회하는 방식을 쓰면, 반복문 부분을 깔끔하게 별도의 함수로 분리할 수 있습니다!
#include <iostream>
void printArray(const int* begin, const int* end)
{
for (; begin != end; ++begin) // begin부터 end에 도달하기 전까지 반복합니다
{
std::cout << *begin << ' '; // 현재 요소를 가져오기 위해 포인터를 역참조합니다
}
std::cout << '\n';
}
int main()
{
constexpr int arr[]{ 9, 7, 5, 3, 1 };
const int* begin{ arr }; // begin은 시작 요소를 가리킵니다
const int* end{ arr + std::size(arr) }; // end는 배열의 '마지막 요소 바로 다음 칸'을 가리킵니다
printArray(begin, end);
return 0;
}
이 프로그램은 arr 배열 자체를 함수에 넘겨주지 않았는데도 아주 잘 작동합니다! 배열을 직접 전달하지 않았으니 배열이 붕괴되어 크기를 잃어버릴 걱정도 없죠. 대신 begin과 end라는 두 개의 포인터만으로 배열을 순회하는 데 필요한 모든 정보를 함수에 전달한 것입니다.
나중에 반복자(iterators)와 알고리즘(algorithms)을 배울 때 다시 보겠지만, C++ 표준 라이브러리에는 이렇게 begin과 end 쌍을 이용해서 데이터를 다루는 함수들이 정말 많답니다.
다음과 같은 아주 편리한 '범위 기반 for 문'을 생각해 봅시다.
#include <iostream>
int main()
{
constexpr int arr[]{ 9, 7, 5, 3, 1 };
for (auto e : arr) // `begin`부터 `end`에 도달하기 전까지 반복합니다
{
std::cout << e << ' '; // 반복 변수를 통해 현재 요소를 가져옵니다
}
return 0;
}
만약 범위 기반 for 문의 공식 문서를 찾아본다면, 컴퓨터 내부에서는 이 반복문을 대략 이런 구조로 바꿔서 실행한다는 것을 알 수 있습니다.
{
auto __begin = begin-expr; // 시작점
auto __end = end-expr; // 끝점
for ( ; __begin != __end; ++__begin)
{
range-declaration = *__begin; // 현재 값을 변수에 담기
loop-statement; // 루프 안의 실제 코드 실행
}
}
이 원리를 바탕으로, 앞서 보았던 범위 기반 for 문을 내부 구현 방식대로 직접 풀어서 써보겠습니다.
#include <iostream>
int main()
{
constexpr int arr[]{ 9, 7, 5, 3, 1 };
auto __begin = arr; // arr이 시작점(begin-expr)이 됩니다
auto __end = arr + std::size(arr); // arr + std::size(arr)이 끝점(end-expr)이 됩니다
for ( ; __begin != __end; ++__begin)
{
auto e = *__begin; // e라는 변수에 현재 포인터가 가리키는 값을 복사해 넣습니다
std::cout << e << ' '; // 여기가 루프 안에서 우리가 작성했던 코드입니다
}
return 0;
}
방금 전 '포인터 연산으로 배열 훑어보기' 섹션에서 우리가 직접 짰던 코드랑 완전히 똑같이 생겼죠?! 유일한 차이점이라면, 포인터에서 값을 꺼내올 때 *__begin을 그대로 쓰지 않고, 한 번 더 편하게 쓰기 위해 e라는 변수에 담아서 썼다는 것뿐입니다!
이전 17.7 레슨(C 스타일 배열 소개)에서 우리는 여러 개의 데이터를 순서대로 모아두는
'C 스타일 배열'에 대해 배웠습니다.
int testScore[30] {}; // 30개의 정수를 담을 수 있는 배열 (인덱스 번호는 0부터 29까지)
그리고 5.2 레슨(리터럴)에서는 "Hello, world!"처럼 문자들이 순서대로 모여 있는 것을 '문자열'이라고 불렀고, 'C 스타일 문자열 리터럴'이라는 개념도 소개했었죠.
여기서 "Hello, world!"라는 문자열은 const char[14]라는 타입을 가집니다. 눈에 보이는 13개의 글자에, 문자열의 끝을 알리는 숨겨진 '널 종료 문자(null-terminator)' 1개가 더해진 거예요.
혹시 눈치채셨나요? C 스타일 문자열은 사실 요소의 타입이 char나 const char로 이루어진 C 스타일 배열일 뿐입니다!
우리가 코드 안에 "안녕"처럼 따옴표로 묶인 C 스타일 문자열 리터럴을 쓰는 건 괜찮습니다. 하지만 C 스타일 문자열 '객체(변수)'를 직접 다루는 것은 요즘 현대 C++에서는 잘 쓰이지 않습니다. 다루기 까다롭고 위험하기 때문이죠. (그래서 요즘은 더 안전하고 편한 std::string이나 std::string_view를 주로 사용합니다). 그럼에도 불구하고 예전에 쓰인 코드들에서는 여전히 이 C 스타일 문자열을 자주 마주치게 될 테니, 꼭 알고 넘어가야 합니다.
그래서 이번 레슨에서는 현대 C++에서 C 스타일 문자열 객체를 다룰 때 꼭 알아야 할 가장 중요한 핵심들을 살펴보겠습니다.
C 스타일 문자열 변수를 만드는 방법은 간단합니다. 그냥 char (또는 const char, constexpr char) 타입의 C 스타일 배열을 선언하면 됩니다.
char str1[8]{}; // 8개의 문자를 담는 배열, 인덱스는 0부터 7까지
const char str2[]{ "string" }; // 7개의 문자를 담는 배열, 인덱스는 0부터 6까지
constexpr char str3[] { "hello" }; // 6개의 문자를 담는 배열, 인덱스는 0부터 5까지
항상 기억하세요! 눈에 보이지 않는 끝맺음 문자인 '널 종료 문자'를 담기 위해 늘 빈칸 1개가 더 필요합니다.
초기값을 넣어서 C 스타일 문자열을 만들 때는, 배열의 크기(길이)를 숫자로 직접 쓰지 말고 컴파일러가 알아서 계산하게 두는 것을 강력히 추천합니다. 이렇게 하면 나중에 문자열의 내용이 길어지거나 짧아져도 숫자를 일일이 수정할 필요가 없고, 널 종료 문자를 위한 공간을 깜빡 잊어버릴 위험도 없으니까요.
17.8 레슨에서 우리는 C 스타일 배열이 대부분의 상황에서 '포인터'로 변해버리는 현상(이를 붕괴라고 부릅니다)을 배웠습니다. C 스타일 문자열도 결국 배열이기 때문에 똑같이 포인터로 퇴화해 버립니다.
C 스타일 문자열 리터럴은 const char*로 퇴화하고, C 스타일 문자열 배열은 배열이 값을 바꿀 수 있는지(const 여부)에 따라 const char*나 char*로 퇴화합니다. 이렇게 문자열이 포인터로 모습이 바뀌어버리면, 문자열이 원래 얼마나 길었는지에 대한 길이 정보가 싹 사라지게 됩니다.
이처럼 길이 정보를 잃어버리기 때문에, C 스타일 문자열의 끝에는 항상 '널 종료 문자'가 달려있는 것입니다. 비록 길이가 몇인지 잊어버렸더라도, 처음부터 시작해서 이 널 종료 문자가 나올 때까지 글자 수를 하나하나 세면 (비효율적이긴 하지만) 길이를 다시 알아낼 수 있으니까요.
C 스타일 문자열을 화면에 띄울 때, std::cout은 이 '널 종료 문자'를 만날 때까지 글자들을 계속 출력합니다. 널 종료 문자가 "여기가 문자열의 끝이야!"라고 표시해주기 때문에, 길이를 잃어버리고 퇴화한 문자열이라도 문제없이 끝까지 출력할 수 있는 겁니다.
#include <iostream>
void print(char ptr[]){
std::cout << ptr << '\n'; // 문자열을 출력합니다
}
int main(){
char str[]{ "string" };
std::cout << str << '\n'; // 문자열을 출력합니다
print(str);
return 0;
}
만약 널 종료 문자가 없는 문자열을 출력하려고 하면 어떻게 될까요? (예를 들어 실수로 널 종료 문자를 다른 글자로 덮어써 버렸다면요) 결과는 '정의되지 않은 동작(예측할 수 없는 치명적인 오류)'으로 이어집니다. 가장 흔하게 일어나는 현상은, 원래 문자열을 다 출력하고 나서도 멈추지 않고 메모리 옆 칸을 계속 뒤지며 아무 글자나 마구잡이로 화면에 찍어내는 것입니다. 그러다 우연히 '0(널 종료 문자)'이 들어있는 메모리를 마주쳐야 겨우 멈추게 되죠!
사용자에게 주사위를 원하는 만큼 굴린 다음, 나온 숫자들을 띄어쓰기 없이 쭉 적어보라고 한다고 상상해 봅시다 (예: 524412616). 사용자가 숫자를 몇 개나 적을까요? 우리는 전혀 알 수가 없습니다.
C 스타일 문자열은 한 번 크기를 정하면 늘어나거나 줄어들지 않는 '고정 크기 배열'이기 때문에, 이럴 땐 우리가 필요할 것 같은 최대 크기보다 훨씬 더 큰 배열을 넉넉하게 만들어 두는 것이 유일한 해결책입니다.
#include <iostream>
int main(){
char rolls[255] {}; // 254개의 글자 + 널 종료 문자까지 담을 수 있을 만큼 충분히 큰 배열을 선언합니다
std::cout << "Enter your rolls: ";
std::cin >> rolls;
std::cout << "You entered: " << rolls << '\n';
return 0;
}
C++20 버전 이전에는 std::cin >> rolls가 띄어쓰기를 만나기 전까지 최대한 많은 글자를 rolls 배열에 쑤셔 넣으려고 했습니다. 문제는 사용자가 실수로든 고의로든 우리가 준비한 254글자보다 훨씬 많은 글자를 입력하는 걸 막을 방법이 없었다는 점입니다. 만약 그런 일이 발생하면, 사용자의 입력값이 rolls 배열의 크기를 넘쳐흘러 버리고 치명적인 오류(정의되지 않은 동작)가 발생합니다.
핵심 통찰
배열 오버플로우(Array overflow) 또는 버퍼 오버플로우(Buffer overflow)는 담을 수 있는 공간보다 더 많은 데이터가 복사될 때 발생하는 무서운 컴퓨터 보안 문제입니다. 이런 일이 생기면 허락된 공간 너머의 메모리 영역까지 덮어써 버려서 프로그램이 엉뚱하게 작동합니다. 악의적인 해커들은 이런 약점을 이용해 메모리 내용을 조작하고 자기들 입맛대로 프로그램을 오작동하게 만들기도 합니다.
이러한 문제 때문에 C++20부터는 >> 연산자가 '퇴화하지 않은(길이 정보를 그대로 가진)' C 스타일 문자열을 입력받을 때만 작동하도록 규칙이 바뀌었습니다. 덕분에 딱 배열의 길이만큼만 글자를 가져올 수 있어서 넘쳐흐르는 것을 막을 수 있죠. 하지만 동시에, 이미 포인터로 퇴화해버린 C 스타일 문자열에는 더 이상 >> 연산자로 값을 입력받을 수 없다는 뜻이기도 합니다.
그래서 std::cin을 사용해 C 스타일 문자열을 읽어 들일 때 가장 추천하는 안전한 방법은 다음과 같습니다.
#include <iostream>
#include <iterator> // std::size를 사용하기 위해 포함합니다
int main(){
char rolls[255] {}; // 254개의 글자 + 널 종료 문자까지 담을 수 있을 만큼 충분히 큰 배열을 선언합니다
std::cout << "Enter your rolls: ";
std::cin.getline(rolls, std::size(rolls));
std::cout << "You entered: " << rolls << '\n';
return 0;
}
이렇게 cin.getline()을 사용하면 빈칸(띄어쓰기)을 포함해서 최대 254글자까지만 rolls 배열에 안전하게 읽어 들입니다. 공간을 넘어서는 나머지 글자들은 그냥 버려지죠. getline()은 글자를 얼마나 읽을지 길이를 정해줄 수 있기 때문에 최대 한도액을 알려줄 수 있습니다. 배열이 퇴화하지 않은 상태라면 std::size()를 써서 전체 크기를 쉽게 구해 넣을 수 있습니다. 하지만 퇴화된 배열이라면 다른 꼼수로 길이를 알아내야 하고, 실수로 엉뚱한 길이를 입력해버리면 프로그램이 망가지거나 보안에 구멍이 뚫릴 수 있습니다.
요즘의 현대 C++에서는 사용자에게 글자를 입력받아 저장할 때 std::string을 사용하는 것이 훨씬 더 안전합니다. std::string은 글자가 들어오는 양에 맞춰 알아서 척척 자기 크기를 늘려주기 때문입니다.
여기서 한 가지 꼭 알아두어야 할 점은, C 스타일 문자열도 C 스타일 배열과 똑같은 규칙을 따른다는 것입니다. 즉, 처음 만들 때 값을 넣어서 초기화하는 것은 가능하지만, 일단 한 번 만들어진 다음에는 = 연산자로 새로운 통짜 문자열을 집어넣을 수 없다는 뜻입니다!
char str[]{ "string" }; // 정상: 처음 만들 때 초기화하는 것은 괜찮습니다
str = "rope"; // 오류: 만들어진 이후에 = 으로 새로운 값을 대입하는 것은 안 됩니다!
이런 제약 때문에 C 스타일 문자열을 다루는 게 조금 성가시게 느껴집니다.
하지만 C 스타일 문자열도 본질은 결국 '배열'이기 때문에, [] 기호(인덱스)를 사용해서 문자열 안에 있는 특정 글자 딱 하나만 콕 집어서 바꾸는 건 얼마든지 가능합니다.
#include <iostream>
int main(){
char str[]{ "string" };
std::cout << str << '\n';
str[1] = 'p';
std::cout << str << '\n';
return 0;
}
이 프로그램을 실행하면 다음과 같이 출력됩니다.
string
spring
거듭 강조하지만 C 스타일 문자열은 C 스타일 배열입니다. 그래서 std::size() (C++20부터는 std::ssize())를 사용해서 배열의 전체 길이를 알아낼 수 있습니다. 하지만 여기서 주의해야 할 두 가지 함정이 있습니다.
#include <iostream>
int main(){
char str[255]{ "string" }; // 6개의 실제 글자 + 널 종료 문자
std::cout << "length = " << std::size(str) << '\n'; // 출력 결과: length = 255
char *ptr { str };
std::cout << "length = " << std::size(ptr) << '\n'; // 컴파일 오류가 발생합니다!
return 0;
}
이를 해결하는 다른 방법은 <cstring> 헤더 파일 안에 있는 strlen() 함수를 사용하는 것입니다. strlen()은 퇴화된 배열에서도 아주 잘 작동하며, 숨겨진 널 종료 문자를 뺀 '순수한 글자 수'만 깔끔하게 세어서 반환해 줍니다.
#include <cstring> // std::strlen을 사용하기 위해 포함합니다
#include <iostream>
int main(){
char str[255]{ "string" }; // 6개의 실제 글자 + 널 종료 문자
std::cout << "length = " << std::strlen(str) << '\n'; // 출력 결과: length = 6
char *ptr { str };
std::cout << "length = " << std::strlen(ptr) << '\n'; // 출력 결과: length = 6
return 0;
}
하지만 안타깝게도 std::strlen() 함수는 좀 느린 편입니다. 길이를 한 번에 바로 아는 게 아니라, 배열의 맨 처음부터 시작해서 널 종료 문자를 만날 때까지 글자를 일일이 하나씩 세어가며 발품을 팔아야 하기 때문입니다.
C 스타일 문자열은 이름 그대로 과거 C 언어 시절의 주력 문자열 형태였습니다.
그래서 C 언어에는 이를 쪼개고 붙이고 다루기 위한 다양한 도구(함수)들이 많이 준비되어 있죠. C++은 이 함수들을 고스란히 물려받아서 <cstring> 헤더에 잘 모아두었습니다.
오래된 코드들을 보다 보면 마주치게 될 유용한 옛날 함수 몇 가지를 소개합니다.
strlen() -- C 스타일 문자열의 길이(글자 수)를 알려줍니다.strcpy(), strncpy(), strcpy_s() -- 한 문자열을 다른 문자열에 덮어씁니다(복사).strcat(), strncat() -- 한 문자열의 맨 끝부분에 다른 문자열을 꼬리표처럼 이어 붙입니다.strcmp(), strncmp() -- 두 문자열이 서로 똑같은지 비교합니다. (완전히 똑같으면 0을 반환합니다).하지만 strlen()을 제외하고는, 특별한 이유가 없다면 이 함수들을 사용하지 않는 것을 강력히 권장합니다.
const가 붙지 않아서 내용을 언제든 바꿀 수 있는(non-const) C 스타일 문자열은 꼭 써야 할 명확하고 어쩔 수 없는 이유가 없다면 피하는 것이 상책입니다. 다루기도 매우 불편할뿐더러, 아까 말했던 메모리를 넘쳐버리는 오류(오버런)가 생기기 딱 좋기 때문입니다. 이런 오류는 프로그램에 원인을 알 수 없는 고장을 일으키거나 보안 문제를 일으킬 수 있습니다.
메모리가 극단적으로 부족한 기기용 프로그램을 짜는 등 아주 드물게 고정된 크기의 버퍼나 C 스타일 문자열을 꼭 써야만 하는 상황이라면, 차라리 검증이 끝난 훌륭한 외부 라이브러리(3rd party fixed-length string library)를 가져다 쓰시는 것을 추천합니다.
권장 사항
값을 수정할 수 있는(non-const) C 스타일 문자열 변수는 피하시고, 대신 훨씬 똑똑하고 안전한std::string을 사용하세요.
이전 레슨(17.10 - C스타일 문자열)에서는 C스타일 문자열을 만들고 그 안에 값을 넣는(초기화) 방법에 대해 알아보았습니다.
#include <iostream>
int main()
{
char name[]{ "Alex" }; // C스타일 문자열
std::cout << name << '\n';
return 0;
}
C++에서는 값이 변하지 않는(상수) C스타일 문자열을 만드는 두 가지 방법을 제공합니다.
#include <iostream>
int main()
{
const char name[] { "Alex" }; // 방법 1: C스타일 문자열 글자 그대로(리터럴) 초기화된 const C스타일 문자열
const char* const color{ "Orange" }; // 방법 2: C스타일 문자열 리터럴을 가리키는 const 포인터
std::cout << name << ' ' << color << '\n';
return 0;
}
이 코드를 실행하면 다음과 같이 출력됩니다.
Alex Orange
위의 두 가지 방법은 똑같은 결과를 만들어내지만, C++이 컴퓨터의 메모리(저장 공간)를 사용하는 방식에는 약간 차이가 있습니다.
방법 1의 경우, 먼저 "Alex"라는 글자가 메모리 어딘가(아마도 읽기만 가능한 구역)에 저장됩니다. 그러고 나서 프로그램은 길이가 5인 배열('A', 'l', 'e', 'x' 4글자 + 끝을 알리는 빈칸 문자 하나)을 담을 빈 공간을 새로 만들고, 그곳에 "Alex"를 복사해서 집어넣습니다.
결과적으로 "Alex"가 두 개(어딘가에 숨겨진 원본 하나, name 변수가 가진 복사본 하나) 생기는 셈이죠. 어차피 name은 const(상수)라서 앞으로 내용이 바뀔 일도 없는데, 굳이 복사본을 만드는 건 메모리를 낭비하는 비효율적인 방식입니다.
방법 2의 경우, 컴파일러(코드를 번역해 주는 프로그램)마다 처리 방식이 다를 수 있습니다. 하지만 보통은 "Orange"라는 글자를 읽기 전용 메모리에 한 번만 떡하니 저장해 두고, 그 위치(주소)를 포인터가 가리키게만 만듭니다. (불필요한 복사본을 만들지 않아요!)
프로그램을 더 빠르고 효율적으로 만들기 위해(최적화), 똑같은 글자들은 하나의 값으로 묶일 수도 있습니다. 예를 들어볼까요?
const char* name1{ "Alex" };
const char* name2{ "Alex" };
"Alex"라는 글자가 두 번 쓰였죠? 이 글자들은 어차피 변하지 않는 상수이기 때문에, 컴파일러는 메모리를 아끼기 위해 "Alex"를 딱 한 번만 저장해 둡니다. 그리고 name1과 name2가 모두 그 똑같은 저장 위치(주소)를 가리키게 공유하도록 만듭니다.
C스타일 문자열에서 auto를 사용해 알아서 타입을 맞추게(타입 추론) 하는 것은 꽤 간단하고 직관적입니다.
auto s1{ "Alex" }; // const char* 타입으로 추론됨
auto* s2{ "Alex" }; // const char* 타입으로 추론됨
auto& s3{ "Alex" }; // const char(&)[5] 타입으로 추론됨 (길이가 5인 배열의 참조)
여러분은 std::cout이 포인터의 종류에 따라 화면에 출력하는 방식이 조금 다르다는 걸 눈치채셨을 수도 있습니다.
다음 예시를 살펴볼까요?
#include <iostream>
int main()
{
int narr[]{ 9, 7, 5, 3, 1 };
char carr[]{ "Hello!" };
const char* ptr{ "Alex" };
std::cout << narr << '\n'; // narr은 int* 타입으로 변환(decay)됩니다.
std::cout << carr << '\n'; // carr은 char* 타입으로 변환(decay)됩니다.
std::cout << ptr << '\n'; // ptr은 이미 char* 타입입니다. (원문 주석의 name은 ptr의 오타입니다)
return 0;
}
글쓴이의 컴퓨터에서는 이 코드가 다음과 같이 출력되었습니다.
003AF738
Hello!
Alex
왜 숫자(int) 배열은 메모리 주소(003AF738 같은 이상한 값)를 출력하고, 문자(char) 배열은 글자를 그대로 출력했을까요?
정답은 std::cout 같은 출력 기능이 여러분의 '의도'를 짐작하기 때문입니다. 문자가 아닌 다른 종류의 포인터를 넘겨주면, 포인터가 가지고 있는 원래 값(즉, 메모리 주소)을 그대로 화면에 보여줍니다.
하지만 char*나 const char* 타입(문자 포인터)을 넘겨주면, std::cout은 속으로 "아! 이 사람은 글자를 화면에 보여주고 싶구나!"라고 생각합니다. 그래서 메모리 주소를 보여주는 대신, 그 주소가 가리키고 있는 진짜 글자들을 출력해 버리는 것입니다!
대부분의 경우에는 이렇게 알아서 해주는 게 무척 편리하지만, 가끔은 생각지도 못한 엉뚱한 결과를 낳기도 합니다. 아래 코드를 볼까요?
#include <iostream>
int main()
{
char c{ 'Q' };
std::cout << &c;
return 0;
}
이 코드를 작성한 사람은 문자 c가 저장된 진짜 메모리 주소(&c)를 확인하고 싶었을 겁니다. 하지만 &c는 타입이 char*(문자 포인터)가 되어버리죠. 그래서 std::cout은 눈치 없이 이것을 글자로 착각하고 출력하려고 시도합니다! 문제는 문자 c 뒤에는 문자열이 끝났다는 표시(널 종단자, null terminator)가 없기 때문에, 프로그램이 고장 난 것처럼 엉뚱하게 작동하게 됩니다(정의되지 않은 동작).
글쓴이의 컴퓨터에서는 이런 식으로 외계어가 출력되었습니다.
Q╠╠╠╠╜╡4;¿■A
왜 이런 짓을 한 걸까요? 먼저 std::cout은 &c(문자 포인터)를 C스타일 문자열로 착각했습니다. 그래서 'Q'를 출력하고 멈추지 않고 계속 다음 메모리 공간으로 전진했습니다. 그 뒤의 메모리에는 알 수 없는 쓰레기 값들이 잔뜩 들어있었던 거죠. 그렇게 쓰레기 값들을 막 출력하다가, 우연히 0이라는 값이 들어있는 메모리를 만나고 나서야 "아! 여기서 문자열이 끝났구나!"라고 생각하고 출력을 멈춘 겁니다. 여러분의 컴퓨터에서는 변수 c 뒤에 어떤 메모리가 있느냐에 따라 전혀 다른 외계어가 보일 수도 있습니다.
사실 실제 코딩을 하면서 문자 변수의 메모리 주소를 출력하고 싶어 하는 경우는 거의 없기 때문에 흔하게 겪을 일은 아닙니다. 하지만 이 예시는 컴퓨터 내부에서 데이터가 어떻게 처리되는지, 그리고 프로그램이 어떻게 의도치 않은 방향으로 폭주할 수 있는지를 아주 잘 보여줍니다.
만약 정말로 문자 포인터(char*)의 메모리 주소를 꼭 화면에 출력하고 싶다면, static_cast라는 기능을 써서 타입을 const void*로 강제로 바꿔주면 됩니다.
#include <iostream>
int main()
{
const char* ptr{ "Alex" };
std::cout << ptr << '\n'; // ptr을 C스타일 문자열(글자)로 출력합니다.
std::cout << static_cast<const void*>(ptr) << '\n'; // ptr이 가지고 있는 메모리 주소를 출력합니다.
return 0;
}
관련 컨텐츠
void*에 대해서는 19.5 레슨(Void 포인터)에서 다룰 예정입니다. 지금 당장 이게 어떻게 작동하는지 몰라도 여기서 쓰는 데는 전혀 문제없습니다.
요즘 사용하는 최신 C++에서는 굳이 C스타일 문자열 기호 상수를 쓸 이유가 거의 없습니다. 대신에 constexpr std::string_view 객체를 사용하는 것이 훨씬 좋습니다. 속도도 똑같이 빠르거나 오히려 더 빠르고, 헷갈리지 않고 항상 일관되게 작동하기 때문입니다.
권장 사항
C스타일 문자열 기호 상수는 피하고, 대신constexpr std::string_view를 사용하는 것을 강력히 추천합니다!
틱택토(Tic-tac-toe) 게임을 떠올려보세요. 빙고랑 비슷한 게임이죠! 이 게임의 기본 판은 3x3 격자 모양이고, 두 명의 플레이어가 번갈아 가며 'X'와 'O' 기호를 그립니다. 기호 3개를 한 줄로 먼저 잇는 사람이 이기는 게임이죠.
게임판의 데이터를 9개의 개별 변수에 따로따로 저장할 수도 있겠지만, 우리는 이미 똑같은 종류의 데이터를 여러 개 다룰 때는 '배열(array)'이라는 묶음을 사용하는 게 훨씬 편하다는 것을 알고 있습니다.
int ttt[9]; // 정수형 C스타일 배열 (값 0 = 빈칸, 1 = 플레이어 1, 2 = 플레이어 2)
이 코드는 메모리에 9개의 칸이 일렬로 나란히 늘어선 C스타일 배열을 만듭니다. 우리는 이 데이터들이 아래처럼 한 줄로 쭉 늘어서 있다고 상상할 수 있어요.
// ttt[0] ttt[1] ttt[2] ttt[3] ttt[4] ttt[5] ttt[6] ttt[7] ttt[8]
배열의 차원(dimension)이라는 건, 배열 안의 특정 값을 콕 집어내기 위해 '방 번호(인덱스)'가 몇 개나 필요한지를 말합니다. 차원이 딱 하나뿐인 배열을 1차원 배열(single-dimensional array 또는 one-dimensional array)이라고 부릅니다. (줄여서 1d 배열이라고도 해요). 위에서 만든 ttt가 바로 1차원 배열의 예시입니다. ttt[2]처럼 방 번호 하나만 쓰면 원하는 값을 꺼낼 수 있으니까요.
하지만 가만히 생각해 보면, 한 줄로 길게 늘어선 이 1차원 배열은 평면(2차원) 공간에서 진행되는 실제 틱택토 게임판의 모습과는 거리가 좀 멉니다. 우리는 이것보다 더 멋진 방법을 쓸 수 있어요.
이전 수업에서 우리는 배열 안에 어떤 형태의 데이터든 다 넣을 수 있다고 배웠습니다. 이 말은 즉, 배열의 알맹이로 '또 다른 배열'을 넣을 수도 있다는 뜻입니다! 그런 배열을 만드는 방법은 아주 간단해요.
int a[3][5]; // 5개의 정수를 가진 배열이 3개 들어있는 배열
이렇게 배열 안에 배열이 들어있는 것을 2차원 배열(two-dimensional array, 줄여서 2d 배열)이라고 부릅니다. 방 번호를 적는 대괄호 []가 두 개 붙어있기 때문이죠.
2차원 배열을 다룰 때는 첫 번째(왼쪽) 괄호가 행(가로줄)을 고르고, 두 번째(오른쪽) 괄호가 열(세로칸)을 고른다고 생각하면 이해하기 아주 쉽습니다. 머릿속으로 이 2차원 배열이 아래와 같은 표 모양으로 펼쳐져 있다고 상상해 보세요.
// 열 0 열 1 열 2 열 3 열 4
// a[0][0] a[0][1] a[0][2] a[0][3] a[0][4] 행 0
// a[1][0] a[1][1] a[1][2] a[1][3] a[1][4] 행 1
// a[2][0] a[2][1] a[2][2] a[2][3] a[2][4] 행 2
2차원 배열의 값을 꺼내거나 바꿀 때는 그냥 괄호 두 개를 써주면 됩니다.
a[2][3] = 7; // a[행][열], 즉 행이 2이고 열이 3인 위치의 값
이제 이 방법을 쓰면 틱택토 게임판도 2차원 배열로 완벽하게 만들 수 있습니다!
int ttt[3][3];
짠! 이제 행과 열 번호를 이용해 아주 쉽게 다룰 수 있는 3x3 모양의 게임판이 완성되었습니다.
차원이 2개 이상인 배열을 통틀어 다차원 배열이라고 부릅니다.
C++는 2차원을 넘어 3차원, 4차원 그 이상의 다차원 배열도 얼마든지 만들 수 있게 해줍니다.
int threedee[4][4][4]; // 4x4x4 배열 (정수 4개가 든 배열 4개가 다시 4개 모인 배열)
예를 들어, 유명한 게임 '마인크래프트'의 지형은 16x16x16 크기의 블록들(청크 섹션이라고 부름)로 나뉘어져 있는데, 이런 3차원 공간을 다룰 때 아주 유용하겠죠.
물론 3차원보다 더 복잡한 배열도 지원하지만, 실제로 쓰이는 일은 거의 없습니다.
컴퓨터의 메모리(램)는 일직선으로 된 1차원 공간입니다. 그래서 다차원 배열이라 할지라도 실제로는 그냥 한 줄로 길게 쭉 늘어서서 저장됩니다.
아래와 같은 2차원 배열이 있을 때, 메모리에 저장하는 방식에는 크게 두 가지가 있습니다.
// 열 0 열 1 열 2 열 3 열 4
// [0][0] [0][1] [0][2] [0][3] [0][4] 행 0
// [1][0] [1][1] [1][2] [1][3] [1][4] 행 1
// [2][0] [2][1] [2][2] [2][3] [2][4] 행 2
C++는 '행 우선 순서(row-major order)'라는 방식을 사용합니다. 이 방식은 위에서부터 한 가로줄(행)씩 차례대로, 왼쪽에서 오른쪽 방향으로 쭉 메모리에 채워 넣는 방식입니다.
[0][0] [0][1] [0][2] [0][3] [0][4] [1][0] [1][1] [1][2] [1][3] [1][4] [2][0] [2][1] [2][2] [2][3] [2][4]
포트란(Fortran) 같은 일부 다른 프로그래밍 언어들은 반대로 '열 우선 순서(column-major order)'를 사용합니다. 이건 세로칸(열)을 먼저 위에서 아래로 다 채우고, 그다음 오른쪽 세로칸으로 넘어가는 방식이죠.
[0][0] [1][0] [2][0] [0][1] [1][1] [2][1] [0][2] [1][2] [2][2] [0][3] [1][3] [2][3] [0][4] [1][4] [2][4]
C++에서 배열을 처음 만들고 값을 채워 넣을 때(초기화할 때), 값들은 바로 이 '행 우선 순서'대로 들어갑니다. 따라서 우리가 코드로 배열의 값들을 하나씩 훑어볼 때도, 컴퓨터 메모리에 저장된 순서(가로줄 먼저)대로 읽어들이는 것이 가장 빠르고 효율적입니다.
2차원 배열에 처음 값을 넣어줄 때는 중괄호 {} 안에 또 중괄호를 넣어서 묶어주는 것이 제일 알아보기 쉽습니다. 안쪽에 있는 중괄호 한 덩어리가 가로줄(행) 하나를 뜻하게 됩니다.
int array[3][5]{
{ 1, 2, 3, 4, 5 }, // 행 0
{ 6, 7, 8, 9, 10 }, // 행 1
{ 11, 12, 13, 14, 15 } // 행 2
};
일부 똑똑한 컴파일러들은 안쪽 중괄호를 생략해도 알아서 처리해주지만, 코드를 읽기 쉽게 만들기 위해 꼭 안쪽 중괄호까지 모두 적어주는 것을 강력히 권장합니다.
안쪽 중괄호를 썼는데 배열의 전체 칸 수보다 값을 적게 넣었다면, 나머지 빈칸들은 알아서 0으로 채워집니다(값 초기화).
int array[3][5]{
{ 1, 2 }, // 행 0 = 1, 2, 0, 0, 0
{ 6, 7, 8 }, // 행 1 = 6, 7, 8, 0, 0
{ 11, 12, 13, 14 } // 행 2 = 11, 12, 13, 14, 0
};
값을 채워 넣으면서 배열을 만들 때는, 오직 가장 왼쪽(첫 번째) 대괄호 안의 숫자만 비워둘 수 있습니다.
int array[][5]{
{ 1, 2, 3, 4, 5 },
{ 6, 7, 8, 9, 10 },
{ 11, 12, 13, 14, 15 }
};
이렇게 하면, 컴퓨터(컴파일러)가 안쪽 중괄호 덩어리 개수를 직접 세어보고 "아, 행이 3개구나!" 하고 알아서 계산해 주기 때문입니다.
하지만 가장 왼쪽이 아닌 다른 대괄호들을 비워두는 것은 허용되지 않습니다. (에러가 나요!)
int array[][]{
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 }
};
당연히 일반 배열처럼 다차원 배열의 모든 칸을 한 번에 0으로 꽉 채울 수도 있습니다. 아주 간단하죠.
int array[3][5] {};
1차원 배열에 들어있는 모든 값을 한 번씩 다 확인하고 싶을 때는 반복문 하나면 충분했습니다.
#include <iostream>
int main()
{
int arr[] { 1, 2, 3, 4, 5 };
// 인덱스를 사용하는 for 반복문
for (std::size_t i{0}; i < std::size(arr); ++i)
std::cout << arr[i] << ' ';
std::cout << '\n';
// 범위 기반 for 반복문
for (auto e: arr)
std::cout << e << ' ';
std::cout << '\n';
return 0;
}
하지만 2차원 배열은 가로와 세로가 있으니 반복문이 2개 필요합니다. 하나는 '행'을 고르고, 다른 하나는 '열'을 골라야 하니까요.
반복문 2개를 겹쳐 쓸 때는, 어떤 것을 '바깥쪽 반복문'으로 하고 어떤 것을 '안쪽 반복문'으로 할지 결정해야 합니다. 방금 전에 컴퓨터가 일하기 편하게 하려면 메모리에 저장된 순서대로 접근하는 것이 가장 효율적이라고 했죠? C++는 가로줄(행)을 먼저 저장하는 '행 우선 순서'를 사용하니까, 가로줄(행)을 고르는 것을 바깥쪽 반복문으로 두고, 세로칸(열)을 고르는 것을 안쪽 반복문으로 두는 것이 가장 좋습니다.
#include <iostream>
int main()
{
int arr[3][4] {
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
};
// 인덱스를 사용하는 이중 for 반복문
for (std::size_t row{0}; row < std::size(arr); ++row) // std::size(arr)는 행의 개수를 반환합니다
{
for (std::size_t col{0}; col < std::size(arr[0]); ++col) // std::size(arr[0])은 열의 개수를 반환합니다
std::cout << arr[row][col] << ' ';
std::cout << '\n';
}
// 범위 기반 이중 for 반복문
for (const auto& arow: arr) // 각 행 배열을 가져옵니다
{
for (const auto& e: arow) // 그 행 안의 각 원소를 가져옵니다
std::cout << e << ' ';
std::cout << '\n';
}
return 0;
}
그럼 이제 2차원 배열을 실제로 어떻게 써먹는지 구구단(곱셈표)을 만드는 예제를 통해 살펴볼까요?
#include <iostream>
int main()
{
constexpr int numRows{ 10 };
constexpr int numCols{ 10 };
// 10x10 배열 선언하기
int product[numRows][numCols]{};
// 구구단 계산하기
// 어떤 수든 0을 곱하면 0이 되니까, 0행과 0열은 굳이 계산할 필요가 없어요.
for (std::size_t row{ 1 }; row < numRows; ++row)
{
for (std::size_t col{ 1 }; col < numCols; ++col)
{
product[row][col] = static_cast<int>(row * col);
}
}
for (std::size_t row{ 1 }; row < numRows; ++row)
{
for (std::size_t col{ 1 }; col < numCols; ++col)
{
std::cout << product[row][col] << '\t';
}
std::cout << '\n';
}
return 0;
}
이 프로그램은 1부터 9까지의 모든 구구단 값을 계산하고 화면에 예쁘게 표 형태로 보여줍니다.
표를 출력할 때 잘 보면, for 반복문이 0이 아니라 1부터 시작한다는 점을 알 수 있어요. 0행과 0열을 그대로 출력하면 화면에 숫자 0만 가득 찰 테니까, 그걸 빼고 출력하려고 일부러 1부터 시작한 거랍니다! 결과는 아래와 같습니다.
1 2 3 4 5 6 7 8 9
2 4 6 8 10 12 14 16 18
3 6 9 12 15 18 21 24 27
4 8 12 16 20 24 28 32 36
5 10 15 20 25 30 35 40 45
6 12 18 24 30 36 42 48 54
7 14 21 28 35 42 49 56 63
8 16 24 32 40 48 56 64 72
9 18 27 36 45 54 63 72 81
수학이나 기하학에서는 물건의 위치를 설명할 때 '데카르트 좌표계'라는 것을 자주 사용합니다. 말이 좀 어렵지만, 중학교 수학 시간에 배운 가로축(x)과 세로축(y)이 있는 그래프를 떠올리면 됩니다! 가로축은 "x", 세로축은 "y"라고 부르는 게 일반적이죠.

2차원 평면에서 어떤 물건의 위치는 { x, y } 쌍으로 표현할 수 있습니다. 여기서 x좌표는 중심에서 오른쪽으로 얼마나 떨어져 있는지, y좌표는 위로 얼마나 올라가 있는지를 나타냅니다. (참고로 컴퓨터 그래픽에서는 종종 y축이 반대로 뒤집혀서, y좌표가 아래쪽으로 얼마나 내려갔는지를 뜻하기도 합니다).
자, 그럼 우리가 배운 C++의 2차원 배열 모양을 다시 살펴볼까요?
// 열 0 열 1 열 2 열 3 열 4
// [0][0] [0][1] [0][2] [0][3] [0][4] 행 0
// [1][0] [1][1] [1][2] [1][3] [1][4] 행 1
// [2][0] [2][1] [2][2] [2][3] [2][4] 행 2
사실 이것도 일종의 2차원 좌표계입니다. 어떤 값의 위치를 [행][열] 로 나타내는 것뿐이죠. (수학 그래프와 달리 세로축 방향이 아래를 향하고 있다는 점이 다릅니다).
수학의 좌표계나 배열의 좌표계, 각자 따로 놓고 보면 이해하기 꽤 쉬운데요. 문제는 수학의 좌표인 { x, y }를 배열의 방 번호인 [행][열]로 바꾸려고 할 때 직관적이지 않아서 헷갈리기 쉽다는 점입니다.
핵심 통찰
여기서 아주 중요한 사실을 하나 알려드릴게요! 수학 좌표계의 'x좌표(가로)'는 배열 시스템에서 '몇 번째 열(column)인지'를 뜻합니다. 반대로 'y좌표(세로)'는 '몇 번째 행(row)인지'를 뜻하죠.
따라서 수학의{ x, y }좌표를 배열에 쓰려면[y][x]순서로 적어줘야 합니다. 우리가 평소에 생각하는 x, y 알파벳 순서와 완전히 반대인 셈이죠!
이런 이유 때문에 코드로 2차원 반복문을 작성하다 보면 아래와 같은 모양이 만들어집니다.
for (std::size_t y{0}; y < std::size(arr); ++y) // 바깥쪽 반복문은 행, 즉 y를 나타냅니다
{
for (std::size_t x{0}; x < std::size(arr[0]); ++x) // 안쪽 반복문은 열, 즉 x를 나타냅니다
std::cout << arr[y][x] << ' '; // y(행)를 먼저 쓰고, 그 다음 x(열)를 써서 값을 꺼냅니다
여기서 배열의 값을 arr[y][x] 순서로 꺼내고 있다는 점을 꼭 기억해 두세요. 알파벳 순서가 거꾸로라서 처음엔 많이 어색하겠지만, 금방 익숙해지실 겁니다!
이전 레슨에서는 C 스타일의 다차원 배열(마치 엑셀 표처럼 가로세로로 늘어선 배열)에 대해 이야기했어요.
// C 스타일의 2차원 배열
int arr[3][4] {
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
};
하지만 아시다시피, 프로그램 전체에서 쓰는 전역 데이터를 저장할 때를 제외하고는 C 스타일 배열은 가급적 피하는 것이 좋습니다.
이번 레슨에서는 안전하고 현대적인 std::array를 사용해 다차원 배열을 어떻게 다루는지 알아볼 거예요. 초보자분들도 이해하기 쉽도록 아주 쉽게 풀어서 설명해 드릴게요!
std::array는 기본적으로 1차원(한 줄짜리) 배열로 만들어져 있어요. 그럼 당연히 "다차원 배열을 위한 전용 표준 클래스는 없나요?"라는 궁금증이 생기겠죠.
정답은... 안타깝게도 없습니다.
2차원 std::array를 만드는 가장 정석적인 방법은 std::array 안에 또 다른 std::array를 넣는 거예요. 마치 상자 안에 상자를 넣는 마트료시카 인형처럼요! 코드로 보면 다음과 같습니다.
std::array<std::array<int, 4>, 3> arr {{ // 이중 중괄호 {{ }} 에 주의하세요
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
}};
여기서 눈여겨볼 만한 점들이 몇 가지 있어요:
std::array에 초기값을 넣어줄 때는 바깥쪽에 이중 중괄호 {{ }}를 사용해야 해요.arr[3][4]라고 생각하는 게 자연스럽죠. 하지만 std::array<std::array<int, 4>, 3>에서는 숫자의 순서가 반대로 되어 있습니다. (안쪽 배열이 4칸짜리이고, 그 묶음이 총 3줄 있다는 뜻이에요.)요소를 꺼내 쓰는(인덱싱) 방법은 C 스타일 2차원 배열과 똑같습니다:
std::cout << arr[1][2]; // 1번 행(두 번째 줄), 2번 열(세 번째 칸)의 요소를 출력합니다
또한 1차원 배열처럼 함수에 통째로 넘겨줄 수도 있어요:
#include <array>
#include <iostream>
template <typename T, std::size_t Row, std::size_t Col>
void printArray(const std::array<std::array<T, Col>, Row> &arr)
{
for (const auto& arow: arr) // 배열의 각 행(가로줄)을 가져옵니다
{
for (const auto& e: arow) // 해당 행의 각 요소를 가져옵니다
std::cout << e << ' ';
std::cout << '\n';
}
}
int main()
{
std::array<std::array<int, 4>, 3> arr {{
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
}};
printArray(arr);
return 0;
}
으악, 괄호가 너무 많아서 눈이 아프죠? 이건 겨우 2차원일 뿐이에요. 3차원, 4차원으로 넘어가면 코드는 훨씬 더 길고 끔찍해집니다!
이전 레슨에서 '타입 별칭(Type alias)'을 배우면서, 복잡한 타입에 짧고 쉬운 별명을 붙여줄 수 있다고 했었죠. 하지만 일반적인 타입 별칭을 쓰면 아래처럼 템플릿에 들어갈 모든 정보를 하나하나 고정해서 적어줘야 합니다.
using Array2dint34 = std::array<std::array<int, 4>, 3>;
이렇게 하면 3x4 크기의 2차원 정수 배열이 필요할 때마다 Array2dint34라는 이름을 쓸 수 있어요. 하지만 크기나 데이터 종류가 바뀔 때마다 이런 별칭을 매번 새로 만들어야 한다면 너무 귀찮겠죠!
이럴 때 별칭 템플릿(alias template)을 쓰면 아주 완벽합니다! 별칭을 만들 때 저장할 데이터의 종류(타입), 행(가로줄)의 개수, 열(세로줄)의 개수를 우리가 템플릿의 재료로 직접 지정해줄 수 있거든요.
// 2차원 std::array를 위한 별칭 템플릿
template <typename T, std::size_t Row, std::size_t Col>
using Array2d = std::array<std::array<T, Col>, Row>;
이제 3x4 크기의 2차원 정수 배열이 필요하면 언제 어디서든 그냥 Array2d<int, 3, 4>라고 쓰면 됩니다. 훨씬 깔끔해졌죠!
전체 예제 코드를 볼까요?
#include <array>
#include <iostream>
// 2차원 std::array를 위한 별칭 템플릿
template <typename T, std::size_t Row, std::size_t Col>
using Array2d = std::array<std::array<T, Col>, Row>;
// Array2d를 함수 매개변수로 사용할 때는 템플릿 매개변수를 다시 지정해 주어야 합니다
template <typename T, std::size_t Row, std::size_t Col>
void printArray(const Array2d<T, Row, Col> &arr)
{
for (const auto& arow: arr) // 배열의 각 행을 가져옵니다
{
for (const auto& e: arow) // 해당 행의 각 요소를 가져옵니다
std::cout << e << ' ';
std::cout << '\n';
}
}
int main()
{
// 3행 4열의 2차원 정수 배열을 정의합니다
Array2d<int, 3, 4> arr {{
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
}};
printArray(arr);
return 0;
}
코드가 얼마나 짧고 쓰기 편해졌는지 확인해 보세요!
이 별칭 템플릿의 또 다른 멋진 점은 우리가 원하는 순서대로 규칙을 정할 수 있다는 거예요. 원래 std::array는 '타입'을 먼저 쓰고 '크기'를 쓰기 때문에 그 규칙은 따랐습니다. 하지만 행(Row)과 열(Col) 중 무엇을 먼저 쓸지는 우리 마음이에요. C 스타일 배열이 행을 먼저 쓰는 방식이므로, 우리도 덜 헷갈리게 행(Row)을 열(Col)보다 먼저 쓰도록 만들었습니다.
이 방식은 3차원 이상의 복잡한 배열로 확장할 때도 아주 유용합니다.
// 3차원 std::array를 위한 별칭 템플릿
template <typename T, std::size_t Row, std::size_t Col, std::size_t Depth>
using Array3d = std::array<std::array<std::array<T, Depth>, Col>, Row>;
1차원 배열에서는 .size() 함수를 써서 배열의 길이를 쉽게 알 수 있었죠. 그런데 2차원 배열에서는 어떨까요? 이 경우 그냥 .size()를 호출하면 첫 번째 차원(보통 행의 개수인 3)의 길이만 달랑 알려줍니다.
이럴 때 원하는 차원의 요소를 하나 콕 집어낸 다음, 거기에 대고 .size()를 부르면 어떨까 하고 솔깃한 생각이 들 수 있어요. (하지만 꽤 위험한 방법이랍니다!)
#include <array>
#include <iostream>
// 2차원 std::array를 위한 별칭 템플릿
template <typename T, std::size_t Row, std::size_t Col>
using Array2d = std::array<std::array<T, Col>, Row>;
int main()
{
// 3행 4열의 2차원 정수 배열을 정의합니다
Array2d<int, 3, 4> arr {{
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
}};
std::cout << "Rows: " << arr.size() << '\n'; // 첫 번째 차원(행)의 길이를 구합니다
std::cout << "Cols: " << arr[0].size() << '\n'; // 두 번째 차원(열)의 길이를 구합니다. 만약 첫 번째 차원의 길이가 0이라면 정의되지 않은 동작(오류)이 발생합니다!
return 0;
}
첫 번째 차원(행)의 길이를 알기 위해 배열 전체에 .size()를 불렀습니다. 두 번째 차원(열)의 길이를 알려면, 먼저 arr[0]으로 첫 번째 줄을 꺼내온 다음 거기에 .size()를 부른 거죠. 3차원 배열이라면 arr[0][0].size()라고 했을 거예요.
하지만 위 코드는 큰 문제가 하나 있습니다. 만약 마지막 차원을 제외한 다른 차원의 길이가 0일 경우, 없는 걸 꺼내오려다 프로그램이 꼬여버리는 '미정의 동작(undefined behavior)'이 발생하게 됩니다!
더 안전하고 좋은 방법은, 우리가 템플릿을 만들 때 넘겨준 숫자에서 직접 길이를 가져오는 전용 함수를 따로 만드는 거예요.
#include <array>
#include <iostream>
// 2차원 std::array를 위한 별칭 템플릿
template <typename T, std::size_t Row, std::size_t Col>
using Array2d = std::array<std::array<T, Col>, Row>;
// Row 매개변수에서 행의 개수를 가져옵니다
template <typename T, std::size_t Row, std::size_t Col>
constexpr int rowLength(const Array2d<T, Row, Col>&) // 원한다면 std::size_t를 반환해도 됩니다
{
return Row;
}
// Col 매개변수에서 열의 개수를 가져옵니다
template <typename T, std::size_t Row, std::size_t Col>
constexpr int colLength(const Array2d<T, Row, Col>&) // 원한다면 std::size_t를 반환해도 됩니다
{
return Col;
}
int main()
{
// 3행 4열의 2차원 정수 배열을 정의합니다
Array2d<int, 3, 4> arr {{
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
}};
std::cout << "Rows: " << rowLength(arr) << '\n'; // 첫 번째 차원(행)의 길이를 구합니다
std::cout << "Cols: " << colLength(arr) << '\n'; // 두 번째 차원(열)의 길이를 구합니다
return 0;
}
이렇게 하면 배열의 실제 데이터가 아니라 배열의 '타입 정보'만 보고 길이를 알아내기 때문에, 길이가 0이어도 오류가 나지 않아요. 또한 굳이 강제로 형변환할 필요 없이 배열의 길이를 우리에게 익숙한 int 자료형으로 쉽게 받아올 수 있다는 장점도 있습니다.
차원이 2개 이상인 다차원 배열은 초보자뿐만 아니라 숙련자에게도 다루기 꽤 까다롭습니다:
for문도 그 안에 겹겹이 계속 추가해야 하거든요).다차원 배열을 좀 더 쉽게 다루는 꿀팁 중 하나는 바로 배열을 납작하게 펴는 것(flatten)입니다. 여러 방향으로 표처럼 늘어선 배열을 그냥 하나의 긴 1차원 배열(한 줄)로 줄여버리는 과정을 말해요.
예를 들어, 가로줄(Row)과 세로줄(Col)을 가진 2차원 배열을 억지로 만드는 대신, 단순히 Row * Col개의 요소를 가진 기다란 1차원 배열 하나를 만드는 거예요. 공간은 똑같이 차지하면서 형태만 단순해지는 거죠.
하지만 1차원 배열은 한 줄짜리라서 그 자체만으로는 2차원처럼 행렬 좌표를 써서 다룰 수는 없습니다. 이를 해결하기 위해, 겉보기엔 2차원 배열처럼 쓸 수 있게 해주는 '도구(인터페이스)'를 만들어 덮어씌울 수 있어요. 사용자가 가로, 세로 좌표를 입력하면, 이 도구가 알아서 1차원 배열의 알맞은 위치를 계산해 연결해 주는 원리입니다.
C++11 이상의 버전에서 이 방법을 어떻게 쓰는지 예제로 확인해 볼까요?
#include <array>
#include <iostream>
#include <functional>
// 두 개의 차원을 사용해 1차원 std::array를 정의할 수 있게 해주는 별칭 템플릿
template <typename T, std::size_t Row, std::size_t Col>
using ArrayFlat2d = std::array<T, Row * Col>;
// ArrayFlat2d를 2차원처럼 다룰 수 있게 해주는 수정 가능한 뷰(view)
// 이것은 뷰(보기 창)이므로, 대상이 되는 ArrayFlat2d가 메모리에 살아있어야 합니다
template <typename T, std::size_t Row, std::size_t Col>
class ArrayView2d
{
private:
// m_arr을 ArrayFlat2d에 대한 참조자(&)로 만들고 싶을 수 있지만,
// 참조자는 한 번 연결되면 대상을 바꿀 수 없어서 복사 할당이 불가능해집니다.
// std::reference_wrapper를 사용하면 참조처럼 작동하면서도 복사 할당이 가능해집니다.
std::reference_wrapper<ArrayFlat2d<T, Row, Col>> m_arr {};
public:
ArrayView2d(ArrayFlat2d<T, Row, Col> &arr)
: m_arr { arr }
{}
// 단일 인덱스로 요소 가져오기 (operator[] 사용)
T& operator[](int i) { return m_arr.get()[static_cast<std::size_t>(i)]; }
const T& operator[](int i) const { return m_arr.get()[static_cast<std::size_t>(i)]; }
// 2차원 인덱스로 요소 가져오기 (C++23 이전에는 operator[]가 다중 차원을 지원하지 않아 operator()를 사용함)
T& operator()(int row, int col) { return m_arr.get()[static_cast<std::size_t>(row * cols() + col)]; }
const T& operator()(int row, int col) const { return m_arr.get()[static_cast<std::size_t>(row * cols() + col)]; }
// C++23부터는 다차원 operator[]가 지원되므로 아래 주석을 해제하고 사용할 수 있습니다
// T& operator[](int row, int col) { return m_arr.get()[static_cast<std::size_t>(row * cols() + col)]; }
// const T& operator[](int row, int col) const { return m_arr.get()[static_cast<std::size_t>(row * cols() + col)]; }
int rows() const { return static_cast<int>(Row); }
int cols() const { return static_cast<int>(Col); }
int length() const { return static_cast<int>(Row * Col); }
};
int main()
{
// 3행 4열 크기를 가지는 1차원 std::array 정의
ArrayFlat2d<int, 3, 4> arr {
1, 2, 3, 4,
5, 6, 7, 8,
9, 10, 11, 12 };
// 1차원 배열을 2차원처럼 보여주는 뷰 정의
ArrayView2d<int, 3, 4> arrView { arr };
// 배열의 크기 출력
std::cout << "Rows: " << arrView.rows() << '\n';
std::cout << "Cols: " << arrView.cols() << '\n';
// 1차원 방식으로 배열 출력
for (int i=0; i < arrView.length(); ++i)
std::cout << arrView[i] << ' ';
std::cout << '\n';
// 2차원 방식으로 배열 출력
for (int row=0; row < arrView.rows(); ++row)
{
for (int col=0; col < arrView.cols(); ++col)
std::cout << arrView(row, col) << ' ';
std::cout << '\n';
}
std::cout << '\n';
return 0;
}
이 코드를 실행하면 다음과 같이 나옵니다:
Rows: 3
Cols: 4
1 2 3 4 5 6 7 8 9 10 11 12
1 2 3 4
5 6 7 8
9 10 11 12
C++23 이전 버전에서는 대괄호 [] 안에 숫자를 하나만 넣을 수 있기 때문에, 2차원 느낌을 내기 위해 보통 두 가지 우회 방법을 씁니다:
()를 사용합니다. 소괄호 안에는 숫자를 여러 개 받을 수 있거든요. 이렇게 하면 1차원으로 접근할 땐 []로, 다차원 좌표로 접근할 땐 ()로 구분해서 쓸 수 있습니다. 위 예제에서 우리가 선택한 방법이기도 해요.[]를 썼을 때 하위 뷰(임시 객체)를 반환하게 해서 [][]처럼 대괄호를 연달아 이어 쓰게 만드는 방법입니다. 하지만 이건 구조가 너무 복잡해지고 차원이 높아지면 골치 아파져요.다행히 C++23부터는 대괄호 [] 안에 쉼표로 인덱스를 여러 개 넣을 수 있게 규칙이 업그레이드되었습니다. 이제 굳이 소괄호 ()를 쓰지 않아도 [] 하나로 깔끔하게 처리할 수 있게 된 거죠.
관련 컨텐츠
std::reference_wrapper에 대한 자세한 내용은 레슨 17.5 -- std::reference_wrapper를 통한 참조 배열에서 다룹니다.
C++23에서 새롭게 도입된 std::mdspan은 메모리에 일렬로 나란히 붙어있는 데이터들을 마치 다차원 배열처럼 다룰 수 있게 해주는 멋진 '수정 가능한 뷰'입니다. 여기서 '수정 가능한 뷰'라는 말은 단순히 데이터 내용만 훔쳐보는 읽기 전용 뷰(std::string_view 같은 것)가 아니라, 원본 데이터가 변경 가능하다면 이 뷰를 통해서 데이터의 내용도 바꿀 수 있다는 뜻이에요.
아래 예제는 바로 위에서 살펴본 예제와 똑같은 결과를 내지만, 우리가 복잡하게 직접 만든 뷰 클래스 대신 C++ 표준 라이브러리의 std::mdspan을 사용했어요.
#include <array>
#include <iostream>
#include <mdspan>
// 두 개의 차원을 사용해 1차원 std::array를 정의할 수 있게 해주는 별칭 템플릿
template <typename T, std::size_t Row, std::size_t Col>
using ArrayFlat2d = std::array<T, Row * Col>;
int main()
{
// 3행 4열 크기를 가지는 1차원 std::array 정의
ArrayFlat2d<int, 3, 4> arr {
1, 2, 3, 4,
5, 6, 7, 8,
9, 10, 11, 12 };
// 1차원 배열을 연결하는 2차원 스팬(span) 정의
// std::mdspan에는 데이터가 있는 메모리 주소(포인터)를 넘겨주어야 합니다.
// std::array나 std::vector의 data() 함수를 쓰면 이 주소를 얻을 수 있습니다.
std::mdspan mdView { arr.data(), 3, 4 };
// 배열의 크기 출력
// std::mdspan에서는 이것을 extents(범위)라고 부릅니다.
std::size_t rows { mdView.extents().extent(0) };
std::size_t cols { mdView.extents().extent(1) };
std::cout << "Rows: " << rows << '\n';
std::cout << "Cols: " << cols << '\n';
// 1차원 방식으로 출력
// data_handle() 함수를 쓰면 데이터 배열의 시작 주소를 얻을 수 있고,
// 이를 이용해 요소에 접근할 수 있습니다.
for (std::size_t i=0; i < mdView.size(); ++i)
std::cout << mdView.data_handle()[i] << ' ';
std::cout << '\n';
// 2차원 방식으로 출력
// 다차원 [] 연산자를 사용해 요소에 접근합니다.
for (std::size_t row=0; row < rows; ++row)
{
for (std::size_t col=0; col < cols; ++col)
std::cout << mdView[row, col] << ' ';
std::cout << '\n';
}
std::cout << '\n';
return 0;
}
코드가 직관적이라 이해하기 쉬우실 거예요! 그래도 몇 가지 핵심만 짚고 넘어가 볼까요:
std::mdspan을 사용하면 우리가 원하는 만큼 차원의 개수를 쭉 늘려서 뷰를 만들 수 있어요.std::mdspan을 만들 때 첫 번째로 넣어주는 값은 배열 데이터가 모여있는 메모리의 시작 주소(포인터)여야 합니다. 구형 C 스타일 배열이라면 그냥 배열 이름을 쓰면 되고, std::array나 std::vector라면 .data() 함수를 호출해서 이 주소를 쉽게 가져올 수 있습니다.std::mdspan을 긴 1차원처럼 다루고 싶다면, .data_handle() 함수를 써서 포인터를 가져온 다음 거기에 []를 붙여 사용해야 해요.[row][col]처럼 두 번 쓰지 않고 [row, col] 형식으로 세련되게 접근합니다.참고로, 다가오는 C++26 버전에서는 std::array와 std::mdspan의 장점만을 합쳐서 아예 메모리 관리까지 알아서 해주는 완벽한 std::mdarray라는 기능이 추가될 예정이랍니다!
std::array는 모두 고정 크기 배열입니다. 런타임에 크기를 조절할 수 있는 배열은 동적 배열(dynamic arrays)이라고 하며, std::vector가 이에 해당합니다.std::array의 길이는 반드시 상수 표현식(constant expression)이어야 합니다. 주로 정수 리터럴, constexpr 변수, 또는 범위 없는 열거자(unscoped enumerator)가 길이 값으로 제공됩니다.std::array는 집계형(aggregate)입니다. 즉, 생성자가 없으며 대신 '집계 초기화(aggregate initialization)'를 사용하여 초기화됩니다.std::array를 constexpr로 정의하세요. constexpr로 만들 수 없는 상황이라면 대신 std::vector의 사용을 고려해 보는 것이 좋습니다.std::array의 타입과 길이를 알아서 유추하게 할 수 있습니다.std::array는 다음과 같이 선언된 템플릿 구조체(struct)로 구현되어 있습니다:
template<typename T, std::size_t N> // N은 비타입(non-type) 템플릿 매개변수입니다
struct array;
N)의 타입은 std::size_t입니다.std::array의 길이를 구하는 방법:size() 멤버 함수를 사용하여 길이를 구할 수 있습니다 (부호 없는 size_type으로 반환됨).std::size() 비멤버(non-member) 함수를 사용할 수 있습니다 (내부적으로 size() 멤버 함수를 호출하여 동일하게 반환함).std::ssize() 비멤버 함수를 사용할 수 있으며, 이 함수는 부호 있는(signed) 큰 정수형(주로 std::ptrdiff_t)으로 길이를 반환합니다.constexpr 값을 반환합니다. (이 예외적인 결함은 C++23의 P2280에서 수정되었습니다.)std::array 인덱싱(요소 접근) 방법:operator[]) 사용: 경계 검사(bounds checking)를 하지 않으므로, 유효하지 않은 인덱스를 넣으면 미정의 동작(undefined behavior)이 발생합니다.at() 멤버 함수 사용: 런타임에 경계 검사를 수행합니다. 하지만 보통 인덱싱 전에 미리 경계 검사를 하거나 컴파일 타임에 검사하는 것을 원하므로, 이 함수는 가급적 피하는 것을 권장합니다.std::get() 함수 템플릿 사용: 인덱스를 비타입 템플릿 인수로 받으며, 컴파일 타임에 경계 검사를 수행합니다.template <typename T, std::size_t N> (또는 C++20의 template <typename T, auto N>) 형태의 매개변수를 가진 함수 템플릿을 사용하면, 요소 타입과 길이가 제각각인 다양한 std::array를 함수에 전달할 수 있습니다.std::array를 '값으로 반환(return by value)'하면 배열과 모든 요소가 복사됩니다. 배열의 크기가 작고 요소 복사 비용이 저렴하다면 괜찮지만, 상황에 따라서는 출력 매개변수(out parameter)를 사용하는 것이 더 나은 선택일 수 있습니다.std::array를 초기화할 때, 각 초기화 값에 요소 타입을 명시하지 않으면 컴파일러가 올바르게 해석할 수 있도록 한 쌍의 중괄호를 추가로 씌워주어야 합니다 ({{ }} 형태). 이는 집계 초기화의 특성 때문이며, 리스트 생성자를 사용하는 다른 표준 라이브러리 컨테이너들은 이런 추가 중괄호가 필요하지 않습니다.std::array를 초기화하거나, 각 요소의 타입이 명시적으로 지정된 클래스/배열로 초기화할 때는 중괄호를 생략할 수 있습니다.std::reference_wrapper의 배열은 만들 수 있습니다.std::reference_wrapper에 대해 알아둘 점:operator=)를 사용하면 참조하는 대상을 다른 객체로 변경(reseat)할 수 있습니다.std::reference_wrapper<T>는 T&로 암시적 변환됩니다.get() 멤버 함수를 사용해 T&를 가져올 수 있으며, 참조 중인 객체의 값을 업데이트할 때 유용합니다.std::ref()와 std::cref() 함수를 사용하면 std::reference_wrapper 객체를 더 쉽게 생성할 수 있습니다.constexpr std::array가 올바른 개수의 초기화 값을 가졌는지 확인하려면 항상 static_assert를 활용하세요.[])를 사용하여 컴파일러에게 해당 객체가 C 스타일 배열임을 알립니다. 대괄호 안에는 배열의 길이를 나타내는 std::size_t 타입의 정수 값을 선택적으로 적을 수 있으며, 이 길이는 반드시 상수 표현식이어야 합니다.operator[]로 인덱싱할 수 있습니다. 이때 인덱스로 부호 있는 정수, 부호 없는 정수, 또는 범위 없는 열거형을 모두 사용할 수 있습니다. 덕분에 C 스타일 배열은 표준 라이브러리 컨테이너들이 겪는 부호 변환 관련 인덱싱 문제를 겪지 않습니다!const나 constexpr로 선언할 수 있습니다.std::size_t로 길이를 반환하는 std::size() 비멤버 함수를 사용합니다.std::ssize() 비멤버 함수를 사용합니다.ptr이 있을 때, ptr + 1은 (가리키는 타입의 크기에 맞춰) 메모리상에 있는 그다음 객체의 주소를 반환합니다.[])를 사용하고, 특정 요소부터 상대적인 위치를 찾을 때는 포인터 연산을 사용하는 것이 좋습니다.char 또는 const char인 C 스타일 배열일 뿐입니다. 따라서 C 스타일 문자열도 배열 붕괴가 발생합니다.std::mdspan은 메모리에 연속적으로 나열된 요소들을 다차원 배열처럼 다룰 수 있게 해주는 뷰(view)입니다.