brunch

매거진 Sinclair

You can make anything
by writing

C.S.Lewis

by Sinclair Feb 13. 2016

배열과 포인터: X-files  포인터

늬들이 포인터를 알아? 진실은 저 너머에 V




자, 드디어 기다리던 포인터 이야기를 하게되었습니다.


제가 C 프로그래밍 강의를 하기 전에 그 동안 C 언어를 공부하면서 가장 어려웠던 내용을 설문 조사를 하면 누구나 손꼽는 것이 바로 포인터입니다. 여러분들도 포인터 때문에 고생 고생하다가 여기까지 온 거 아닌가요? 아니면 다행입니다만 그건 아직 hello world 말고는 다른 건 안 짜봤기 때문이죠?


저는 포인터가 싫어서 포인터가 없다고 하길래 Java를 배웠습니다.

그러나 그렇게 굳게 믿었던 Java에서 NullPointerException를 만나 한참을 고생한 이후에는 그곳에도 포인터가 없는 게 아니었다고, Java에게 마저 사기 당했다고 좌절하면서 프로그래머의 길을 고이 접었던 사람이 바로 이 { Sinclair ˚C* }를 쓰고 있습니다.


포인터 때문에 좌절했지만 지금은 생각이 많이 바뀌었습니다.

사실 포인터가 어려운 이유는 포인터가 특별하다고 생각하기 때문이었습니다. 제가 아는 한 포인터는 전혀 특별하지도, 어렵지도 않습니다.


프로그램을 좀 작성해 본 사람이라면 어느 누구도 일반 변수를 어렵다고 생각하지 않습니다. 그처럼 포인터도 일반 변수랑 다르지 않고 똑같습니다. 비트 박스를 잘하려면 "북치기", "박치기" 두 가지만 기억하면 된다는 것을 이젠 전국민이 거의 다 알고 있습니다. 비트 박스처럼 포인터를 잘 사용하려면 "초기화", "사이즈" 이 두 가지만 기억하면 됩니다.


사실 저는 이것을 모든 프로그래머가 다 알았으면 좋겠습니다. 포인터와 관련된 문제와 에러의 99%, 아니 100%가 이 두 가지만 기억하면 해결됩니다.


프로그램을 10년 가까이 작성한 대기업의 개발자들도 모여서 "내 코드 내의 대량 살상무기"라는 주제로 세미나를 하는데 그 내용이 포인터의 초기화 문제와 메모리 사이즈 문제였습니다. 잘못 작성한 코드가 다른 사람을 죽일 수도 있다는 사실을 기억해야 합니다. 프로그램으로 바둑두고 프로그램으로 운전하고 프로그램으로 전쟁하고 프로그램으로 사람을 고치는 세상입니다.


그렇다면 Java나 Pascal처럼 포인터 연산자가 아예 없는 언어도 있는데 왜 C언어는 포인터를 이토록 고집(?)하고 있는 걸까요? 포인터 연산자가 없다고 Java가 할 일을 못하는 건 아닙니다. 포인터 연산자도 없이 태어난 지 20 여년 만에 이렇게 멋지게 성장하지 않았습니까?


하지만 이미 앞에서 우리가 배워 알고 있는 것처럼 모든 프로그램은 메모리에 올라가야 실행됩니다.

그렇기 때문에 모든 프로그램의 요소들은 OS(운영체제)에 의해 관리되는 정해진 메모리의 주소 값을 갖습니다.


C언어는 함수를 호출할 때 값을 직접 전달하는 call by value를 지원한다고 했습니다.

일반적으로 call by value 함수 호출 방식은 메모리를 많이 사용하게 되고 호출된 함수에서 변경한 인자 값이 호출한 함수로 전달되지 않는 단점이 있습니다. 이러한 문제점들은 함수를 호출할 때 인자 값이 아닌 포인터를 이용하여 인자의 주소 값으로 호출하면 해결할 수 있습니다. 그래서 포인터 연산자를 간접 전달 방식이라고 부르기도 합니다.

프로그램이 실행되는 공간인 메모리가 번지(주소)값을 갖고 그것을 담을 수 있는 것이 포인터라는 그릇이라면 그것을 잘 이해하고 아는 것이 프로그램을 좀더 유연하게 작성하는데 힘이 되지 않겠습니까?


옛말에 선무당이 사람 잡는다고 했습니다. 그 동안 어설프게 대충 알아 매번 우리를 힘들게 했던 것은 버리고 올바르게 포인터를 알아가도록 하겠습니다. C언어로는 모든 메모리를 직접 관리해야 하는 OS(운영체제)도 만들 수 있지만 포인터 연산자가 없는 순수 Java로는 메모리를 직접 핸들링 할 수 없습니다.



포인터 연산자도 배열 연산자처럼 선언과 수식 두 가지 용도와 의미로 사용됩니다.

포인터 연산자는 선언할 때는 주소 값을 담기 위한 타입이지만 수식에서는 배열 연산자와 같이 메모리를 참조하는 연산자 입니다. (∴ 정리 1번이 성립합니다.) 이게 왜 그러냐고 물으면 저는 K&R, 그분들이 그렇게 만들었다고 이야기할 수 밖에 없습니다.

우선 우리는 포인터가 등장하면 선언할 때 사용한 * 의 개수만큼 초기화를 해야 한다는 사실을 기억하면 됩니다. 초기화 하지 않은 포인터 변수를 수식에서 사용하는 것은 아주 위험한 일입니다.



일반변수


선언하기:

type variable ;  

// type형 데이터를 저장할 공간을 sizeof(type) byte(s)만큼

// 메모리 할당하고 variable이라 붙인다는 의미 입니다.


초기화:

variable = 값 ;

variable = 수식 ;

variable = 함수() ; // 리턴 받기

함수(&variable) ;  // 함수 인자로 세팅하기

// ∵ Call by value 잖아요~


초기화하지 않고 사용하다 망하기:

int intData ;

printf("%d" , ++intData) ;



포인터변수


선언하기:

type * pointer ;

// type *형 데이터(type형 데이터의 주소 값 ∵ 정리 3번)를 저장할 공간

// sizeof(type *) byte (s)만큼 메모리 할당하고

// pointer라 붙인다는 의미입니다.

// *은 선언할 땐 정말 타입입니다.


초기화:

pointer = 값 ;

pointer = 수식 ;

pointer = 함수() ;

함수(&pointer) ;  // 함수 인자로 세팅하기

// 아, 제가 포인터는 일반 변수랑 같다고 얘기 했나요?

// C 언어는 Call by value 인 것도 제가 말했나요?


초기화하지 않고 사용하다 망하기:

int * iPointer ;

printf("%d" , *iPointer) ;

// 단항연산자 *은 선언할 때를 제외한 모든 경우에서 메모리를 참조하는 수식



아래에 있는 코드는 어마어마하게 큰 문제점을 안고 있지만 컴파일 에러도 발생하지 않으며, 또한 대부분의 경우 아무런 문제 없이 잘 동작하고 있습니다. 많은 분들이 잘만 돌아가는데 대체 뭐가 문제냐고 제게 오히려 반문합니다.


#include <stdio.h>

/*

* copyleft ⓛ 2006 - 2017 programmed by Sinclair

*/


int main() {

    int i = 0 ;

    char * name ;

    while(-1) {

        gets(name) ; // 그나마 이것은 gcc에서는 워닝이 나줍니다.

        // scanf("%s" , name) ;

        // 어떤 워닝이나 에러 없이 컴파일 됩니다. 그래서 더 무서운 놈입니다.

        // 요즘은 VS도 에러를 내줍니다.

        // 쓸데없는 비표준 함수를 내세우며

        // 그걸 쓰라고 강요합니다.

        if(!*name) break ; // 그냥 엔터를 치면 루프 종료

        puts(name) ;

        i++ ;

    } // end while

    printf("%d%s" , i , " name(s), I have...\n") ;

    return 0 ;

} // end main()



 

우리는 변수를 선언했다면 사용하기 전에 반드시 초기화를 해야 합니다.

하늘이 무너져도 반드시 해야합니다.



int intData ; // intData 방이 만들어집니다.

printf("%d" , ++intData) ;



위의 코드가 잘못 되었다면, 동일하게


int * iPointer ;

// iPointer 방이 만들어집니다. *iPointer 방이 절대 아닙니다.

printf("%d" , *iPointer) ;



이 코드 역시 잘못된 것입니다. 일반 변수건 포인터 변수건 초기화하지 않고 사용하는 연산자는 그저 피 연산자와 연산자의 잘못된 만남일 뿐입니다.


하지만 대부분 포인터 연산에서 문제 없이 동작하는 것처럼 보이는 까닭은 대부분의 OS(운영체제)는 메모리를 사용하고 정리할 때 메모리를 청소하거나 지우지 않기 때문입니다. OS(운영체제)는 할 일이 산더미처럼 쌓여 있는데 한가로이 다 사용한 메모리를 초기화하며 지우고 있을 시간이 없습니다.


당연히 초기화는 아주 중요한 문제 입니다. 초기화할 때는 주로 대입 연산자를 사용하며 대입연산자는 양쪽 피 연산자들의 타입이 같아야 합니다.


그렇다면 포인터 변수는 어떻게 초기화 해야 할까요? 포인터 변수가 일반 변수와 같다면 일반 변수 초기화 하듯이 하면 되지 않을까요?

앞에서 정리했던 것처럼 일반 변수는 상수 값, 수식, 함수 호출로 초기화합니다. 물론 세 가지가 함께 섞여 사용되기도 합니다만, 일반적으로 세 가지로 정리됩니다. 마찬가지로 포인터 변수도 똑같습니다.



pointer = (type *)상수값 ;


일반 변수가 데이터 값을 담기 위한 그릇이라면 pointer 변수는 주소 값을 담기 위한 그릇입니다.

pointer 변수에 값으로 초기화 하려면 주소 상수 값이 존재해야 합니다.

하지만 실제로 그런 주소 상수 값이 존재하지 않습니다. 그래서 사용하는 방법이 캐스팅입니다. 캐스팅을 해서 초기화 했다 하더라도 문제는 남아 있습니다. 대부분의 경우 OS에서 일반 애플리케이션 프로그램이 사용하는 메모리 주소 값은 상대 번지입니다. 상대 번지일 뿐만 아니라, 그 값이 실행될 때 마다 자주 변하기도 합니다. 이렇게 실행할 때마다 변하니 상수 값으로 줄 수 없을 뿐 아니라 상수 값으로 줘도 소용이 없습니다. 그래서 이 초기화 방법은 일반 애플리케이션 프로그램을 만들 때는 거의 사용하지 않습니다. 다만 OS(운영체제)의 핵심 부분인 커널이나 디바이스 드라이버를 프로그래밍 할 때는 사용하기도 합니다.



pointer = 수식 ;


주소 값이 결과로 나오는 수식이 있습니까? & 단항 연산자가 메모리의 실제 상대 주소를 알려준다고 했습니다. 타입이 있는 포인터 또는 주소 값에다 정수를 더하거나 빼면 그것도 주소 값입니다. 반드시 정수만 가능합니다. "포인터 ± 정수"는 "포인터 ± sizeof(type) * 정수"번지가 됩니다. 또 다른 방법은 이미 할당된 메모리의 다른 주소를 알아오는 방법입니다. 주소를 물어온다고 표현합니다. 포인터와 할 수 있는 다른 연산은 바로 포인터에서 같은 타입의 포인터를 빼는 일입니다. 결과는 두 주소 값 사이에 존재 할 수 있는 데이터의 최대 개수가 부호 있는 값으로 나옵니다.


    printf("%d\n" , (long *)1000 - (long *)600) ; /* 100 */

    printf("%d\n" , (long *)1000 - (long *)603) ; /* 99 */

    printf("%d\n" , (long *)1000 - (long *)605) ; /* 98 */

    printf("%d\n" , (long *)1000 - (long *)1399) ; /* -99 */

    printf("%d\n" , (long *)1000 - (long *)1403) ; /* -100 */

    printf("%d\n" , (long *)1000 - (long *)1405) ; /* -101 */



pointer = 함수() ;


주로 메모리 동적 할당할 때 사용하는 방법입니다.

메모리를 직접 할당한다고 표현합니다.


이 방법은 우리가 단순히 알고 있는 것 보다 생각해야 할 것이 조금 많습니다. 그래서 나중에 advanced pointer의 첫 부분, 동적 할당에서 자세히 다루겠습니다.

이렇게 초기화 할 때 주로 사용하는 함수들이 바로 malloc(), calloc(), realloc() 함수들이며 이 세가지 함수들을 사용했다면 메모리영역을 다 사용한 후에 반드시 free() 함수를 통해 해제해 주어야 합니다.

free() 함수는 OS(운영체제)에게 이제 이 메모리 영역을 다 사용했다고 알리는 것뿐입니다. 그 영역을 깨끗이 청소하거나 하지는 않습니다.

무엇보다 가장 큰 문제는 free() 함수로 해제하지 않아도 아무런 문제가 발생하지 않는다는 것입니다. 컴파일도 되고 실행도 아주 잘(?) 됩니다. 그래서 free() 함수 호출을 잊는다는 것은 언제 터질지 모르는 무시무시한 시한 폭탄을 껴안고 잠드는 것과 같습니다. 때때로 프로그램이 끝나도 해제되지 못한 채 메모리를 계속 차지하고 있기 때문입니다. 나중에 다시 사용하거나 그곳의 위치를 알아 낼 수 있는 방법이 거의 없습니다. 이 프로그램을 계속 반복해서 실행한다면 메모리가 부족하게 되는 사태를 맞이합니다. 뛰어난 OS(운영체제)라면 그 문제를 알아서 처리 할 수도 있습니다. 또는 메모리가 부족하다고 팝업 다이얼로그 창을 띄어 사용자에게 조치를 취하도록 해주기도 합니다. 물론 그 때 사용자가 할 수 있는 유일한 일이라고는 컴퓨터를 껐다 다시 켜는 일뿐입니다만, 메모리가 부족해서 프로그램이 더 이상 실행될 수 없는 것을 어쩌겠습니까? 때문에 반드시 목숨을 걸고(?) free()를 해야 합니다.


혹시 free()를 했더니 실행 에러가 나는 경우를 보셨습니까? free()를 했더니 에러가 난다구요? 에이 설마 하겠지만 정말입니다. 그 실행 에러를 고치려고 노력하다가 나중에 잡지 못하고 결국 조용히 free()를 지웁니다. 그러면 에러가 감쪽같이 사라지고 실행이 잘 되는 것처럼 보입니다. 하지만 나중에 더 큰일이 발생합니다. 무선 휴대용 단말기나 embedded 개발 환경이라면 기기 자체가 꺼지는 경우도 빈번하게 발생합니다. 뭣 좀 하다 보면 자꾸 꺼집니다. 우쒸~ 이거 왜이래? 이미 늦었습니다. 그래서 이러면 안됩니다. 나쁜 사람이 됩니다. 때문에 다른 글타래에서 좀더 상세하게 설명하겠습니다.


간혹 free() 안 한 것을 나중에 알아낼 수 있는 방법이 있냐고 묻는 분들이 있습니다. 현재 표준 라이브러리 함수에서는 불가능합니다. 하지만 다양하고 다른 라이브러리가 존재하고 있습니다. 또는 아예 OS(운영체제)에서 free()를 안 하더라도 전혀 문제가 없도록 자동해제, 일명 garbage collection(쓰레기수집)을 지원할 수도 있습니다. 그리고 C++에서는 smart pointer라는 놈이 등장해서 free()함수를 호출하거나 delete연산을 하지 않아도 됩니다.


점점 프로그래밍 하기 좋은 세상이 우리에게 다가 오고 있는 걸까요? 대학에서 저를 가르치셨던 교수님 한 분이 20년 정도 지나면 더 이상 프로그래밍을 안 해도 될 거라고 하셨더랬는데, 지금 여러분들과 저를 한번 보면 어떻습니까? FORTRAN, COBOL, Pascal 등과 함께 21세기가 되면 역사의 뒤안길로 사라질 줄만 알았던 마흔넘은 C언어가  버젓이 살아서 우리를 괴롭히고(?) 있습니다. 뭡니까 이게? 교수님 나빠요.






#Sinclair #씽클레어 #싱클레어 #씽클레어도씨 #씨언어 #씨프로그래밍  #C언어 #Cprogramming #C_Programming #C #Programming #Clanguage #C_Language

매거진의 이전글 배열과 포인터: X-files 포인터
브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari