C++로 dp관련 알고리즘 문제를 풀 때, 아래와 같이 참조(reference)를 종종 이용하곤 했다.
void sol(int i,int j){
int& ref=s[i][j];
...
}
포인터와 유사한 듯한 이 참조는 포인터와 비교하여 어떤 차이가 있는지 궁금하여 알아보았다.
Pointer
우선, 포인터는 다른 변수의 메모리 주소를 가지고 있는 변수로, 아래와 같이 사용한다.
int a = 10;
int *p = &a;
포인터가 가리키는 메모리 위치에 접근하기 위해서는 * 연산자(operator)를 사용하여 디레퍼런스(dereference)하는 과정이 필요하다.
Reference
반면에, 참조 변수는 이미 존재하는 변수의 별칭(alias)이다. 내부적으로는 포인터와 같이 객체의 주소를 저장하도록 구현되었다. 한번 초기화되면 참조 객체를 변경할 수 없다는 점에서 const pointer로 볼 수도 있다.
int a = 10;
int &p = a;
차이점
이러한 포인터와 참조의 비교를 아래 표로 정리해보았다.
Pointer | Reference |
선언한 이후에 초기화할 수 있다. | 반드시 선언과 동시에 초기화해야 한다. |
NULL 값을 할당할 수 있고, 재할당(reassignment)이 가능하다. | NULL을 참조할 수 없고, 재할당도 불가능하다. |
* operator를 사용하여 dereference해서 참조 변수에 접근한다. | 단순히 이름만으로 접근이 가능하다. |
참조 변수의 제약 때문에 포인터를 사용하면 맞닥뜨릴 수 있는 예외 상황이 없다는 점에서, 참조변수가 포인터에 비해 유연하진 않지만 안전하다.
근데, 메모리에 데이터는 어떻게 쌓일까?
개념을 정리하다보니 위 질문에 대한 답이 궁금해졌다. 참조는 내부적으로 포인터와 유사하게 동작한다고 하니 메모리 스택에 메모리 공간이 할당되고, 주소값이 할당될 것이다. 이 가설을 증명하기 위해 에디터(Xcode)를 사용해서 직접 확인해보았다.
변수 a를 123값으로 초기화하여 선언하고, 포인터 변수 ptr을 선언하여 a를 참조하도록 했다. 유사하게 b와 참조변수 ref를 선언하였다.
int a=123;
int* ptr=&a;
int b=456;
int& ref=b;
포인터 변수 ptr에 저장된 값은 a의 주소값이다. 이는 아래와 같은 코드로 확인할 수 있다.
cout<<&a<<' '<<ptr<<endl;
// 0x7ffeefbff3a8 0x7ffeefbff3a8
또한, 참조 변수 ref에 저장된 값은 b의 주소값일 것이다. 하지만 이건 어떻게 코드로 확인할 수 있을까? 어셈블리 코드의 도움이 필요한 시점이다. (Xcode의 Assistant로 어셈블리 변환 코드를 쉽게 확인할 수 있다.)
int a=123;
// movl $123, -8(%rbp)
int* ptr=&a;
// leaq -8(%rbp), %rax
// movq %rax, -16(%rbp)
int b=456;
// movl $456, -20(%rbp)
int& ref=b;
// leaq -20(%rbp), %rax
// movq %rax, -32(%rbp)
포인터 변수와 참조 변수의 선언부를 어셈블리로 변환한 코드는 동일한 형태를 보인다. (컴파일러에 따라 변환된 어셈코드는 상이하다.) 즉, 참조 변수가 내부적으로 포인터 변수처럼 동작한다는 증명이 된 것이다. (포인터 변수와 다르게 참조 변수는 컴파일 시에 자동으로 dereference되어 참조하는 변수에 접근하게 된다.)
어셈블리어 코드에서 b와 ref의 주소값 차이를 보면 12임을 알 수 있고, 포인터의 포인터로 접근하여 타입캐스팅을 해주면, 참조 변수 ref에 저장된 값이 b의 주소값이라는 것은 아래 코드로 확인할 수 있다.
cout<<&b<<' '<<*(int**)(&b-3)<<endl;
// 0x7ffeefbff39c 0x7ffeefbff39c
즉, 참조 변수도 포인터 변수와 동일하게 같은 크기의 메모리 공간을 사용하여 참조하는 변수의 주소값을 저장한다.