Stack Overflow에는 프로그래밍 전반에 걸쳐서 다양한 질문들이 있고, 수준 높은 답변들도 상당히 많다. 훌륭한 답변이 달린 질문을 읽고, 해당 주제를 보다 심층적으로 공부해보는 것이 큰 도움이 될 것이라고 생각하여 이 시리즈를 시작하였다. 주제는 되도록 프로그래밍 전반에 걸친 범용적인 주제들로 선정하고자 한다. 


* 부족한 부분도 있고, 틀린 부분도 있을 수 있으니 발견하시면 댓글로 알려주시면 감사하겠습니다. 주제 추천도 받습니다.



What is the difference between char s[] and char *s?



<1>

C의 꽃이자 가장 헷갈리는 부분이 포인터이다. 그런데 배열 이름(정확하게는 배열 타입)은 포인터와의 유사성으로 인해 이 난해함을 더욱 증폭시킨다. 처음 c를 공부할 때 "배열 이름은 몇 가지 예외를 제외하고는 기본적으로 포인터와 같다. 일반적으로 배열 이름을 상수 포인터라고 지칭한다."라고 배운 바가 있다. 하지만 안타깝게도 예외가 '몇 가지' 정도가 아니라서 배열 이름을 상수 포인터라고 지칭하는 것은 많은 문제점을 야기할 수 있다. 이번 주제에서는 배열 이름과 포인터의 차이점을 중점적으로 살펴보고자 한다.



<2>

왜 '상수 포인터'라는 말이 붙었는지부터 살펴보자. 먼저 일반적인 포인터의 경우 


1
2
char * p = "hello";
= "world";
cs


와 같은 구문이 전혀 문제가 없이 동작한다. 즉 포인터가 가르키는 대상을 원하는대로 변경할 수 있다(물론 이 때 가비지가 발생하지 않도록 프로그래머가 신경을 써줘야 한다.). 하지만 배열은 다르다.


1
2
char a[] = "hello";
= "world";    // illegal!
cs


위와 같이 a가 가르키는 대상을 변경할 수 없다. 이런 특성 때문에 배열 이름을 상수 포인터라고 지칭하는 경우가 상당히 많다. 하지만 엄밀하게 따지면, 배열은 포인터가 아니다. 아래에서 자세히 살펴보자.



<3>

여기에 질문 답변식으로 비교적 상세하게 배열 이름과 포인터에 대해 다룬다. 또한 나무위키의 항목도 훌륭하게 정리되어 있다. 이들을 기반으로 배열 이름과 포인터 사이의 변환에 대해 정리하자면 다음과 같다.


배열 이름은 수식에서 다음의 예외를 제외하고는 해당 배열의 첫 번째 원소를 가르키는 포인터로 변환된다. 단, 이 때 변환되는 포인터는 lvalue가 아니다.

(lvalue와 rvalue에 대해서는 링크를 참조)


예외 1) sizeof의 피연산자일 때

예외 2) &의 피연산자일 때

예외 3) char arr[] = "hello"; 와 같이 초기화할 때


이 규칙을 기반으로 <2>를 생각해보면, 배열 이름은 'lvalue가 아닌 포인터'로 변환되므로 수식의 좌측에 놓일 수 없다. 따라서 배열 이름이 새로운 문자열을 가르키도록 변경할 수가 없다. 


자 이제 '배열은 배열이고 포인터는 포인터다. 수식에서 배열 이름은 위의 규칙을 기반으로 포인터로 변환된다'라는 걸 머리속에 집어 넣고 스택오버플로우의 질문을 들여다 보자.



<4>

이제 위에 링크한 스택오버플로우의 첫 번째 답변을 보면 다음과 같은 차이점이 보인다.


1) char a[] = "hello";

2) char * p = "hello";


\begin{center}\vbox{\input{cfaqs-ko-1.epic}
}\end{center}

 (그림 출처 : 링크)


1)는 읽기 전용 메모리에 "hello"를 쓰고, 이를 복사하여 배열 a에 새로 메모리를 할당한다. 따라서, s[1] = 'X'; 와 같은 쓰기가 가능하다. 반면에 2)에서는 읽기 전용 메모리에 "hello"를 쓰고, 이를 가르키는 포인터를 생성한다. 따라서 p[1] = 'X' 와 같은 변경이 불가능하다.


왜 이러한 차이가 발생할까? '배열은 배열이고, 포인터는 포인터' 이기 때문이다! 배열은 같은 타입의 원소들이 연속적으로 미리 할당되어 있는 메모리 공간이며, 포인터는 어떠한 종류이든지 그저 가르킬 뿐이다. 2)에서 포인터는 읽기 전용 메모리 영역을 가르키므로 수정이 불가능하게 되는 것이다. 반면에 1)과 같이 선언했더라도, 배열은 연속적이며 미리 할당된 메모리 공간을 보장한다. 따라서 읽기 전용 메모리 영역을 그저 가르키는 것이 아니라, 메모리 영역을 새로 할당 받게 되는 것이다. 당연히, 할당 받은 메모리 공간에서 수정이 가능하다.



<5>

그렇다면 함수에 사용 될때는 어떨까? 우선 반환될 때를 살펴보자. 

1
2
3
4
5
6
7
8
9
10
11
12
// This successfully returns "Hello, World"
char* function1()
{
    char* string = "Hello, World!";
    return string;
}
// This returns nothing
char* function2()
{
    char string[] = "Hello, World!";
    return string
}
cs

(출처 : 링크)



<4>를 바탕으로 추측을 해볼 수 있다. 먼저 function1()의 "Hello, World!"는 함수의 스택 프레임이 아니라 읽기 전용 메모리에 위치할 것이고, string은 이를 가르키게 될 것이다. 따라서 함수가 종료되어도 "Hello, World!"는 무사히 살아남게 되고 string도 정상적으로 "Hello, World!"를 가르킬 수 있다.


function2()에서는 어떨까? <4>에서 본 대로, "Hello, World!"는 복사되어 string 배열에 새롭게 할당될 것이고, 이는 지역 변수이므로 당연히 function2()의 스택 프레임에 위치하게 된다. 그리고 fucntion2()의 스택 프레임은 반환과 동시에 해제될 것이므로 반환 후에 string이 가르키는 곳에서는 아무것도 존재하지 않는 허상 포인터(dangling pointer)가 될 것이라고 예상할 수 있다.



<6>

흥미로운 점은 배열 이름이 함수의 매개변수로 사용되었을 때이다. 여기서는 특이하게도 배열 이름이 완전하게 포인터로 변환된다. 이 내용은 메인 질문의 두 번째 답변에 나와있다. 


1
2
void foo(char *x);
void foo(char x[]); // exactly the same in all respects

cs


매개변수로 사용했을 때의 한해서, 배열 이름은 포인터와 완전하게 동일하다. 배열 전체를 함수 매개변수로 넘겨주게 될 경우엔, 함수의 스택 프레임이 너무 비대해지는 문제점 때문에 포인터로 변환하는 것이 아닐까?(내 생각)



<7>

여기에 내가 정리하지 못한 예외가 더 있는지는 모르겠다. C는 함정이 빈번한 언어인데, 이를 제대로 공부하기란 여간 어려운 것이 아니다.(제대로 공부해본적은 없지만 아마도) 그래도 그 함정카드 중 하나인 배열 이름과 포인터의 관계를 이 포스트를 통해서 조금이나마 정리를 해보고자 하였다.


+ Recent posts