본문 바로가기
C,C++/C++

[C++] 포인터와 레퍼런스(참조)의 차이를 이해해보자

by woohyeon 2019. 12. 5.
반응형

C++에는 포인터(Pointer)와 레퍼런스(Reference)라는 개념이 있다. 포인터는 C 에도 있었던 개념이며 레퍼런스는 C++ 에서 등장한 개념이다. 언뜻 보면 용도가 비슷한데 정확히 어떤 차이점이 있는지, 내부적으로 어떻게 동작하는지, 왜 레퍼런스가 나오게 됐는지에 대해서 정리해보자.

포인터에 대해 헷갈려 하시는 분들을 위해 간단하게 용어 정리를 해보자면.. 포인터는 변수의 주소를 저장하는 특별한 타입의 변수이다. 주소값 자체를 포인터라고 생각해도 좋다. 주소만을 저장할 수 있는 변수를 포인터 변수라고 하고 일반적인 변수 선언과는 다르게 자료형에 * 표시를 붙여 선언한다. int* p; 는 int형 변수의 주소를 저장하는 포인터 (변수) p를 선언한 것이다. 포인터, 포인터 변수 다 같은 말이다.

포인터와 레퍼런스의 차이는 함수의 매개변수로의 전달 방법으로 알아볼 것이다. 매개변수를 단순 값 타입(call by value) 로 전달했을 경우 함수에선 단순히 복사된 값을 사용하기 때문에 원본에 접근하지 못한다. 만약 포인터 또는 레퍼런스로 전달하면 함수에서도 주소를 통해 원본에 접근하여 값을 읽거나 수정할 수 있도록 해줄 수 있다. 우선 매개변수로 전달하는 방법에 대해 간단한 차이 먼저 보자.


[표면적인 차이]

포인터 사용 시(call by address) 다음과 같이 매개변수를 포인터로 주고 함수 호출 시 주소를 전달한다.

void func(int* ptr) { *ptr *= 2; }

int num = 3;
func(&num); // call by address


그러면 함수에선 전달 받은 주소를 통해 주소가 가리키는 메모리 공간에 가서 값을 수정하거나 읽을 수 있게 된다.
이것도 사실 단순한 (주소)값의 복사이기 때문에 주소가 가리키는 공간에 접근할 수 있는 것 뿐이지 더블포인터를 쓰지 않는 이상 포인터가 가리키는 대상(주소) 자체를 변경할 순 없다.



다음은 레퍼런스이다.
레퍼런스로 전달 시(call by reference) 다음과 같이 매개변수를 참조형 변수로 주고 함수 호출 시 lvalue를 전달한다.

void func(int& a){ a *= 2; }

int num = 3;
func(num); // call by reference

 

참고로 매개 변수에 있는 &(ampersand) 연산자는 변수의 주소값을 얻을 때 사용하는 & 연산자와 다르다. 위 함수처럼 레퍼런스 형태로 변수를 받게 되면 매개변수 a를 통해 num 값(원본)에 접근할 수 있게 된다. 사실 정확히 말하면 int& ref = num 과 같이 참조 변수를 만들 경우 ref라는 변수는 따로 메모리 공간에 할당되지 않는다. (그런데 밑에서 살펴보겠지만 어셈 레벨까지 내려가면 어차피 포인터기 때문에.. 변수 자체는 동일한 주소를 가지지만 실제론 레지스터를 사용할 수 밖에 없고 그러면 메모리를 사용하긴 하는 것 같기도..)

int* ptr = &num
위와 같이 포인터는 포인터 변수 ptr이 stack에 4바이트 공간으로 존재하고, 변수 num의 주소를 ptr이 가지고 있는 것이다. 그냥 둘은 서로 다른 변수이고, ptr은 num의 주소를 저장하고 있을 뿐 그 이상 그 이하도 아니다. 반면 참조자 ref는 포인터처럼 변수 개념으로 따로 생성되는 것이 아니다.
num이라는 변수의 메모리 공간에 ref 라는 이름만 하나 더 생긴다고 생각하면 된다. 

즉 다음과 같이 포인터 변수와 가리키는 대상의 주소값은 같더라도 포인터 변수의 주소값과 대상의 주소값은 다르다. (포인터를 이해하고 있다면 당연한 결과다.)

ptr1 == &num  
&ptr1 != &num


하지만 레퍼런스는 동일하다.

ref == num    
&ref == &num

 


[내부적인 차이]
한편, 위에서 살펴 보았을 때 두 개의 전달 방식이 차이가 조금 있었는데 내부적으로도 다른지 살펴보자. vs2019의 디스어셈블 기능을 활용한다.

우선 테스트 코드는 다음과 같이 간단하다. 포인터와 레퍼런스를 인자로 받는 함수를 정의하고, 함수를 호출할 때 인자를 전달하는 과정에서 두 방법이 각각 내부적으로 어떤 차이가 있는지 볼 것이다.

 

위 코드에서 8라인의 함수 호출 단계를 어셈블리어로 변환하면 아래와 같다.

위 코드를 모두 이해할 필요는 없다. 첫 네줄만 보면된다.

lea eax, [num]은 num의 주소값을 eax 레지스터에 저장한다는 의미이다.
push eax는 eax 레지스터에 저장된 값을 스택에 저장한다는 의미이다.

위 코드의 대략적인 의미는 변수 num을 주소 또는 레퍼런스로 전달하기 위해, 주소를 스택에 저장하는 명령이다. 그러면 함수 내에서 스택에 올라온 주소를 사용할 수 있다. 참고로 함수의 인자들은 우측에서 좌측으로 읽힌다. 따라서 첫줄은 2번 째 인자에 관한 코드이다.

2번째 인자를 레퍼런스로 넘기기 위해 준비되는 과정은
00A61D39     lea eax, [num]
00A61D3C    push eax
이고

1번째 인자 주소를 넘기는 과정도
00A61D3D    lea ecx, [num]
00A61D40    push ecx
이다.

레퍼런스와 포인터로 전달한 두 과정을 까보니 과정이 동일하다.
(eax와 ecx는 저장되는 레지스터 종류 차이기 때문에 지금 중점에선 상관없는 문제다.)

어쨌든 주소를 전달하나 레퍼런스를 전달하나 내부적으로 동일한 2단계를 거친다는 소리고 전달 방식이 겉으론 달라보여도 내부적으론 똑같다는 소리다.


그럼 결과적으로 포인터와 레퍼런스는 동일하게 처리되는데 레퍼런스가 등장한 이유는 무엇일까?
먼저 포인터의 위험성 2가지를 살펴보자.

첫번째.
기본적으로 포인터는 주소를 가리키지만 다음과 같이 널을 가리킬 수도 있다.

int* ptr = nullptr;


만약 nullptr를 가리키는 포인터를 역참조하면 어떻게 될까?

nullptr(널포인터)는 유효하지 않는 객체라는 NULL(value 타입)의 객체를 가리키는 상수이다.
(실제  값은 다르지만 의미는 비슷하다. NULL은 0과 동일)

유효하지 않는 공간을 가리키게 되면 당연히 에러를 발생시키며 프로그램에 문제를 줄  수 있다.
(세그멘테이션 오류, 프로그램 충돌 등)


두번째. 
인자에 주소를 담아 call by address로 함수를 호출했다고 가정해보자.
호출된 함수에선 포인터를 통해 주소가 가리키는 값에 접근할 수 있다.

그런데 포인터가 강력한 이유는 전달 받은 주소에 덧셈뺄셈하면서 다음과 같이 다른 주소에도 자유롭게 접근이 가능하다는 것이다.

void func(int* ptr)
{
   *(ptr) = 3;
   *(ptr+1) = 4;
   *(ptr+2) = 5;
}

int arr[10]{};
func(arr);


이 말은 반대로 생각하면 포인터를 통해 할당되지 않은 메모리 공간 또는 다른 용도로 사용되고 있는 메모리 공간에 임의로 접근할 수 있다는 의미이다. 100번지에 절대 없어지면 안되는 값을 저장해놨는데 포인터를 통해 실수로 100번지의 값을 건드릴 수 있다는 얘기이다.

그래서 이러한 위험은 없애고 포인터는 그대로 사용할 수 있도록 만들어진 것이 레퍼런스이다.


일단 레퍼런스는 다음과 같이 아예 NULL을 참조할 수 없다.
레퍼런스는 아까 어셈블리어에서 살펴본 것과 같이 우변의 주소값을 할당하기 때문에 우변이 상수이면 안된다. NULL은 0이다. 

int& ref = NULL; // 불가



또한 레퍼런스는 다음과 같이 참조로 전달을 받아도 함수 내에서 절대 원본(a: 값이 아닌 대상)을 변경할 수 없다.

void func(int& ref) {}

int a = 3;
func(a);

 

따라서 다른 메모리 상의 위치에 접근할 수가 없다.

결국 레퍼런스는 포인터를 위험하게 사용하는 경우가 많기 때문에 안전하게 사용할 수 있도록 만든 개념이다.
포인터와 같이 주소를 통해 원본에 접근하는 핵심적인 기능을 가졌지만, 가리키는 대상, 원본, 주소를 변경하지 못하게 막은 것이라고 볼 수 있다.


그렇다면 언제 포인터를 사용하고 언제 레퍼런스를 사용할까? 
이 부분은 상황에 따라 다를 수 있다. 무조건 맞는 것도 아니고 참고만 하는 것이 좋을 듯.

일단 가리키는 대상을 바꿀 수 있어야 한다. (ex: iterator, linked list 등) 그러면 포인터

가리키는 대상이 절대 변하지 않는다. 변하면 안된다.
(ex: 함수에 결과 값을 저장하는 변수를 전달할 때, 해당 객체를 변경없이 참조만 할 때) 그러면 레퍼런스



해당 내용은 다음 유튜브(포프TV)를 보고 정리하며 쓴 글입니다.
https://youtu.be/lRUiaigGIDA

 




댓글