이 포스트는 C 언어의 문법을 정리하는 글이 아니라, C 언어에 대한 흥미로운 내용?(책 후기)을 쓰고자 합니다. 주로 Java를 사용해왔지만, C언어에 대한 글을 쓰게 된 계기는 ‘컴파일러 개발자가 들려주는 C 이야기’라는 책을 읽고 기록에 남기고 싶어서 입니다. 방을 정리하다가 형이 언제 산 건지 모르는 책을 발견하고 호기심에 펼쳤던게 글을 쓴 시작점인 것 같습니다.

C 언어는 프로그래밍을 처음 배우는 사람들이 가장 많이 접하는 언어일 것입니다. 저 또한 대학교 1학년 때 처음으로 C 언어를 배웠습니다. 그러나 어떤 사람들은 처음 언어를 선택할 때 C 언어를 꼭 선택할 필요는 없다고 말하기도 합니다. 그 이유는 아마도 C 언어가 다른 프로그래밍 언어에 비해 불친절하기 때문일 것입니다. 하지만, 저는 C 언어를 완벽히 이해한다면 C++, Java, Python과 같은 다른 언어를 배울 때 더 깊이있는 이해를 얻을 수 있을 것이라 생각합니다. 원인을 알 수 없는 에러가 발생했을 때, 그 에러를 해결하는 가장 좋은 방법은 그 문제를 깊이 있게 파고들어 이해하는 것이라고 생각합니다.

비록 C 언어를 주 언어로 사용하지는 않지만, 어느 정도 C 언어의 문법을 잘 알고 있다고 생각해서 자신있게 책을 펼쳤지만 제 생각이 틀렸다는 것을 책을 읽고나서 알 수 있었습니다. 책에서 재미있게 읽었던 부분을 아래에 적고자 합니다.

C Book

  1. C언어 개요

  2. 배열과 포인터


개요

C 언어는 데니스 리치가 벨 연구소에서 개발한 프로그래밍 언어로, 초기에는 운영 체제를 구현하기 위해 만들어졌습니다. 이렇게 개발된 운영 체제가 바로 유닉스입니다. 정확히 말하자면, 유닉스가 C 언어보다 먼저 만들어졌습니다. ‘신형B’ 언어로 유닉스를 초기에 구현하였고, 이후 ‘신형B’는 빠르게 C 언어로 진화했습니다. BCPL -> B -> 신형B -> 초기 C로 발전하는 과정은 C 언어가 왜 다소 불친절한지를 조금이나마 이해할 수 있게 해줍니다. C 언어는 컴파일러 개발자들과 함께 성장했습니다. 뛰어난 성능의 컴파일러를 개발하기 위해 C 언어가 진화해왔습니다. 그래서 모호하고 애매한 부분은 프로그래머가 직접 지정하고 정의하도록 만들어졌습니다. (제가 책을 읽고 이해한 내용을 정리한 것이므로, 정확하지 않을 수도 있습니다.)



배열과 포인터

파일 1: int a[10];

파일 2: extern int *a; …


위 프로그램은 정상적으로 동작하지 않습니다. 처음 위 코드를 보았을 때는 정확한 이유를 알지 못했습니다. 다음은 제가 배열과 포인터에 대한 내용을 읽고 이해한 것을 바탕으로 배열과 포인터의 차이점을 정리하고자 합니다.

C언어에서 ‘선언’은 이름을 가리키는 것이고, ‘정의’는 객체를 생성하는 특수한 종류의 선언입니다. 선언을 할 시에 메모리 할당하지 않으므로 전체 크기에 대한 정보 제공이 필요하지 않습니다. 여기서 중요한 점은 ‘선언’은 컴파일러에게 정의된 객체에 대한 정보를 제공합니다.

‘x = y;’에서 왼쪽에 있는 값을 ‘l-값’, 오른쪽에 있는 값을 ‘r-값’이라고 합니다. l-값은 주소를 의미하고 r-값은 주소에 들어 있는 내용을 의미합니다. l-값은 컴파일 타임에 알게 되며 런타임에 변수가 유지되는 곳이고 r-값은 런타임까지 알 수 없습니다. 여기서 중요한 점은 l-값은 컴파일 타임시 주소가 알려진다는 점입니다.

컴파일 타임에서 배열과 포인터가 컴파일러 심볼 테이블에 저장되는 방식은 다릅니다. 컴파일러는 배열의 경우 시퀀스 객체로서 배열의 시작 주소를 심볼 테이블에 등록하는 반면, 포인터의 경우 포인터 객체로서 포인터의 변수 주소를 심볼 테이블에 등록합니다.

파일1에서 a를 배열로 정의하고 파일2에서 a를 포인터로 선언하게 되면, 컴파일 시 변수 a를 포인터로 취급합니다. 그렇게 되면 원인 모를 오류에 빠질 우려가 있습니다. 구체적인 과정을 살펴보면, extern int *a 선언시 배열이 할당되어 있는 메모리의 시작 주소를 a의 주소값으로 받아들이게 됩니다. 왜냐하면, 파일 2에서 a를 컴파일러가 포인터로 취급하기 때문입니다.(다른 말로 이중 참조가 될 수 있다는 의미 같습니다. 이 부분은 정확하지 않을 수도 있습니다.) 우리는 아래의 두 코드와 위의 코드는 전혀 다르다는 것을 알아야 합니다.

(i)
int main(void){int a[10];int *b; b=a;}
(ii)
int sum(int *a){…}
int main(void){int a[10];sum(a);…}

(i)의 경우 직관적으로 변수 a,b가 다르다는 것을 알 수 있고, 컴파일 타임에 a는 시퀀스 객체로서 심볼 테이블에 주소가 정해지고, b는 포인터 객체로서 심볼 테이블에 주소가 정해집니다. (ii)의 경우 함수 sum에서 매개변수로 변수 a를 미리 포인터로 선언했습니다. 그래서 sum 함수를 호출할 때, 주소에 의한 호출이 일어나고 매개변수 a에는 배열 a가 할당되어 있는 메모리의 주소를 참조하게 됩니다. 따라서 sum 함수에서는 아무런 문제가 일어나지 않습니다.