배열 탐험 신비의 세계, 진실은 저 너머에 II
일반적으로 배열의 선언은 다음과 같이 합니다.
type 배열이름[사이즈] ;
/*********************************************************************
type은 void를 제외한 모든 타입을 사용할 수 있으며
배열이름은 C의 변수 명명 법을 따르고 반드시 사이즈로 상수를 사용해야 합니다.
사이즈를 상수 대신 일반 변수는 물론 const변수도 절대 사용해서는 안됩니다.
그리고 배열 초기화도 반드시 상수를 사용해야 합니다.
그러나 표준 C99에서 자동 변수 영역 안에서 수식 등 상수 값이 아닌 경우도
포함할 수 있게 확장되었습니다.
그래서 변수를 이용한 동적 할당 배열도 가능하기도 합니다.
그리고 int something[100] = { [50] = 21}; 이렇게 초기화하면
50번 방만 21로 나머지는 모두 0으로 초기화됩니다.
*********************************************************************/
이것은 type 형 데이터 사이즈 개수를 저장할 메모리 공간 sizeof(type) * 사이즈 byte(s)를 (주로 stack영역에) 메모리 할당하고 배열이름이라고 이름을 붙인다는 의미입니다.
실제로
int array[10] ;
이라고 선언하면 (주로 stack에) sizeof(int)*10 bytes를 메모리 할당하고 array라고 이름을 붙인 것과 같습니다. 32bit 운영체제를 기준으로 그냥 40 bytes가 메모리의 stack 영역에 할당됩니다. 절대로 메모리에 금을 긋고 방을 만들거나 하지 않습니다. 여기서 가장 중요한 것은 바로 배열은 선언할 때 메모리 할당을 한다는 것입니다. 이것이 바로 포인터와 배열이 다른 점들 중 중요한 첫 번째입니다. 배열은 메모리 할당이 끝났으니 이제 데이터 할당만 하면 됩니다. 변수 선언은 데이터를 담을 공간, 즉 그릇을 준비했다는 의미입니다. 빈 그릇이나 지저분한 그릇은 의미가 없습니다. 준비된 그릇에 음식을 담듯이 메모리가 할당되었으니 이제 데이터를 담아야 합니다. C언어에서는 이렇게 메모리 덩어리 채 잡힌 메모리 공간을 다양하게 사용할 수 있는 방법을 제공하는데 그것이 바로 type과 cast연산자입니다. 바로 여기에 int형 열 개를 저장하도록 해주는 것이 바로 배열 연산자의 사용할 때의 수식으로서의 의미입니다.
그러면 앞의 제대로 된 배열의 메모리 그림에서 array[2]을 어떻게 찾을까요? 그건 바로 연산을 해서 찾아야 합니다. 컴퓨터는 사람이 칠 삼? 이십 일 하듯이 그냥 나오는 게 절대 아닙니다. 어떻게 해야 하는지 함께 알아보도록 하겠습니다.
일반적으로 배열의 사용은 다음과 같은 수식을 사용합니다.
base[index] ;
// 반드시 base는 타입이 있는 데이터의 주소 값이고 index는 정수형(수식)입니다.
이것은 base 번지로부터 sizeof(type) * index byte(s) 만큼 떨어진 주소를 기준으로 sizeof(type) byte(s) 만큼 참조하라는 의미입니다.
메모리를 정해진 위치의 시작점부터 참조해야 하니 배열이름을 기준으로 잡았다면 당연히 인덱스가 0부터 시작합니다.
앗 그러면… 포인터도 메모리를 참조하는 연산자인데 포인터 연산자와 배열 연산자를 서로 바꿔 사용할 수 있을까요? 그야 물론 당근이 백만 스물 여섯 개입니다.
일반적으로 수식에서 사용할 때 배열 연산자와 포인터 연산자는 같은 의미로 사용됩니다. 기억할 것은 이것이 배열과 포인터가 같다는 말을 의미하지 않는다는 것입니다.
여기서 포인터와 배열에 대한 모든 것을 명확하게 보여주는 Sinclair's 정리가 등장합니다.
구구단처럼 외워 두면 나중에 두고두고 효자 노릇을 할 아주 멋진 녀석들입니다. 다행히 구구단처럼 81개가 아니라 딱 3개입니다. Sinclair의 법칙도 아닙니다. 그냥 정리입니다. K&R께서 만들어 놓은 법칙을 포인터와 배열과 관계되어 있는 것만 잘 모아서 정리했습니다. 처음부터 아예 작정하고 Sinclair의 정리도 아니었습니다. 3개를 만들 생각도 없었습니다. 프로그램을 한 십수년쯤 짜고 또 강의를 십수년 하게 되면서 정리 세가지가 모였습니다.
그래서 감히 제 이름을 붙였습니다. 그만큼 자신 있습니다. 10년 이상을 두고 지켜 봤지만 이것을 벗어나는 예외 사항을 만난 적이 없습니다.
저를 비롯한 많은 개발자들이 포인터 때문에 죽을 고비를 수없이 넘겼습니다. 하지만 이것만 제대로 이해하면 포인터도 별거 아니더라는 걸 알았습니다. 저는 강의를 오랫동안 했습니다. 한번은 대기업 강의 중에 어떤 분이 저에게 외국인 개발자가 작성한 이해하기 힘든 코드라면서 긴 수식 한 줄을 달랑 내밀며 이게 뭔뜻인지 좀 알려 달라고 합니다. 타입도 모르는 상태입니다. 하지만 타입이 중요하지 않습니다. 우선 설명을 하고 타입을 유추하고 동일한 일을 하는 좀더 쉬운 코드로 바꿔놓으면 정말 이것이 동작하냐고 되려 제게 되묻습니다.
몇 달이 지나고 몇 년이 지난 지금까지도 아무런 문제 없이 잘 돌아간다고 합니다. 제가 이렇게 되기 까지 포인터때문에 좌절의 끝판왕을 달리던 중 일본 분이 쓰신 책 한 권을 끌어 안고 그걸 읽는데 3년 이상이 걸렸습니다. 그걸 요약한 것이 바로 정리 세가지입니다.
다른 책에서는 어렵고 매번 다르게 설명하고 있어 이해하기 어려웠던 것을 이 세가지 정리를 깨닫고 받아들이니 일관성 있고 보다 쉽게 모든 것이 해결되더라는 이야기를 많은 개발자들이 하고 있습니다.
Sinclair's 정리 #1 ( 배열과 포인터의 관계, 배열이 연산자인 증거 )
base[index]
== * ( base + index ) // 1) K&R의 법칙입니다.
== * ( index + base ) // 2) 덧셈의 교환법칙 아닌가요?
== index[base] // 3) 배열의 교환법칙
∴ 배열도 연산자
이렇게 1) 수식에 근거 이제부터 선언할 때를 제외한 모든 포인터는 배열처럼 사용할 수 있습니다.
1차원 배열만 가능하냐?
아닙니다. 몇 차원이건 포인터의 별이 몇 개 있건 관계없이 통하는 법칙입니다.
*iPointer == *( iPointer + 0 ) == iPointer[0] ;
*( fPointer + 5 ) == fPointer[5] ;
**cPtr == *( *( cPtr + 0 ) + 0 ) == *( cPtr[0] + 0 ) == cPtr[0][0] ;
*( pointer + i ) == pointer[i] == i[pointer] ;
*( *( pptr + i ) + j ) == *( pptr[i] + j ) == ( *( pptr + i ) )[j] == pptr[i][j]
== j[pptr[i]] == j[i[pptr]];
*( **( ppptr + i ) + j ) == *( *( *( ppptr + i ) + 0 ) + j ) == ppptr[i][0][j] ;
**( *( ppptr + i ) + j ) == *( *( *( ppptr + i ) + j ) + 0 ) == ppptr[i][j][0] ;
*( *( *( ppptr + i ) + j ) + k ) == ppptr[i][j][k] ;
반대로 선언할 때를 제외한 모든 수식에서의 배열을 포인터처럼 사용할 수도 있습니다. 미친 짓이죠? 배열로 이쁘게 잘 되어있는 것을 왜 힘들게 포인터로 바꿉니까? 하지만 거기엔 다 심오한 의미가 있지 않겠습니까?
보통 배열 연산을 할 때 대부분 배열 이름을 사용합니다. 그러면 배열 이름이 포인터 또는 주소 값처럼 사용이 된다는 말인데 과연 배열 이름이 포인터일까요? (이건 아니잖아~ 이건 아니잖아~ ^^;;;)
배열 이름은 절대로 포인터가 아닙니다.
그러면 왜 배열 이름이 포인터처럼 동작할까요?
사실 배열 이름이 포인터이거나 또는 포인터처럼 동작한다는 것이 완전 틀린 말은 아닙니다만 거기에는 우리가 아직 몰랐던 진실이 숨겨져 있습니다.
사실 포인터처럼 동작하는 게 아니라 그 안에 우리에게 보이지 않는 & 연산자가 들어 있기 때문입니다.
배열 이름이 이렇게 말합니다. "내 안에 &연산자 있다."
그것을 증명하려고 Sinclair의 정리 두 번째가 등장합니다. & 단항 연산자가 왜 포인터처럼 동작하는 지는 세 번째 정리에서 이야기 하겠습니다.
Sinclair's 정리 #2 ( & 와 * 의 만남 )
* & pointer == pointer == & * pointer
이게 뭐야? 대체 나한테 왜 이래? 이러지 말길 바랍니다.
우리가 구구단을 외울 때 이해하고 외웠습니까?
만약에 2 + 1이 왜 3이냐구 묻는다면 뭐라 대답하겠습니까?
그런데 집 앞 포장마차에서 떡볶이를 3인분 한꺼번에 포장해 오는 것 보다 2인분 따로 1인분 따로 포장해 달라고 하면 양이 더 많던데요? 이거 어떻게 된 겁니까? 뭡니까 이게~ 주인 아줌마 나빠요~
int iArray[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 } ;
이라고 선언되었다면 대부분의 수식에서 iArray == &iArray[0] 이 등식이 성립합니다. 그걸 증명하여 정리하면 다음과 같습니다.
&iArray[0] == &( *( iArray + 0 ) )
// ∵ Sinclair's 정리 #1
== &* iArray
// +0 은 왜 하죠?
// 그냥? 없어도되죠?
== iArray
// ∵ Sinclair's 정리 #2
하지만 이 등식(iArray == &iArray[0])은 모든 곳에서 성립되지 않습니다.
이것이 모든 수식에서 100% 성립된다면 모든 배열 이름은 상수 포인터인 것이 맞습니다.
하지만 배열은 배열입니다. 절대 포인터가 아닙니다.
그 증거가 바로 sizeof와 & 단항 연산자 입니다. 만약에 그냥 그 자체만으로 배열이 상수 포인터이거나, 상수 포인터처럼 동작하는 거라면 sizeof(배열이름) 하면 운영체제에 따라서 2 또는 4가 나와야 정상입니다. (어떤 포인터이건 운영체제에 따라서 사이즈는 일정합니다.) 하지만 결과를 보게 되면 선언할 때 잡았던 전체 메모리 바이트 수가 나옵니다.
sizeof(iArray)와 sizeof(&iArray[0]), 그리고 sizeof(&iArray)를 비교해 보면 알 수 있습니다.
게다가 32bit 운영체제에서 &배열이름 값과 &배열이름 + 1 값의 차이는 당연히 4가 나와야 합니다.
정수형의 주소 값에 1을 더하면 정수형 사이즈만큼 증가합니다.
배열이름이 포인터라면 포인터의 주소는 포인터의 사이즈만큼 증가하는 것이 맞지 않겠습니까? 실제로는 전체 배열 사이즈만큼 증가하는 것을 볼 수 있습니다. 그러면 배열이름이 포인터일까요? 전체 배열일까요?
잘 기억하셔야 합니다. 배열이름이 sizeof와 & 단항 연산자 이 두 가지 연산자를 만날 때를 제외한 나머지 모든 경우에 배열이름은 &와 [0]를 포함하고 있습니다.
반드시 기억하셔야 합니다. 우리 눈에는 배열이름만 보일지라도 sizeof와 & 단항 연산자를 제외한 모든 수식에서 &와 [0]가 함께 들어 있다는 것을 알아야 합니다.
그래서 배열이름에는 대입연산자를 사용 못합니다. 잘 알다시피 & 단항연산자의 결과 값은 상대주소이지만 그렇다고 정해진 주소를 바꿀 수 없지 않습니까?
iArray = (int*) 20000 ; //?????
우리가 아무리 포장이사를 부르더라도 이사하는 일은 아주 고역입니다. 하지만 이사하기 힘들다고 주소를 변경할 수 있습니까? 동사무소에 가서 이사하기 귀찮고 시간도 없으니 그냥 우리 집 주소 좀 바꿔 달라고 하면 미친 사람 취급 받습니다. 제발 그러면 안됩니다.
#include <stdio.h>
/*
* copyleft (l) 2006 - 2017 programmed by Sinclair
*/
main() {
int iArray[10] = {0} ;
int iData = 0 ;
double dData = 0.0 ;
int * iPtr = NULL ;
printf("%d %d %d %d\n" , sizeof iArray , sizoef iData
, sizoef dData , sizeof iPtr) ;
printf("%d %d %d %d\n" , sizeof &iArray , sizoef &iData
, sizoef &dData , sizeof &iPtr) ;
puts("****************************************************");
printf("%u %u %u %u\n" , &iArray , &iData , &dData , &iPtr) ;
printf("%u %u %u %u\n" , &iArray+1 , &iData+1 , &dData+1
, &iPtr+1) ;
return 0 ;
} // end main()
// 그럼 sizeof iArray + 1 와 sizeof(iArray + 1)은 어떤 값이 나올까요?
우리는 앞에서 다음과 같이 선언했습니다. 기억하죠?
int array[10] ;
메모리에 금도 안 긋고 방을 만드는 것도 아니라면 여기에서 array[2]를 어떻게 찾아갈까요? 123 + 456 * 7 은 어떻게 하나요? 똑같습니다. 연산하는 거죠.
제대로 그린 배열 int array[10] 메모리 그림
[]는 연산자라고 했습니다. 게다가 연산자 우선순위는 젤 높은 것들 중 하나입니다. 선언할 때는 타입이 필요하고 메모리 할당의 의미지만 그것을 제외하면 나머지는 일반 수식과 같습니다. 배열 연산자가 수식에서 갖는 의미로 해석해 보면 위의 그림에서처럼 array[2]는 array번지로부터 sizeof(int)*2 bytes만큼 떨어진 주소를 기준으로 sizeof(int) bytes만큼 참조하라는 의미가 됩니다.
Sinclair's 정리 #3 ( 포인터의 정체 )
1) 양방향 모두 성립하는 수식입니다.
2) 어떤 type의 변수(레지스터 제외) typeData가 존재할 때 그 변수의 주소 값 &typeData의 타입은 type *형이다.
3) type * 형은 type형 데이터의 주소 값을 담기 위한 타입이다.
사실 정리 3번은 포인터와 관련되어 있지만 먼저 등장했습니다. 배열을 설명하면서 포인터를 따로 떼어 놓을 수 없기 때문입니다. 여기서 먼저 기억할 것은 아하~ 포인터는 주소 값을 담는 타입이구나 하는 것 입니다. 포인터는 포인터 부분에서 자세히 설명할 것입니다. 기대하셔도 좋습니다.
이제 우리는 왜 & 단항 연산자의 결과 값이 포인터처럼 동작하는지 알았습니다.
int * iPointer ; // 아항 정수형 주소 값을 담기 위한 타입이구나~
iPointer = & iArray[2] ; // 가능한 수식입니다. 아무 문제없습니다. (정리 #3)
/*
iPointer == &iArray[2] 대입 했으니 등식이 성립합니까? 아싸~ 성립합니다.
*iPointer == *&iArray[2]
*(iPointer+0) == *&iArray[2]
iPointer[0] == iArray[2] ∵ Sinclair의 정리 # 1,2
iPointer[-1] == iArray[1]
iPointer[-2] == iArray[0]
정말 iPointer[-1], iPointer[-2]를 써도 되나요?
된다 안 된다 에서는 된다 입니다.
하지만 좋다 나쁘다 에서는 모른다 입니다. 아니 사실 나쁘다 쪽이 좀더 맞습니다.
*/
여기서 왜 갑자기 무슨 뚱딴지 같은 소리냐 할지 모르겠습니다.
분명히 수식에서 사용될 때 배열 연산자의 피 연산자는 정해져 있습니다.
정해진 것 말고 다른 것을 사용할 수 없습니다.
배열 연산자의 피연산자들 중 하나는 반드시 타입이 있는 주소 값이어야 합니다.
나머지 하나는 정수형입니다. 0과 자연수라고 하지 않았습니다.
그냥 정수형이라고 했습니다.
때문에 음수 인덱스도 가능합니다.
메모리이기 때문에 음수방향(역방향)으로도 참조 가능합니다.
하지만 배열 연산자의 의미에는 범위를 체크하는 동작이 들어 있지 않습니다. 그렇기 때문에 C언어에서 메모리를 핸들링 할 때는 매우 조심해야 합니다. 그래서 Java에서는 절대로 음수 인덱스를 사용할 수 없으며 범위를 넘어가는 indexing도 절대 안됩니다. ArrayOutOfBoundaryException이 발생합니다.
아예 컴파일이 안됩니다.
혹시 인덱스 값이 변수였다면 어찌, 어찌 컴파일이 되었다고 해도 실행 중에 아주 난리가 납니다. 하지만 C에서는 다릅니다. 아무리 범위 밖을 참조하더라도 컴파일도 되고 때론 (어떤 CPU, 어떤 OS에서는) 아무런 문제없이 잘 동작하는 것처럼 보입니다.
참고로 말하자면 표준 C자체에서는 인덱싱 범위와 관련된 어떤 검사도 요구하지 않는다는 것입니다.
그래서 자유롭다고 합니다.
다른 언어 보다 자유롭기 때문에 좀더 빠르다고 합니다.
하지만 그 자유에는 엄청난 책임이 따릅니다.
니트로글리세린은 잘못 관리하면 폭발하여 많은 사람을 죽이기도 하지만 잘 사용하면 혈관확장이나, 협심증치료에 사용되어 사람의 목숨을 살리기도 합니다. 우리는 메모리를 자유롭게 사용하는 것에 대한 마땅한 책임감을 가지고 있어야 합니다. 마찬가지로 메모리와 관련된 모든 범위 검사는 프로그래머가 직접 해야 합니다. 알아서 돌아가겠지 라고 생각하면 큰 일 납니다. 이 세상에 저절로 되는 것은 하나도 없습니다. 이젠 No more free lunch! 입니다. 여러분은 아는 게 힘이니 그것을 깨닫고 그 자유를 누리겠습니까? 아니면 모르는 게 약이라고 생각하며 그냥 대충 살겠습니까? 선택은 여러분의 몫입니다.
#include <stdio.h>
/*
* copyleft (l) 2006 - 2017 programmed by Sinclair
*/
main() {
int iArray[10] = {0} ;
int index ;
for( index = 0 ; index < 30 ; index++ ) {
printf("%d 번째 정수형 데이터를 입력하세요~ ", index) ;
scanf("%d" , iArray + index) ; // scanf()는 나쁘다며? ㅠㅠ
// iArray + index == & iArray[index]
while(getchar() ^ '\n') ; // buffering문제 해결해야죠?
} // end for
puts("press enter key to continue.....") ;
getchar() ; // 잠깐 멈췄다가 다시 시작하기 위한 가장 간단한 방법
for( index = 0 ; index < 30 ; index++ ) {
printf("iArray[%d] = %d\n", index , index[iArray]) ;
// index[iArray] == iArray[index]
} // end for
puts("stopped this program.....") ;
return 0 ;
} // end main()
어디까지 출력이 됩니까? 모두 출력 되고 아무 문제가 없나요? 아니면 어디서 문제가 발생할까요?
혹시 문제가 발생했다면 그 문제는 왜 발생할까요? 한번 생각해 보기 바랍니다.
문제가 없다고요? 그럼 왜 없을까요? 그것도 한번 생각해 보세요.
어제는 되었는데 오늘은 안되더라, 내가 테스트할 때는 되더니 부장님이 하면 안되더라, 심지어 제품으로 발표까지 했는데 고객이 하면 안되더라… 눈물이 앞을 가립니다. 어디 한두 번 겪어봤습니까? 아직 못 겪어 봤다구요? 그럼 아직까진 행복한 겁니다. 여러분들의 앞날에 피쑤~ 아무 이유 없어~
#Sinclair #씽클레어 #싱클레어 #씽클레어도씨 #씨언어 #씨프로그래밍 #C언어 #Cprogramming #C_Programming #C #Programming #Clanguage #C_Language