배열은 같은 타입의 요소들이 메모리에 나란히 놓인, 크기가 고정된 블록입니다. 이 연속된 배치가 이야기의 전부입니다. 인덱싱이 즉시 이루어지는 이유도, 배열과 포인터가 그토록 밀접한 이유도, C가 0번 요소의 주소만 건네주고 나머지는 산술로 찾게 하는 이유도 모두 여기서 나옵니다.
배열 선언하기
요소 타입, 이름, 그리고 대괄호 안의 크기입니다.
int scores[5]; // int 5개
double prices[100]; // double 100개
char initials[3]; // char 3개
크기는 요소의 개수이며, 배열이 선언되는 지점에서 알 수 있어야 합니다. 보통은 리터럴이거나 #define으로 정의한 상수입니다. 메모리는 그 즉시 확보되고, 나중에 따로 할당되는 것은 아무것도 없습니다.
방금 선언한 지역 배열에는 0이 아니라 쓰레기 값이 들어 있습니다.
두 번째 줄은 0을 출력할 수도, 거대한 숫자를 출력할 수도 있고, 실행할 때마다 또는 디버그 빌드와 릴리스 빌드 사이에서 달라질 수도 있습니다. 그 값을 읽는 것 자체가 미정의 동작이니, 읽기 전에 초기화하세요.
초기화
여러 가지 형태가 있고, 모두 쓸모가 있습니다.
기억해 둘 관용구는 {0}입니다. 나열하지 않은 요소는 모두 0으로 초기화되므로, 0 하나로 배열 전체가 0이 됩니다. 지정 초기화자([4] = 99)를 쓰면 특정 위치만 설정하고 나머지는 0으로 둘 수 있는데, 대부분이 비어 있는 조회 테이블에 유용합니다.
한 가지 할 수 없는 일은 선언한 뒤에 배열을 대입하는 것입니다.
int a[3] = {1, 2, 3};
int b[3];
b = a; // 오류: 배열은 대입할 수 없음
memcpy(b, a, sizeof a); // 복사는 이렇게 합니다
인덱스는 0부터 시작합니다
첫 번째 요소는 arr[0]이고 마지막은 arr[n-1]입니다. arr[n]은 존재하지 않습니다.
마지막 반복문을 실행해 주소를 보세요. 각각이 바로 앞의 것보다 정확히 sizeof(int) 바이트 뒤에 있습니다. 요소들은 정말로 끝에서 끝으로 이어져 놓여 있습니다.
인덱스 0 1 2 3 4
+--------+--------+--------+--------+--------+
값 | 88 | 95 | 75 | 60 | 100 |
+--------+--------+--------+--------+--------+
오프셋 +0 +4 +8 +12 +16 바이트
^
&scores[0], 이것이 `scores`가 변환되는 값이기도 합니다
0부터 시작하는 인덱싱은 임의로 정한 규칙이 아닙니다. 인덱스를 시작 지점으로부터의 오프셋으로 만들어 주는데, 이것이 바로 주소 산술이 필요로 하는 형태입니다. scores[i]는 *(scores + i)로 정의되어 있습니다. 포인터와 배열을 참고하세요.
길이 구하기
C는 배열의 길이를 실행 시점에 어디에도 저장하지 않지만 컴파일러는 알고 있으며, sizeof가 그 정보에 접근할 길을 줍니다.
sizeof(arr) / sizeof(arr[0])이 바로 그 관용구입니다. sizeof(double)처럼 하드코딩하지 않고 arr[0]으로 나누면, 요소 타입을 바꿔도 이 줄은 계속 동작합니다.
함정은 이것이 배열이 선언된 자리에서만 통한다는 점입니다. 배열을 함수에 넘기면 매개변수는 포인터이므로 sizeof는 포인터의 크기, 보통 8바이트를 재고, "길이"는 1이나 2가 나옵니다. C에서 배열을 받는 모든 함수가 개수도 함께 받는 이유가 여기에 있습니다.
배열 순회하기
표준적인 형태는 0에서 시작해 i < n인 동안 도는 for 반복문입니다.
i <= n이 아니라 i < n인 점에 주목하세요. 요소가 n개일 때 유효한 인덱스는 0부터 n - 1까지이므로, <=는 한 번 더 돌면서 끝을 넘어 읽습니다. hottest와 coldest를 0이 아니라 temps[0]으로 시작하는 것도 따라 할 만한 습관입니다. 최댓값을 0에서 시작하면 데이터가 전부 음수일 때 조용히 틀린 답을 내놓습니다.
범위를 벗어나면 미정의 동작입니다
다른 언어에서 넘어온 사람들이 가장 놀라는 부분입니다. 경계 검사가 없습니다. 컴파일 시점에도, 실행 시점에도 없습니다. 요소 5개짜리 배열의 arr[10]은 오류가 아니라, 컴파일러가 아무 말 없이 수행하는 주소 계산일 뿐입니다.
int arr[5] = {1, 2, 3, 4, 5};
arr[7] = 99; // 배열 끝에서 8바이트 뒤에 씀
int x = arr[-1]; // 시작 지점 앞을 읽음
그다음 벌어지는 일은 미정의 동작이며, 증상은 야속할 만큼 제각각입니다.
- 건드린 메모리가 쓰이지 않던 곳이라 잘 동작하는 것처럼 보입니다.
- 눈에 보이는 이유 없이 다른 변수의 값이 바뀝니다.
- 프로그램이 세그멘테이션 폴트로 크래시하는데, 한참 뒤에 그럴 수도 있습니다.
-O0과-O2에서 동작이 달라집니다.
"잘 동작하는 것처럼 보인다"가 가장 위험합니다. 버그가 그대로 출시된다는 뜻이니까요. 세 가지 방어책이 있습니다.
i < n으로 반복하세요. 대부분의 범위 초과는 반복문 조건의 off-by-one(하나 차이) 오류입니다.- 입력에서 들어온 인덱스는 검증하세요.
- 개발 중에는 새니타이저를 켜고 빌드하세요.
gcc -fsanitize=address -g prog.c는 대부분의 범위 초과를 파일, 줄 번호, 해당 배열까지 짚어 주는 즉각적인 보고로 바꿔 줍니다.
다른 타입의 배열
같은 문법이 구조체를 포함한 모든 요소 타입에 그대로 통합니다.
char word[6] = "hello"; 줄은 잠시 멈춰 볼 만합니다. C의 문자열은 마지막 유효 바이트가 '\0'인 char 배열일 뿐입니다. 글자 다섯 개에 칸이 여섯 개 필요한 이유가 바로 그 종료 문자입니다.
가변 길이 배열, 간단히
C99는 크기가 실행 시점 값인 배열을 허용합니다.
int n = get_count();
int buffer[n]; // 가변 길이 배열
주의할 점이 둘 있습니다. 배열이 만들어지고 나면 크기는 고정되므로 VLA는 늘어나는 리스트가 아닙니다. 그리고 메모리가 스택에서 오기 때문에, n이 크거나 공격자가 조종할 수 있는 값이면 스택을 넘치게 해 프로그램을 죽일 수 있습니다. 리눅스 커널을 비롯한 많은 프로젝트가 VLA를 아예 금지하는 이유입니다. 게다가 C11 구현에서는 지원 여부가 선택 사항입니다.
크기를 실행 시점까지 정말로 알 수 없다면 대신 할당하세요.
arr이 배열이든 할당된 메모리를 가리키는 포인터든 arr[i]는 똑같이 읽힌다는 점에 주목하세요. 인덱싱 문법은 그 차이를 신경 쓰지 않습니다. 자세한 내용은 동적 메모리에 있습니다.
자주 묻는 질문
C에서 배열은 어떻게 선언하나요?
요소 타입, 이름, 그리고 대괄호 안의 크기를 씁니다. int scores[5];는 int 다섯 개가 들어갈 공간을 확보합니다. 크기는 배열이 선언되는 지점에서 알 수 있는 상수여야 하며(가변 길이 배열은 예외), 값을 직접 주지 않으면 요소들은 초기화되지 않은 상태로 시작합니다.
C에서 배열의 길이는 어떻게 구하나요?
sizeof(arr) / sizeof(arr[0])입니다. 전체 바이트 크기를 요소 하나의 크기로 나눈 값이죠. 이 방법은 배열이 선언된 범위 안에서만 통합니다. 배열을 함수에 넘기는 순간 포인터로 변환(decay)되기 때문에, 그 뒤로 sizeof는 포인터의 크기를 재게 됩니다.
C에서 배열의 끝을 넘어가면 어떻게 되나요?
아무것도 막아주지 않습니다. C는 경계 검사를 전혀 하지 않으므로, 요소 5개짜리 배열에 arr[10]을 쓰면 그 자리에 있는 메모리를 그대로 읽거나 씁니다. 이는 미정의 동작입니다. 쓰레기 값을 출력할 수도, 다른 변수를 망가뜨릴 수도, 크래시가 날 수도 있으며, 망가지기 전까지는 잘 동작하는 것처럼 보일 수도 있습니다.
C에서 배열의 모든 요소를 0으로 초기화하려면 어떻게 하나요?
int arr[100] = {0};입니다. 나열하지 않은 요소는 모두 0으로 초기화되므로 0 하나가 배열 전체를 채웁니다. C23에서는 int arr[100] = {};도 됩니다. 초기화자를 아예 주지 않으면 지역 배열의 내용은 알 수 없는 쓰레기 값입니다.