본문 바로가기
프로그래밍/C & C++

[C++] 참조자(Reference)에 대해서

by 별준 2020. 11. 24.

우리는 변수(Variable)이 할당된 메모리 공간을 지칭하는 것, 즉 메모리 공간에 붙여진 이름이라는 것을 알고 있습니다.

그리고 C++에서 처음 도입된 참조자(Reference)는 메모리가 할당된 변수에 또 다른 이름, 즉 별명을 붙이는 것이라고 할 수 있습니다.

 

이번에 C++에서 사용되는 참조자의 특징에 대해서 한 번 알아보겠습니다.

 

1. 참조자의 사용

참조자는 '&'를 사용해서 선언할 수 있습니다. 다만 주의해야할 점은 선언과 동시에 정의를 해주어야 하고, 상수는 참조할 수 없습니다.(뒤에서 다시 설명하겠습니다.)

int main()
{
    int a = 10;
    int& ref_a = a;

    printf("a : %d\n", a);
    printf("ref_a : %d\n", ref_a);

    printf("address of a : 0x%X\n", &a);
    printf("address of ref_a : 0x%X\n", &ref_a);

    return 0;
}

변수 a를 정의하고 10이라는 값을 넣어주었고, a의 참조자인 ref_a를 정의하였습니다. 이렇게 참조자를 사용하고 싶다면, '&'를 사용하면 됩니다.

int main()
{
    int a = 10;
    int& ref_a = a;

    printf("a : %d\n", a);
    printf("ref_a : %d\n", ref_a);

    printf("--- change value of ref_a ---\n");
    ref_a = 20;

    printf("a : %d\n", a);
    printf("ref_a : %d\n", ref_a);

    return 0;
}

그리고, a의 참조자 ref_a에 값을 변경하면, 변수 a의 값도 변경된 것을 확인할 수 있습니다.

즉, 포인터와 유사한 개념이라고 볼 수 있죠. 하지만 포인터와 차이점이 몇 가지 있는데, 아래에서 계속 알아보도록 하겠습니다.

 

우선 참조자는 반드시 선언할 때, 어떤 변수의 참조자인지 정의를 해주어야 합니다.

(포인터와 다르게 null도 참조할 수 없습니다)

위와 같은 경우에는 에러가 발생합니다.

 

또한, 어떤 변수의 참조자로 정의가 되었다면, 더 이상 다른 변수를 참조할 수 없게 됩니다.

int main()
{
    int a = 10;
    int b = 20;

    int& ref_a = a;
    ref_a = b;

    return 0;
}

처음에 a를 참조하도록 설정을 했다면, 7 line에서 ref_a = b 는 a에 b의 값을 할당한다는 의미가 됩니다.

(반면에 포인터는 a를 가리키도록 했다가 b를 가리키도록 변경할 수 있죠)

 

2. Reference는 메모리상에 존재하지 않을 수도 있다.

일단 포인터에 대해서 먼저 이야기해보도록 하겠습니다. 

int main()
{
    int a = 10;
    int *p = &a;

    return 0;
}

만약 포인터 p를 정의한다면, p는 메모리 주소를 저장할 수 있는 공간을 할당받게 됩니다. Visual Studio에서 쉽게 확인하실 수 있습니다.

p의 주소 0x00EFF954로 a의 주소값을 저장하기 위한 공간이 존재하게 됩니다.

참조자는 어떨까요 ?

int main()
{
    int a = 10;
    int *p = &a;
    int& ref_a = a;

    return 0;
}

ref_a의 주소는 a와 동일합니다. ref_a는 a를 가리키는 다른 이름이기 때문에, 컴파일러 입장에서는 그냥 a와 동일한 녀석으로 인식하는 것이죠. 따라서 참조자는 메모리 상에 존재하지 않게 됩니다.

 

하지만, 항상 메모리 상에 존재하지 않는 것은 아닙니다.

바로 함수 인자를 참조자로 전달받게 되면, 메모리가 할당되게 됩니다. 즉, call by reference를 하게 되면 메모리가 할당되게 되는데, 실제로 할당이 되는지 확인해보도록 하겠습니다. 

void Swap(int& a, int& b)
{
    int temp = a;
    a = b;
    b = temp;
}

int main()
{
    int a = 10;
    int b = 20;

    printf("--- Before Swap ---\n");
    printf("%d\n", a);
    printf("%d\n", b);

    Swap(a, b);

    printf("--- After Swap ---\n");
    printf("%d\n", a);
    printf("%d\n", b);

    return 0;
}

기본적인 Swap 함수입니다. 기본적으로 함수의 매개변수는 call by value로 전달되지만, 전달되는 값이 실제 값이냐 주소냐에 따라서 call by value와 call by address(call by reference)로 나뉘게 됩니다. 

(call by address와 call by reference는 컴파일러 입장에서는 둘 다 포인터로 전달받는 것으로 거의 동일합니다)

 

그리고 이렇게 전달받는 주소값은 호출되는 함수의 Stack Memory에 저장이 되어서 사용됩니다. 

즉, 참조자로 매개변수를 전달받는다고 하더라도, 전달받는 a와 b의 주소값이 Swap 함수의 Stack Memory에 저장이 되어서 사용된다는 의미입니다. 따라서, 참조자를 통해서 인자를 받지만, 이전과는 다르게 메모리 공간이 할당이 된다는 것이죠.

실제로 디버깅을 통해서 확인해보도록 하겠습니다.

main 함수에서의 a,b의 주소

main함수에서의 변수 a와 b의 주소가 위와 같이 찍히고 있습니다. 이제 Swap 함수 내부로 들어가면 어떻게 되는지 살펴보죠.

Swap 함수 내에서의 a,b의 주소

a와 b의 주소가 main에서의 주소와 동일하게 찍히고 있습니다. 

그렇다면 참조자를 사용했을 때, 메모리 할당이 되지 않고 main에서 할당된 주소를 그대로 사용하는 것이 아니냐고 할 수 있는데, Swap 함수의 Stack Memory 공간을 보시면 메모리 할당이 되었다는 것을 쉽게 확인할 수 있습니다.

(Visual Studio의 Watch로는 확인할 수 없었고, Memory 창을 사용해서 확인했습니다.)

 

Swap 함수의 register를 확인해보시면, ESP(Extended Stack Pointer), EBP(Extended Base Pointer)가 있는데 바로 Stack Memory의 가장 위에 있는 데이터를 가리키는 포인터와 현재 Stack Memory에서 가장 바닥에 있는 데이터를 가리키는 포인터를 의미합니다.

대강 현재 Stack에 존재하는 데이터가 저 사이에 있다고 보시면 됩니다.

그래서 0x00B7F584를 찾아 아래로 내리면서 살펴보면...

무언가 익숙한 주소값이 보입니다.. !

이렇게 주소값을 저장하는 공간이 할당되었고, 이 공간이 참조자에 의해서 할당된 메모리 공간입니다.

 

3. 상수에 대한 참조자는 불가능하다

글 초반에 참조자는 선언하면서 정의해야하지만, 상수는 할당할 수 없다고 이야기를 했습니다.

상수는 리터럴 값인데, 메모리 측면에서 살펴보면 리터럴 값은 text segment에 정의가 되며 (컴파일러에 따라서 다르긴 하지만) 이 메모리 공간은 읽기만 가능한 곳입니다. 

하지만, 참조자가 사용이 가능하다면, 이 공간을 참조해서 변경을 시도할 수 있기 때문에 상수를 참조하는 것은 금지됩니다.

 

이 경우에는 const와 같이 사용하게 되면, 사용이 가능합니다.

const int& c = 5;

 

4. 참조자의 배열 / 배열의 참조자

우선 참조자의 배열은 불가능합니다.

int main()
{
    int a = 10;
    int b = 20;

    int& ref_arr[2] = { a, b };

    return 0;
}

위 코드를 컴파일해보면, C2234: 'ref_arr' : arrays of references are illegal 이라는 에러문구를 보실 수 있습니다.

우선 C++에서 배열이 어떤 식으로 구성되는지 살펴보면, 배열의 이름은 첫 번째 원소의 주소값으로 변환이 되어야합니다. 그리고 *(arr + 1)과 같이 다음 원소에 접근할 수 있습니다. 따라서 배열은 무조건 주소가 존재해야하는데, 참조자의 경우에는 메모리 상에 존재하지 않을 수 있습니다. 이런 이유로 인해서 참조자들의 배열을 정의하는 것은 불가능합니다.

 

다만, 배열들의 참조자는 가능합니다.

int main()
{
    int arr[3] = { 1, 2, 3 };
    int(&ref_arr)[3] = arr;

    cout << arr[0] << arr[1] << arr[2] << endl;
    ref_arr[0] = 4;
    ref_arr[1] = 5;
    ref_arr[2] = 6;
    cout << arr[0] << arr[1] << arr[2] << endl;

    return 0;
}

위와 같은 방식으로 정의가 가능하며, 정상적으로 동작하게 됩니다. 주의해야할 점은 반드시 배열의 크기를 명시해주어야 합니다.

 

 

5. 참조자를 리턴하는 함수

int& function()
{
    int a = 5;

    return a;
}

int main()
{
    int c = function();

    printf("%d\n", c);

    return 0;
}

위와 같이 참조자를 리턴하는 함수는 어떨까요 ?

함수를 호출할 때, 매개변수의 전달이나 반환값의 전달은 기본적으로 '복사'를 통해서 이루어집니다. 

즉, function 함수를 호출할 때, function 함수 Stack에 변수 a가 할당되고 값이 저장되며, 반환할 때에는 function 함수의 a값이 복사되어서 main 함수의 변수 c로 전달되는 것이죠.

 

일단 위의 코드는 visual studio에서는 오류없이 동작은 됩니다. 경고는 발생합니다.

warning C4172: returning address of local variable or temporary

이건 컴파일러마다 차이가 있는 것 같지만, 기본적으로 함수가 종료되고나면 함수에서 사용된 메모리는 사라지게 되고 그 안에서 사용되었던 메모리에 접근할 수 있는 방법이 사라집니다. 즉, 이 이야기대로라면 function 함수 내에서 선언된 a를 참조를 하지만 a는 함수가 종료되면서 쓰레기값을 가질 수도 있다는 것이죠.

 

하지만 제 PC visual studio 환경에서 리턴할 때, 정상적으로 5를 출력하고 있습니다. 아마도, function 함수가 종료되면서 메모리는 해제되었지만, 변수 a가 할당되었던 메모리 공간에 5라는 값이 아직 유지되고 있기 때문에 정상적으로 5라는 값이 복사되어 c에 입력된 것 같네요. 아마 다른 값으로 덮어씌어지기 전까지는 접근이 가능할 것으로 보입니다. 

하지만, side effect가 발생할 수도 있으니 지역변수를 참조자로 리턴하는 것은 주의를 해야할 것 같네요.

 

(참조자는 있지만, 원래 참조하던 것이 사라진 참조자를 Dangling reference라고 부릅니다)

 

 

- 참고

modoocode.com/141

 

씹어먹는 C++ - <2. C++ 참조자(레퍼런스)의 도입>

 

modoocode.com

 

댓글