배열과 포인터의 차이
100분 안팎
학습 목표
배열 길이를 인자로 전달하고 NULL과 경계 밖 인덱스를 거부합니다.
개념
주소만으로는 기록 건수를 알 수 없습니다
센서 값 세 개를 int a[3]에 저장했다고 가정합니다. 배열은 같은 자료형의 객체들이 순서대로 배치된 공간입니다. int *p = a;는 첫 원소의 주소를 포인터 변수에 담습니다. a와 p가 같은 원소를 읽게 해 준다고 해서 두 객체가 같아지는 것은 아닙니다. 배열 자체는 세 칸의 저장 공간이고 포인터 자체는 주소를 담는 별도 칸입니다. 함수가 주소만 받으면 그 뒤에 몇 칸이 있는지 자동으로 알아낼 수 없습니다.
실습의 sample_at은 const int *values, size_t length, size_t index, int *out을 받습니다. 호출자는 values가 length개의 실제 int 객체를 가리키도록 제공해야 합니다. 함수는 그 약속 안에서 index가 length 미만인지 확인합니다. 길이를 실제보다 크게 전달하면 함수 안의 비교가 있어도 안전하지 않습니다. 주소와 길이를 같이 전달하는 것은 정확한 길이를 만들어 전달할 책임을 없애는 장치가 아닙니다.
배열을 선언한 곳에서는 sizeof a / sizeof a[0]으로 원소 개수를 계산합니다. 함수를 호출할 때 a는 대부분 첫 원소의 주소로 변환됩니다. 매개변수를 int values[]라고 적어도 함수 안에서는 포인터 매개변수로 조정되므로 sizeof values로 원래 배열 용량을 얻을 수 없습니다. 계산은 배열을 실제로 가진 위치에서 하고 size_t n을 함수에 함께 넘깁니다. 이 실습은 원소 수를 받으며 바이트 수를 받지 않습니다.
허용 구간을 먼저 검사합니다
길이 3인 배열의 읽을 수 있는 인덱스는 0, 1, 2입니다. index == length인 3은 마지막 원소가 아니라 마지막 뒤의 위치입니다. 따라서 index >= length를 거부합니다. index > length만 거부하면 3이 통과하므로 잘못된 메모리를 읽습니다. length가 0이면 index 0도 거부합니다. 빈 배열 계약을 따로 예외 처리하지 않아도 같은 비교가 모든 읽기를 막습니다.
함수의 첫 문장은 if (!values || !out || index >= length) return 0;입니다. 검사 이후 *out = values[index];를 실행하고 1을 반환합니다. NULL 주소로 읽기나 쓰기를 먼저 시도한 뒤 실패를 처리할 수는 없습니다. short-circuit 논리 연산으로 앞의 조건부터 평가합니다. 다만 NULL이 아니라는 사실만으로 해당 주소가 유효한 배열인지 증명되는 것은 아니므로 호출자의 수명 계약도 필요합니다.
거부했을 때 out을 0으로 만드는 구현은 여기의 계약과 다릅니다. 0은 정상 센서 값이고, 기존 출력 객체를 변경하면 실패와 성공을 혼동할 수 있습니다. 테스트는 out을 88로 두고 인덱스 3을 요청한 다음 반환값 0과 out 88을 모두 비교합니다. 성공 상태와 데이터 값은 두 개의 독립된 확인 대상입니다. 반환값을 if로 확인한 뒤 성공 분기에서만 out을 새 값으로 취급합니다.
size_t는 부호 없는 형입니다. -1을 size_t 인자로 옮기면 큰 값이 될 수 있습니다. 이런 index는 length보다 크므로 거부되어야 합니다. 하지만 함수 밖에서 int 입력을 받고 size_t로 바꾸는 파서에는 음수 검사가 별도로 필요합니다. signed와 unsigned 비교 경고가 나면 경고를 끄지 말고 변수의 의미와 변환 위치를 확인합니다. 이 실습은 음수 표기를 받아들이는 사용자 파서 대신 이미 정해진 인덱스 객체로 테스트합니다.
포인터 연산을 원소 단위로 읽습니다
values[index]는 *(values + index)로 같은 원소를 읽는 표현입니다. int 포인터에 1을 더하면 int 한 원소만큼 이동합니다. 주소의 숫자를 직접 1 증가시키는 것과 다릅니다. 반복문은 size_t i = 0에서 시작하고 i < length를 동안 진행합니다. 길이가 3이라면 세 번만 실행됩니다. <= length는 네 번째 읽기를 추가하므로 작은 배열에서도 경계 오류가 됩니다.
실제 배열 안과 끝 바로 뒤까지의 포인터를 만드는 규칙과, 그 포인터를 역참조해 값을 읽는 규칙은 다릅니다. a + 3은 이 세 칸 배열의 끝 뒤 위치로 만들 수 있지만 *(a + 3)은 읽을 원소가 없습니다. 끝 포인터를 쓰는 반복문이라면 끝에 닿기 전에 읽고, 끝에서는 비교만 합니다. 아직 배우는 단계에서는 인덱스와 길이 비교로 구현해 중단점에서 두 숫자를 함께 관찰하기 쉽게 만듭니다.
const int *values는 이 경로로 배열 내용을 수정하지 않겠다는 약속입니다. out은 int *이므로 호출자가 제공한 결과 객체에는 기록할 수 있습니다. const가 전체 프로그램에서 원본이 절대 바뀌지 않는다는 뜻은 아닙니다. 다른 쓰기 가능한 주소가 원본을 바꿀 수 있습니다. 읽기 함수가 입력을 수정하지 않게 만들어 실패 시 원본 보존과 결과 객체 보존을 따로 검토합니다.
주소를 전달하는 비용은 배열 전체를 복사하는 비용과 다릅니다. 그렇다고 포인터 반환으로 모든 복사를 없애는 것이 항상 좋은 설계는 아닙니다. 호출자가 내부 원소를 수정하거나 소유자의 수명 이후에도 주소를 보관하면 새 오류가 생깁니다. 이번 함수는 int 한 건을 out으로 복사합니다. 다음 구조체 실습과 미션에서도 작은 레코드를 복사해 내부 저장소의 주소가 외부로 새지 않도록 합니다.
경계 테스트로 함수 계약을 증명합니다
starter의 sample_at은 모든 요청을 거부하므로 정상 인덱스 테스트부터 실패합니다. 검사 조건을 구현하고 그 뒤 복사를 추가합니다. 일곱 사례는 첫 원소, 마지막 원소, 끝 바로 뒤, 빈 유효 구간, NULL 입력, NULL 출력, 매우 큰 인덱스를 다룹니다. 정상 사례만 추가하면 항상 거부하는 구현과 구별하기 어렵고 거부 사례만 추가하면 실제 읽기 기능을 확인하지 못합니다. 두 종류의 사례가 함께 있어야 합니다.
demo는 sizeof a와 sizeof(int *)를 함께 표시합니다. 이 맥에서는 배열 12바이트와 포인터 8바이트로 다릅니다. 이 결과를 보고 함수가 받는 주소의 크기로 배열 건수를 계산할 수 없음을 설명할 수 있어야 합니다. 만약 다른 대상에서 숫자가 우연히 같더라도 객체의 종류와 길이 전달 책임은 달라지지 않습니다. 같은 출력 숫자는 같은 의미를 보장하지 않습니다.
테스트가 array: 7 cases, 1 failures처럼 끝나면 성공과 실패 경계를 따로 손으로 따라갑니다. 인덱스 2에는 4095가 나와야 하고, 인덱스 3에서는 쓰기 문장까지 내려가면 안 됩니다. segmentation fault 같은 런타임 오류가 보이면 범위를 벗어난 주소 접근을 의심할 수 있지만 그 메시지만으로 어느 조건이 틀렸는지 확정할 수 없습니다. 마지막 레슨에서 중단점과 주소 검사기를 이용해 접근 직전 상태를 확인합니다.
실무 리뷰에서는 함수 선언 옆에 길이가 원소 수인지 바이트 수인지, 실패 때 out이 바뀌는지, 입력의 소유자가 누구인지 적습니다. 이름을 length로 둔 것만으로 단위는 전달되지 않습니다. 여기서는 const int 배열의 원소 수이며 호출 동안 유효해야 한다는 계약입니다. 더 읽기의 포인터 산술 예제로 표기법을 보충하고, 다음에는 호출이 끝난 뒤에도 주소를 사용할 수 있는지 판단합니다.
따라하기
검증 실패에서 구현 시작
starter 폴더에서 실행합니다. 실패 개수는 수정 정도에 따라 달라지므로 자신의 결과를 확인합니다.
make test관찰 실행 파일 만들기
구현을 수정한 뒤 실행합니다. 컴파일 성공은 출력 없이 끝납니다.
make -s demo값과 계약 관찰
solution과 같은 구현에서 실행한 결과입니다. 숫자 크기는 이 맥의 측정값입니다.
./demo실행 결과
length=3 array=12 pointer=8 index=2 accepted=1 value=4095 index=3 accepted=0 unchanged=88
같은 테스트 재실행
수정 후 기대 반환값과 상태 유지가 함께 확인됩니다.
make -s test실행 결과
array: 7 cases, 0 failures
확인 문제
실습
lab.c의 sample_at을 구현합니다. 실제 원소 수 length 안의 인덱스만 out으로 복사하고 NULL·빈 구간·끝 뒤·큰 인덱스는 실패로 반환합니다. 거부 시 out을 유지하며 make test의 7개 검사를 통과시킵니다.
실행 명령
make test
기대 결과
array: 7 cases, 0 failures
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 배열과 포인터를 사용할 때 메모리 오류를 확인하는 방법을 설명해 주시면 됩니다.