Search

참조자에 대하여

Created
2020/06/22
tag
C++
씹어먹는 C++
참조자

1. 참조자 (Reference)

C 언어에서 특정 변수를 가리키는 포인팅 작업을 할 때는 포인터 변수를 이용해서 주소 값을 갖고 있도록 하였다. C++에서는 포인터 변수를 이용한 방법 외에 특정 변수를 가리키는 방식이 하나 더 추가 되었다. 바로 참조자라고 하는 것이다. 아래 코드를 통해 참조자가 실제로 변수를 어떻게 가리키는지 확인해보자.
#include <iostream> int main() { int a = 10; int &aRef = a; std::cout << a << std::endl << aRef << std::endl; return 0; }
C++
복사
변수 선언한 것을 보면, 못 보던 친구가 있다. C 언어에서는 변수 이름 앞에 Ampersand는 변수의 주소 값을 지칭할 때 사용했다. 하지만 C++에서 타입에 대해서 Ampersand를 붙이게 되면, 그 타입을 참조하는 참조자라고 선언하게 된다. 예를 들어 int &로 변수를 선언하면, 그 변수는 int 타입의 변수를 참조하는 변수가 된다. 마찬가지로 double &로 변수를 선언하게 되면, double 타입의 변수를 참조하는 변수가 된다. 포인터 변수가 특정 변수의 주소 값을 저장하는 방식으로 특정 변수를 참조했다면, 참조자는 특정 변수를 어떤 방식으로 참조하는 것일까?
참조자는 일종의 Aliasing이라고 보면 된다. 즉, 별칭을 두는 것인데 위 예제와 같은 경우는 a의 또 다른 이름이 aRef라고 컴파일러에게 알려주는 것이다. 엥 그러면... 변수를 선언한다는 것은 메모리 공간에 이용하는 크기만큼 할당하고 그 곳에 이름을 붙이는 것이라고 했고, 그 공간에 대해서 별칭을 두는 거면... 메모리를 먹는다는 거야, 안 먹는다는 거야?와 같은 생각이 들 것이다.
나도 이런 고민을 했고, 처음에는 a라는 변수의 별칭인 aRef를 따로 저장해둬야 한다고 생각을 했다. 그래서 참조자도 선언을 하면 메모리를 먹겠거니 생각을 했었다. 근데 뭔가 찜찜해서 다시 생각을 해보니, a라는 변수를 선언할 때 해당 변수에 대해선 메모리를 할당하지만 그 공간을 지칭하는 a라는 이름에 대해서는 추가로 메모리 할당하는 식으로 a라는 이름을 저장하지 않는 것이 떠올랐다. 변수 a를 이용하면 a라는 이름이 붙은 메모리 공간은 컴파일러가 이미 알고 있다. 따라서 이름에 대해서는 메모리를 추가로 할당하여 이름을 기록할 필요는 없다. 이와 같이 단순히 이름에 대해서는 메모리를 먹지 않는다는 사실이 떠오르자, 참조자도 그저 이름을 하나 더 두는 것 뿐이고 지칭하는 공간이 동일한 곳이라면 메모리를 더 먹지 않겠다고 생각을 하게 되었다. 만일 참조자별칭을 이용하게 되면 컴파일러참조자의 이름에 대해서는 원래 이름으로 모두 바꿔버리면 되니까 원래 변수를 사용하던 것처럼 이용하게 되지 않을까 싶었다. 이 추측이 맞았고, (특정 경우를 제외하고는) 참조자를 사용할 때 메모리를 추가로 할당하지 않아도 된다고 한다. (이 문단에서 지칭하는 메모리는 Stack을 의미한다.)
위 문단에 적은 것과 같이 참조자를 이용한다는 것은 결국에 원래 변수로 이용하는 것과 같다고 했다. (컴파일러참조자에 대해서 원래 변수 이름으로 모두 바꾼다.) 그렇다면 참조자에 대해서는 대입 구문이 가능할까? 포인터 변수에 대해서는 아래와 같이 대입 연산을 통해 참조하고 있는 변수를 바꾸는 것이 가능했다.
int a = 10; int b = 20; int *pa = &a; pa = &b;
C++
복사
포인터변수에 위와 같은 연산이 가능했다면, 참조자에 대해서는 대입 연산이 가능할까?
int a = 10; int b = 20; int &aRef = a; aRef = b;
C++
복사
일단 위 구문처럼 대입 연산은 가능하다. 하지만 원래 의도대로 지칭하는 변수에 대해서는 바꿀 수 없다. 위 코드의 수행 결과는 참조자가 지칭하는 변수가 a에서 b로 바꾸는 것이 아니라, 참조자참조하는 원래 변수 a의 값이 b의 값으로 만드는 것이 된다. 즉, 지칭하는 변수가 바뀌는 것이 아니라 a의 값이 20이 되버린다. 그럼 아래와 같은 구문은 어떨까?
int a = 10; int b = 20; int &aRef = a; &aRef = b;
C++
복사
제시된 구문 역시 주소 값b로 바꿔 버리는 것이 되어버리므로 말도 안 되는 구문이 된다. 이렇듯 참조자에 대해서는 선언 이후에 대입을 통한 별칭 지정이 불가능하기 때문에 참조자를 이용할 때는 2가지를 반드시 준수해야 한다.
참조자에 대한 선언을 할 때, 반드시 어떤 변수의 별칭이 될지 정해줘야 한다.
참조자가 한 번 특정 변수에 대한 별칭이 되면, 지칭하는 대상은 변경 할 수 없다.
이렇게 참조자를 사용하는 것은 어떤 이점이 있어서 포인터가 이미 있는데도 C++에서 새롭게 생긴 것일까? 이 역시 2가지 이유가 있다.
첫 째는 다른 변수를 지칭할 때 포인터 변수를 사용하게 되면 주소체계의 크기만큼 메모리에 할당하며 사용해야 하지만, 참조자의 경우는 위에 제시된 내용처럼 (특정 경우를 제외하고) 추가적인 메모리 할당 없이 별칭을 이용할 수 있다. 즉, 메모리 이용에 이점이 있는 것이다. 둘 째는 아래 항목을 읽으면 알게 되겠지만, 지칭하는 대상을 사용할 때 포인터처럼 Asterisk를 이용하지 않아도 되고 인자를 넘길 때도 Ampersand를 일일이 붙여서 넘기지 않아도 된다. 그저 참조자 선언만 제대로 되어 있다면, 해당 별칭으로 그대로 이용할 수 있기 때문에 포인터 변수를 이용하는 것보다는 조금 더 가독성 좋게 이용할 수 있다.

2. 함수 인자로 사용하는 참조자

위 항목에서 참조자의 메모리 할당에 대해서 언급하면서, 참조자를 사용할 때 추가로 메모리를 할당하는 특정 케이스가 존재한다고 했다. 이에 대해선 참조자를 할당하기 위해서 메모리를 할당하는 것이 아닌데, 그 특정 케이스가 함수의 인자로 참조자를 사용할 때이다. 조금 더 정확히 말하면 Call Stack (호출 스택)이 달라지는 경우에 추가 메모리 할당이 일어난다.
함수는 호출 될 때마다 호출 스택에 쌓이게 된다. 같은 호출 스택 내에서는 변수가 사용되더라도 어떤 변수를 사용했는지 알 수 있다. 하지만 호출 스택이 달라지면 호출 스택 내에 존재하지 않는 변수를 사용했을 때 해당 변수에 대해서 알 수 없다. 이런 경우가 참조자를 인자로 받는 함수를 호출 했을 때이다.
#include <iostream> void getParam(int getValue); int main() { int param = 10; getParam(param); } void getParam(int getValue) { std::cout << getValue << std::endl; }
C++
복사
위와 같은 경우에는 getParam이 호출되어 해당 함수를 실행했을 때 getValue를 스택에 할당하고 그 값을 10 (인자로 받은 param의 값)으로 할당하면 된다. 즉, 값 자체를 받게 되는 것이므로 main함수에서 getParam이라는 함수로 호출 스택이 달라져도 추가적인 메모리 할당이 필요하지 않다.
#include <iostream> void getParam(int &getValue); int main() { int param = 10; getParam(param); } void getParam(int &getRef) { std::cout << getRef << std::endl; }
C++
복사
위 코드와 같이 참조자를 인자로 받는 경우에는 값 자체를 받는 것이 아니라 그 변수의 참조에 대해서 넘겨 받는 것이기 때문에 어떤 변수를 지칭하는지 알아야 한다. 만일 main이라는 하나의 함수에서 모든 과정이 흘러간다면 같은 호출 스택 내에서 이용을 하는 것이므로 어떤 변수인지 알려주지 않아도 참조자는 그 변수를 잘 찾을 수 있다. 하지만 main이라는 함수에서 getParam이라는 함수로 호출 스택이 변경되면서, getRef라는 참조자param이라는 변수를 참조하도록 실행된다. 이 때 바뀐 호출 스택 내에는 param이라는 변수가 존재하지 않기 때문에 어떤 변수인지 알 수 없다. 따라서 위 와 같이 함수를 호출하더라도 참조자를 인자로 받는 경우에는 참조자가 지칭하는 변수에 대해서 주소 값을 별도로 저장하는 과정이 필요하다.
마지막으로, 참조자를 인자로 받는 함수를 이용할  때 추가적인 메모리 할당이 일어난다는 것을 살펴보면서, int &getRef와 같은 구문을 보았을 때 약간 이상한 느낌이 들었다면 정상적인 느낌이다. 2가지를 준수하며 참조자를 이용해야 한다고 했었다. 지칭해야 하는 대상을 명시하지 않고, 단일로 int &getRef와 같이 참조자에 대한 선언은 불가능하다고 말이다. 하지만 함수의 인자를 작성하면서 int &getRef와 같이 단일로 사용 했는데 어째서 문제 없이 실행이 되는 것일까?
준수해야 하는 사항 2가지는 참조자 작성에 대한 것이긴 하지만 엄밀히 말하면 참조자가 정의되는 순간에 지칭하는 대상이 있어야 한다는 것이다. 함수의 코드 자체는 Text 영역에 존재하는 상태로 있다가 함수가 호출이 되는 순간에 실행이 되면서 각 변수들이 정의된다. 그리고 함수가 실행될 때, 참조자가 인자를 받으면서 문제 없이 정의 된다. 즉, 함수가 호출되기 전까지는 정의되지 않는 상태로 존재하기 때문에 함수의 인자로 참조자를 사용할 때는 단독으로 위와 같이 작성할 수 있는 것이다.

3. 복수 참조자

참조자에 대해서는 참조자참조자를 둘 수 없다. 애초에 문법상 불가능하게 막혀 있다. (존재할 수가 없다.) 그렇다면 다음 코드를 보자.
int a = 10; int &aRef1 = a; int &aRef2 = aRef1; int &aRef3 = aRef2; aRef1 = 20; aRef2 = 30; aRef3 = 40;
C++
복사
위 코드는 오류가 없을까? 참조자에 대한 참조자가 존재하지 않는다고 했으므로 오류라고 생각할 수도 있지만, 그렇지 않다. 일단 참조자는 한 번 정해지고 나면, 참조자에 대해서 이용할 때는 원래 변수로 인식하게 된다. 2번째 라인에서 aRef1이라는 참조자에 대해서 선언했다면, 3번째 라인에서 aRef2를 선언할 때 사용된 aRef1참조자가 아니라 원래 변수인 a를 의미한다. 따라서 위 코드들을 수행하면 a를 나타내는 별칭이 aRef1, aRef2, aRef3이 되는 것이고, a의 값은 40으로 바껴 있는 것이 된다. (참조자참조자라는 것이 불가능 하다는 것은 int &&, int &&&, int &&&&과 같은 표현이 생기는 것을 금지하는 것이다. 위의 경우 사용된 참조자들은 모두 int &이다.)

4. 상수 참조자

포인터 변수로는 특정 변수를 지칭하는 것도 가능했지만, 아래와 같이 리터럴을 가리키는 것도 가능했다. 비록 HELLOYELLO처럼 리터럴을 바꾸는 것은 불가능했지만 지칭은 가능했다. HELLO라는 리터럴은 메모리에 할당되고 그 주소 값을 갖고 있는데다가, 포인터 변수는 주소 값을 저장할 수 있으니 말이다.
char *strPointer = "HELLO";
C++
복사
포인터 변수는 상수 값을 지칭하는 것이 가능했다면 참조자로도 상수 값을 지칭하는 것이 가능할까?
int &aRef = 10;
C++
복사
위 코드와 같이 참조자를 선언했다. 이를 실행해보면 알겠지만, 오류가 발생한다. 참조자는 별도의 메모리 공간을 지칭하는 것도 아니고, 말 그대로 할당된 대상 자체를 의미하는 별칭이기 때문에 상수 값에 대한 별칭을 두면 그 값을 바꿀 여지가 있는 한 오류를 발생시킨다. 1010 그 자체이다. 104를 대입하여 104로 만들 수 없다는 것이다.
const int &aRef = 10;
C++
복사
그렇다면 위 경우처럼 상수 값에 대해 별칭을 뒀을 때 그 값을 바꿀 여지가 없다면 오류가 발생하지 않을까? 그렇다 오류가 발생하지 않는다. 심지어 aRef라고 이용하면 aRef10이라는 값으로 이용할 수 있게 된다.

5. 참조자 배열와 배열 참조자

배열 포인터포인터 배열이 생각나지 않는가? 비슷한 개념이다. 배열 참조자는 배열을 가리키는 참조자로써 그 크기가 명시되어야 하는 것이고, 참조자 배열은 말 그대로 여러 참조자들을 배열 형태로 지정하는 것이다. 아래 설명과 코드가 잘 이해되지 않는다면 여기에서 배열 포인터포인터 배열 항목을 읽는 것을 추천한다.

1) 참조자 배열

우선 참조자 배열부터 확인해보자.
int a = 10; int b = 20; int &refArray[2] = {a, b}; // illegal
C++
복사
위 코드를 실행하게 되면, 아무런 문제 없이 refArray[0]이라고 하는 것은 a별칭이 되고 refArray[1]이라고 하는 것이 b별칭이 될 것 같지만 오류를 발생하는 것을 볼 수 있다.
참조자에 대한 개념을 적은 것을 읽었다면, 참조자를 이용하면서 주소 값에 대해서 메모리 할당은 일어날 수 있어도 참조자 그 자체에 대해서 메모리 할당이 일어나는 것은 아니라는 것을 알 수 있다. 하지만 배열로 선언을 한다는 것은 배열의 이름이 있다는 것이고, 포인터 글에서 밝힌 바와 같이 배열의 이름은 암시적 형 변환으로 인해 주소 값을 내놓게 된다. 즉, 주소 값을 내놓는다는 것은 메모리 상에 존재하는 것과 같은 것이므로 메모리 상에 존재하지 않는 참조자와 모순이 되는 것이다. 따라서 참조자 배열은 존재할 수 없다. 위 구문은 illegal하다.
이로써 참조자참조자illegal하다는 것과 더불어 참조자 배열illegal한 것이고, 참조자 배열illegal하기 떄문에 비슷한 이유로 참조자포인터illegal하다는 것을 알 수 있다. 참조자 배열이 불가능 했다면 배열 참조자는 어떨까?

2) 배열 참조자

이미 메모리 상에 존재하는 배열에 대해서 별칭을 만들어 참조자를 두는 것은 가능하다. (개념상을 보아도 이 경우에는 별칭만 두면 되니, 메모리를 할당할 필요가 없지 않은가?) 배열 참조자는 배열에 접근할 수 있는 참조자를 선언하는 것이고, 이는 배열 포인터와 비슷한 구문으로 선언할 수 있다.
int arr[5] = {1, 2, 3, 4, 5}; int (&arrRef)[5] = arr;
C++
복사
위와 같이, 연산자 우선 순위로 인해 &참조자의 이름을 먼저 괄호로 묶고 지칭하려는 배열의 크기만큼을 명시해줘야 한다.

6. 댕글링 참조자 (Dangling Reference)

댕글링 참조자라고 하는 것은 참조자 중에서도 참조자가 지칭하는 변수가 메모리 상에서 사라진 참조자를 의미한다. 어떤 경우에 지칭하는 변수가 메모리 상에서 사라질 수 있는 것일까? 우선 프로그램의 시작부터 종료까지 살아있는 전역 변수정적 변수는 아닐 것이다. 그렇다면 지역 변수를 이용할 때 변수가 메모리에서 사라질 수 있다는 것이다. 특히 main함수 안이 아닌, 다른 함수 호출을 통해 함수가 실행되었을 때 정의되는 지역 변수들을 의미할 것이다. 이런 지역 변수들은 함수 실행이 모두 끝나면 호출 스택 내에 존재하는 변수들은 모두 메모리 상에서 사라지게 된다. 이 때 만일 참조자가 함수 내에서 정의된 지역 변수를 지칭하고 있는 경우 댕글링 참조자가 될 수도 있는 것이다. 케이스를 나누어 예시를 통해서 살펴보자.

1) 참조자를 return 하는 경우 (int& f()와 같은 경우)

참조자를 return하여 값에 할당하는 경우

→ 값 복사, 지역 변수 지칭하여 발생하는 댕글링 참조자 문제 가능성 있음

참조자를 return하여 참조자에 할당하는 경우

→ 참조자 선언 가능, 지역 변수 지칭하여 발생하는 댕글링 참조자 문제 가능성 있음

참조자를 return하여 상수 참조자에 할당하는 경우

→ 참조자 선언 가능, 지역 변수 지칭하여 발생하는 댕글링 참조자 문제 가능성 있음

예시

3가지 케이스들 모두 비슷한 예시로 설명이 가능하다. int a = f();로 하든 int &a = f();로 하든 const int &a = f();로 하든 어쨌든 댕글링 참조자는 함수 내에서 return되는 참조자가 지칭하는 값이 사라지는 것이니 말이다. 따라서 한 가지 함수의 예시를 통해 댕글링 참조자를 이해하면, 참조자return하는 경우들에 대해서 값이든, 참조자든, 상수 참조자든 문제가 있는지 없는지 이해할 수 있다.
#include <iostream> int &returnRef(); int main() { int var = returnRef(); int &ref1 = returnRef(); const int &ref2 = returnRef(); return 0; } int &returnRef() { int refVar = 10; return refVar; }
C++
복사
위와 같이 int &타입의 참조자return하는 함수가 있다고 하자. 이 때 해당 함수는 지역 변수참조자 타입으로 return하게 된다. (값이 아니라 그 변수 자체를 return한다.) 하지만 해당 함수가 return하는 변수는 함수의 호출이 끝나면서 호출 스택이 비워질 때 메모리 상에서 사라지게 되고 return참조자는 곧 댕글링 참조자가 되어버린다. 따라서 참조자return하는 3가지 경우 모두 댕글링 참조자를 이용하게 되므로 지역 변수참조자로써 return하는 경우에는 문제가 생긴다. 그렇다면 왜 애초에 컴파일 오류로 잡지 않고 컴파일 경고만 띄우면서 이용이 가능할까?
참조자return하는 경우에, 지역 변수에 대한 return댕글링 참조자return하게 되어 문제가 있지만 외부에서 들어온 변수에 대해서는 정상적으로 참조자return할 수 있기 때문이다.
#include <iostream> int &returnRef(int &ref); int main() { int refVar = 10; int var = returnRef(refVar); int &ref1 = returnRef(refVar); const int &ref2 = returnRef(refVar); return 0; } int &returnRef(int &ref) { return ref; }
C++
복사
따라서 위 코드와 같은 경우에는 댕글링 참조자return하게 되는 것이 아니기 때문에 문제 없이 사용할 수 있고, 별도의 컴파일 오류를 발생시키지 않는다. 이러한 이유로 댕글링 참조자가 발생하는 경우도 있지만 그렇지 않은 경우도 있기 때문에 무조건 컴파일 오류를 발생시키지는 않는다.

2) 값을 return 하는 경우 (int f()와 같은 경우)

값을 return하여 값에 할당하는 경우

값에 할당되는 경우  int a = f();와 같이 이용되며, 이 때는 f()를 통해 return된 값 자체가 a에 할당된다. 정상적으로 값이 잘 복사 되며, 참조자 자체가 없기 때문에 댕글링 참조자에 대한 걱정을 할 필요는 없다.
→ 값 복사, 댕글링 참조자 문제 없음

값을 return하여 참조자에 할당하는 경우

f()를 통해 return한 값을 참조자에 할당하는 경우 댕글링 참조자가 되면서 컴파일 오류를 뽑아낸다. 함수 내에서 return된 값은 함수의 호출이 끝나면서 사라지기 때문이다. 즉, 참조자가 가리키는 값이 함수 호출을 통해 할당되었다가 함수 호출이 끝나면서 가리키는 값이 사라지게 된다. 그렇다면 참조자return하는 경우에서는 댕글링 참조자가 있음에도 컴파일이 가능했지만, 값을 return하는 경우에는 댕글링 참조자에 대해 왜 컴파일 오류를 내면서 컴파일이 불가능할까? 참조자return하는 경우에는 지역 변수참조자return할 때는 댕글링 참조자가 되지만, 외부에서 들어온 변수를 참조자return하는 경우엔 댕글링 참조자가 되지 않고 문제 없이 이용할 수 있기 때문에 컴파일 오류를 뱉지는 않는다. 하지만 값을 return하는 경우에는 어떤 경우에도 댕글링 참조자가 되기 때문에 컴파일 오류를 발생시키게 되는 것이다.
→ 컴파일 오류, 댕글링 참조자 문제 확실

값을 return 하여 상수 참조자에 할당하는 경우

위와 같이 참조자에 대해서 값을 return하는 경우에는 함수 호출이 끝나면서 return한 값은 사라지기 때문에 댕글링 참조자가 되기 때문에 컴파일 오류가 발생되었다. 하지만 예외적으로 상수 참조자에 값을 할당하는 경우에는 Life Cycle이 연장되어, 상수 참조자호출 스택이 이용되는 동안에는 댕글링 참조자가 되더라도 상수 참조자가 가리키고 있는 값에 대해서는 사용할 수 있다.
→ 참조자 선언 가능, 댕글링 참조자 문제 확실 (부분 허용)

3) 정리

위의 내용을 표로 정리하면 아래와 같다. 참조자return한 경우에 대해서, 참조자Life Cycle참조자가 생성된 호출 스택 내에서 유효하다는 것을 유의하면 되겠다.

7. Reference