AndreaSmalltalkLecture:QnA 03

From 흡혈양파의 인터넷工房
Jump to navigation Jump to search
질문과 답변-3
:C/C++에서 포인터가 중요한 이유

C/C++에서 포인터가 중요한 이유

음, 여기 들어와 보니깐 포인터에 대해 이야기가 나오고 있는데요, 사실 포인터라고 하는 것은 프로그래밍 언어라면 정도의 차이는 있지만 모두 다 지원하는 걸로 알고 있습니다. 그것을 어떻게 부르느냐, 또한 문법적으로 어떻게 나타내느냐가 다를 뿐이지요.(베이식에도 VarPtr(), Peek()함수, 그리고 Poke 명령을 사용하여 포인터를 구현하고 있습니다.)


그런데 C/C++을 애용하시는 분들은 한결같이 포인터를 매우 중요한 존재로 생각하고, 포인터가 없이 프로그래밍을 한다는 것은 말도 안 되는 것 처럼 포인터에 대해 상당한 비중을 두고 계십니다. 물론 이는 맞는 말씀입니다. C++, 특히 C의 경우는 포인터를 빼 놓으면 도저히 프로그래밍을 하기 어렵게 되어 있습니다. 그런데 여러분들 중에는 C/C++에서 포인터가 왜 그토록 중요한 위치를 차지해야만 하는지, 그리고 반드시 그래야만 하는지에 대해서 의문을 제기하는 사람들은 드뭅니다.


실제로 Java 나 Smalltalk 과 같은 언어에서는 포인터가 아예 표면적으로 존재하지 않습니다. 그뿐만 아니라 Object Pascal 의 경우도 포인터가 표면적으로 등장하고는 있지만, Ada와 같이 포인터 연산에 심한 제한을 가지고 있습니다. 만일 포인터의 사용이 그렇게 필수적인 것이었다면, 왜 이들 언어가 한결같이 포인터에 대해서 제제를 가했을까요?


제 생각부터 말씀드리면, 사람들이 흔히 생각하는 것처럼 포인터를 그렇게까지 기를 쓰고 사용해야할 만큼 용도가 많지는 않으며, 극히 제한되어야 하고, 또한 그러는 것이 시스템의 안전에도 도움이 된다는 것입니다.


그럼 여기서 C/C++이 왜 포인터를 쓸 수 밖에 없었는지를 생각해 봅시다.


참조자(reference)의 부재

처음 C언어가 발표되었을 때에는 참조자가 없었습니다. 이 참조자는 함수나 절차를 호출할 때 인자의 전달 방법 중 "가리킴 전달"(call-by-reference) 기법을 구현하기 위하여 필요한 것입니다. 즉 하나의 객체(object, 여기서는 "대상물"이라고 번역해도 되겠습니다)에 둘 이상의 이름이 붙을 수 있다는 것을 조건으로 합니다. 즉 어떤 객체가 있을 때, 이 객체를 "A"라고 부를 수도 있고 "B"라고 부를 수도 있다는 것을 전재로 한 것이며, 이 경우 객체에 또 다른 이름을 부여하는 역할은 참조자(reference)가 맡는 것입니다. 즉 이렇게 하면 함수나 절차를 부른 쪽과 불린 쪽 모두 똑같은 객체에 접근할 수 있게 되고, 이것이 결국 "가리킴 전달" 인 것입니다. 물론 의미론 적으로 보면 이런 것이고, 이것을 구현할 때 주소를 사용하건 다른 방법을 사용하건, 그것은 언어 구현상의 문제인 것이지요.


파스칼의 경우는 일찍부터 참조에 의한 호출을 사용할 수 있었으며, C++의 경우도 하나의 객체에 다른 이름을 붙일 수 있는 참조자를 두어서 이를 해결하고 있습니다. 그러나 C의 경우는 참조자가 없기 때문에, 이미 있던 객체에 다른 이름으로 접근하기 위해서는 결과적으로 포인터를 사용할 수 밖에 없는 것입니다. C++이 참조자를 지원하기는 하지만, 오히려 포인터를 사용해서 구현되는 방식과 혼용해서 쓰게 되면 문맥상의 혼란만 더 가중시키게 됩니다. 또한 이미 C언어를 기반으로 구축되었던 라이브러리 함수에서는 한결같이 포인터를 사용하여 가리킴 전달을 구현하였기 때문에, 오히려 참조자를 사용하여 가리킴 전달을 하게 되면 함수의 사용자들은 참조자를 쓸 때와 포인터를 쓸 때를 분간하기 어렵게 되고, 차라리 예전 그대로 포인터를 사용한 가리킴 전달의 구현을 더 편하게 생각하는 풍토가 생긴 것입니다. 그러므로 C++의 참조자는 포인터 사용을 줄이기는 커녕, 오히려 혼란만 가중시켰다는 것이 필자의 생각입니다.


포인터, 그것이 C언어가 선택한 것이고, 이것이 모든 제앙의 시작이 되었던 것입니다.


배열을 인자로 전달할 수 없는 문제

C/C++ 에서 포인터를 사용할 때 몇 가지 변환 규칙이 있습니다. 그 중에 "배열 변수의 이름은 그 배열의 첫 번째 원소를 가리키는 포인터이다"(임인건, 1991)라는 법칙이 그것입니다. 이 말은 결국 함수나 절차에 배열을 인자로 넘겨줄 수 없다는 말입니다. 실제로 C/C++에서 배열 변수를 인자로 받을 수 없기 때문에, 이를 포인터로 받고, 따라서 배열의 크기를 알 수 없으므로 따로 크기를 명시해 두거나 자료 자체에 꼬리표(null-terminator 등)를 두어 처리하는 방식을 체택하고 있습니다. 파스칼이나 Smalltalk, Ada 등 배부분의 언어에서는 배열을 인자로 사용할 수 있으며, 따라서 포인터 연산이라는 것이 필요치 않습니다. 배열이 인자로 전달될 때에는 엄연한 하나의 자료형이고, 따라서 배열의 위치 뿐만 아니라 배열의 크기 및 기타 다른 속성들도 함께 전달받을 수 있도록 되어 있습니다. 특히 Object Pascal 이나 Ada 에 도입된 "열린 배열"은 배열 변수의 원소수에 대한 제한을 없애주어, 결과적으로 C/C++에서보다 더 유연하면서도 안전하게 배열을 처리할 수 있도록 하고 있습니다. Smalltalk의 경우 배열은 어떤 자료형(갈래)를 가진 객체라도 다 가질 수 있으므로 일찍부터 열린 배열을 사용하고 있다고 해도 과언이 아닐 것입니다.


아무튼 배열 변수를 절차나 함수에 인자로 사용할 수 없다는 것은 큰 제한이며, 따라서 배열의 경계성 검사와 같은 시스템 안전에 직결된 결함을 안게 된 원인이 여기에 있는 것입니다.


배열 원소수의 접근은 곧 포인터 연산이다

C/C++ 에서 배열 변수를 함수나 절차에 넘겨줄 수 없다보니, 그리고 원래 C를 고안한 K&R의 경우 당시의 요구 사항에 따라서 고급언어이면서도 저급언어의 기능을 유치하기 위해서 모든 배열 연산을 포인터 연산으로 처리하도록 했습니다. 이 말은 만약 배열의 크기보다 큰 이은 번호를 부여하더라도, 아무런 경고나 잘못 없이 번역 및 실행이 됨을 말합니다. 결과적으로 이것은 프로그램에 벌레가 살고 있게 될 소지를 다분히 안고 있었으며, 이를 검사하기 위해서 "BoundsChecker" 등의 디버깅 보조 도구가 필요하게 되었습니다.


C언어가 나올 당시에는 언어 명세와 언어 구현이 따로 생각되지 않고 함께 다루어졌습니다. 그러므로 어떻게 하면 효율적인 코드를 만들어 낼까 하는 것이 언어 명세에 영향을 주게 된 것입니다. 결국 배열의 경계값 검사 기능 역시 이것이 프로그램 전체의 속도를 떨어뜨릴 수 있기 때문에 C언어에서 배제된 것인데, 오늘날의 대부분의 번역기들은 번역기 지시어를 통해서 사용자가 선택적으로 배열 경계값을 검사할 수 있도록 지정해 놓고 있습니다. 또한 최적화 기술의 발달로, Object Pascal 번역기의 경우 배열 변수의 연산을 자동으로 포인터 연산으로 바꾸어 주고 있습니다. 물론 이 경우 C/C++처럼 배열과 포인터의 연산을 문법적으로 동일하게 만들어 놓지 않고 포인터 연산과 배열 연산이 문법적으로 엄연히 구분이 되기 때문에 혼란이 없는 것입니다.


아무튼 언어 명세와 이를 구현하는 것이 밀접한 관계를 가졌던 70 년대에 C언어의 선택은 합당했을지 몰라도, 현재와 같이 프로그램이 대형화되고 복잡해지는 경우 배열 경계값의 검사 미비로 인해 발생되는 문제와, 이를 교정하기 위해서 출시되는 여러 가지 프로그램들을 보면, C언어에서 포인터 남용으로 인한 폐해를 쉽게 찾아볼 수 있을 것입니다. 명세가 정형화되어있다고해서 구현 역시 정형화될 필요는 없지요. a[3]은 a[3]이지만, 이를 *(a+3)으로 고치는 일은 번역기가 맡아야 합니다. 사용자가 일일이 *(a+3)과 a[3]을 혼용해서 사용하게 된다면, C언어와 같은 문법 혼란은 피할 수 없는 것이겠지요.


단순 객체 모형의 폐해

C에서 포인터와 배열을 마구 뒤섞어 놓음으로써 한 번의 큰 홍역을 치렀는데, C++에서는 설상 가상으로 "단순 객체 모델"을 도입함으로써 그 심각성을 배가시켰다고 말하면, 필자가 지나칠까요?


C++ 는 어찌됐건 기존 C언어에 객체지향 프로그래밍의 패러다임을 적용시키기 위해서 C에서 문법적으로 규정된 사항을 대부분 그대로 받아들였습니다. 그러다보니 포인터와 배열의 모호한 관게에다 참조자의 도입으로 인한 또 한번의 혼란은 결국 C++언어의 문법을 헌법처럼 만들어 버린 셈입니다. 그런데 여기에 더하여, 객체 지향 프로그래밍에서의 핵심적인 표현 방법인 "객체의 표현 방법"에서 C++는 또 한번의 실수를 거듭하게 되었는데, 그것이 바로 "단순 객체 모형"이라는 것입니다.


단순히 겉으로 드러난 것을 보면 분명히 "단순 객체 모형"이 "객체 참조 모형"보다는 __단순해__ 보입니다. 그러나 이 "단순"이라는 낱말 때문에 결국은 포인터를 사용하지 않고는 객체를 제대로 표현하지 못하는 지경에 이르고 있습니다.


제가 여태껏 접해본 객체지향 언어로는 C++, Java, Smalltalk, 그리고 Object Pascal 이 있었습니다. Objective-C 의 경우는 C 언어에서 특정 부분을 Smalltalk의 문법을 빌어서 처리하기 때문에, 일단은 Smalltalk의 문법 형식과 돌일하다고 생각하면 됩니다.


그런데 이들 언어에서 C++만 제외하고는 객체를 표현하는데 있어서 한결같이 객체 참조 모델을 사용하고 있습니다. 즉 변수(variable)가 어떤 값을 저장하는 "창고"의 개념이 아니라, 어떤 객체에게 이름을 붙여주는 꼬리표(tag)의 역할을 한다는 것입니다. 즉, 이는 묵시적으로 퍼인터의 개념을 도입했다고 생각하면 됩니다. 그러나 C++의 경우는 단순 객체 모형을 사용합니다. 즉 어떤 갈래(class)를 만들어 놓고, 그 갈래를 자료형으로 하는 변수를 만들게 되면, 해당 갈래만큼의 크기를 가진 변수가 만들어지게 됩니다. 이럴 경우 객체는 정적 모형으로 생성 및 파괴되고, 따라서 가상함수를 이용한 다형성을 구현하지 못하게 됩니다. 결과적으로 가상 함수나 기타의 객체지향적 요소의 이득을 얻기 위해서는 포인터를 쓰지 않을 수 없게 되는 것입니다.


Delphi 에서 작성된 프로그램을 동일하게 C++Builder 에서 작성해 보면 이를 확연히 알 수 있습니다. 객체 참조 모형을 사용하는 Object Pascal 의 경우는 바탕글을 읽는 것 만으로도 그 의미를 쉽게 파악할 수 있지만, C++Builder에서 생성한 바탕글의 경우는 객체를 참조할 때 일일이 포인터를 사용해야 하므로 여간 불편한 것이 아닙니다. 물론 포인터를 쓰기 좋아하는 사용자들이라면 별 어려움이 없겠지만, 바탕글을 유지하고 관리하는 측면에서 볼 때, 그리고 기독성을 생각하면 단순 객체 모형보다는 확실히 객체 참조 모델이 더 편리합니다.


Java 나 Smalltalk 에서도 마찬가지입니다. 이들 언어에서의 변수는 해당 객체 자체가 저장되는 창고가 아니라, 해당 객체에 붙은 꼬리표인 것입니 다. 따라서 해당 변수는 묵시적인 포인터로 처리되고 있는 것이며, 가상 함수나 모듬(collection) 등과 같이 객체지향적인 요소를 아무런 제한 없이 그대로 나타낼 수 있는 것입니다.


C++

	Set *aSet = new Set;

	aSet->add(3);
	aSet->add(4);
	aSet->add(5);

	cout << *aSet;

	delete aSet;


Java

	Set aSet = new Set();

	aSet.add(3);
	aSet.add(4);
	aSet.add(5);


Object Pascal

	var
	  ASet: TSet;
	begin
	  ASet := TSet.Create;
	  with ASet do
  	    Add(3);
	    Add(4);
	    Add(5);
	  end;
	  ASet.Free;
	end;


Smalltalk

	| aSet |
	aSet := Set new.
	aSet
	  add: 3;
	  add: 4; 
	  add: 5;


위에서 보면 분명히 객체 참조 모델을 사용하는 것이 단순 객체 모델을 사용하는 것보다 바탕글을 쉽게 알아볼 수 있음을 알 수 있습니다. C++의 경우는 늘 객체를 참조하때 이것이 포인터인지 일반 변수인지를 생각해야 하고, 함수에 인자로 객체를 넘길 경우 역시 포인터인지의 여부를 늘 프로그래머가 신경을 써 주어야 합니다. 여러모로 객체 참조 모형이 단순 객체 모형보다 훨씬 편리하다고 필자는 생각합니다.


결론적으로 말하자면, C/C++언어에서의 포인터는 편리하기는 하지만 오용 및 남용의 경우에는 무시할 수 없는 문제가 생기며, 따라서 비교정 안전성을 추구하는 Ada 나 기타 다른 언어에서는 포인터에 많은 제한을 가하고 있습니다. 물론 포인터가 나쁘다고만은 할 수 없지만, 무분별한 포인터의 사용은 좀 자제해야 할 것입니다. 특히 Object Pascal 의 경우에는 C/C++ 수준과 거의 비슷하게 포인터 연산이 가능하지만, 포인터를 사용하지 않고도 충분히 문제를 해결할 수 있다면, 굳이 포인터를 쓰지 않는 것이 좋을 것입니다.


포인터 만능론을 주장하는 사람들은 Java 나 Smalltalk 에 왜 명시적인 포인터 개념을 넣어놓지 않았는지를 생각해 봅시다. 흡사 이전 BASIC의 GO TO 논쟁처럼, 결국은 GO TO를 쓰지 않고도 프로그래밍이 가능하다는 것을 인정하기에는 꽤나 많은시간이 걸렸지만, Smalltalk 나 Forth 처럼 아예 GOTO 가 없는 경우도 있으므로, 포인터의 사용에 대해서도 신중을 기해야 할 것입니다.



Notes