C언어

C++ 프로그래밍 - 참조자(Reference)

머리큰개발자 2021. 5. 5. 14:32
참조자

C언어에서 C++로 나아가기 위해서 추가적으로 알아야할 사항들이 많이 남아있다.

이번 글에서는 참조자에 대해서 공부한 내용을 기록한다.

 

C에서 포인터의 개념을 정확하게 이해했다면 참조자에 대한 이해는 상당히 쉽다.

물론 포인터를 몰라도 이해할 수 있을만큼 참조자는 간단하다.

 

참조자는 말하자면 별명이다.

예를 들어보자면, 어느 집에 강아지 1마리가 있다. 

이 집안에서는 강아지의 이름을 뽀삐라고 지었다. 

가족들은 뽀삐를 뽀삐라고만 불렀지만, 어느 날 옆집에서 아이가 놀러와서 뽀삐를 얘삐라고 불렀다.

결국 뽀삐는 옆집에서는 얘삐라는 이름으로 불리게 되었다.

 

여기서 뽀삐는 결국 얘삐와 동일한 강아지를 가리키는 단어이다.

뽀삐에게 밥을 너무 많이줘서 살이 1kg 쪘다면 결국 얘삐가 1kg 찐 것이랑 마찬가지이다.

뽀삐가 본명이라면, 얘삐는 별명이고 이것이 참조자의 기본적인 이해가 되겠다.

 

 

선언

참조자는 &를 이용하여 선언한다.

int a= 20; 이라는 선언과 정의가 있었다면, int &b = a; 로 참조자를 선언 및 초기화 할 수 있다.

a = 10; 으로 수를 바꿨다면 b를 호출해도 10이 나온다. 

#include <iostream>
using namespace std;

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

	std::cout << "a : " << a << " b : " << b << "\n";

	a = 10;
	std::cout << "a : " << a << " b : " << b << "\n";

}

즉 같은 메모리 공간을 가리키는 별칭으로 생각하면 편하다.

 

C에서는 이런 방식을 포인터로 구현해서 사용했다.

즉 int a=20; 이었다면 int* p = &a; 로 선언하여 a의 메모리에 직접 접근하여 *p = 10; 으로 변경하곤 했다.

C++에서는 이런 번거로움과 어려움을 개선하기 위하여 참조라는 개념을 도입한 것이라 생각하면 된다.

 

참조자의 (별명의) 개수는 제한이 없고, 참조자가 참조자를 참조할 수 있다.

int a= 20;

int &b= a;

int &c= b;

int &d =a; 

물론 인자로 받을 때 말고는 별로 쓰지 않는 것 같다.

 

주의할 점

1. 변수에 대해서만 선언이 가능하다. (int &a = 20; x, int &a= NULL; x)

2. 선언과 동시에 초기화가 되어야 한다.(const와 비슷, int &a; x)

3. 참조의 대상을 바꾸는 것은 불가능하다.

 

한 번 선언했다면 계속 그 대상을 가리켜야한다는 뜻이다! 

 

참조자가 유용한 이유는 함수의 인자로 전달하기 용이하기 때문이다.

기존 C언어는 call by value만 유효했다. 

아무리 포인터를 이용하여 주소값을 전달한다고 해도 결국에 값을 전달받아 복사하여 사용하는 것이기 때문에

call by address 는 결국 call by value와 유사하다고 생각할 수 있다.

 

C++에서 이제 call by reference 가 가능해졌다. 

call by address와의 차이는 결국 거의 없지만, address 값을 value 처럼 받아 계산할 수 없기 때문에(prt + 1등의 연산x)

주소에 접근하여 그 값만을 처리한다는 차이가 있다.

 

 

함수의 사용

앞선 설명을 보면 알겠지만, reference로 전달할 경우 직접 그 메모리 주소에 접근하여 값을 바꿀 수 있다는 장점이 있다.

#include <iostream>
using namespace std;

void swap(int x, int y) {
	int temp = x;
	x = y;
	y = temp;
}

void swap_reference(int& x, int& y) {
	int temp = x;
	x = y;
	y = temp;
}

int main() {
	int a = 20;
	int b = 15;
	std::cout << "Input : ";
	std::cout << "a : " << a << " b : " << b << "\n";

	swap(a, b);
	std::cout << "swap by value : ";
	std::cout << "a : " << a << " b : " << b << "\n";

	swap_reference(a, b);
	std::cout << "swap by reference : ";
	std::cout << "a : " << a << " b : " << b << "\n";

}

 

기존 C에서 swap을 이용하려면, 전역변수로 선언하거나 매크로 함수를 이용 혹은 주소를 전달받아 포인터로 값을 변경하곤 했다.

하지만 이제는 그럴 필요가 없다.

위의 코드를 보면 swap함수는 기존 call by value를 이용하여 값을 서로 바꾸기 때문에 함수가 종료되면 main함수의 변수는 영향을 받지 않았다. 

하지만 swap_reference는 레퍼런스로 값을 받았기 때문에 같은 메모리 주소로 접근하여 값을 뒤바꾼다. 

실제로 함수가 종료되고 나니 main함수에서도 값이 뒤바뀐 것을 볼 수 있다.

이렇게 편리한 방법이 있기 때문에 더 이상 포인터를 이용해 복잡하게 코드를 짜지 않아도 된다..!!!!!

 

그럼에도 불구하고 참조자에는 치명적인 단점이 존재한다.

그것은 바로... 구별이 안된다!는 점이다.

실제로 함수가 선언된 부분을 보지 않는 한, 함수가 call by value 로 작동하는지 call by reference 로 작동하는지 알 길이 없다. 포인터를 사용했을 경우에는 주소값이 들어간다는 확실한 차이점이 있기 때문에 파악할 수 있었지만, 참조자는 아니다. 

결과적으로는 함수가 수행되고 난 후 변수의 값이 변할지 안변할지 전혀 알 수가 없기 때문에 코드를 분석하기 쉽지 않아진다는 단점이 발생한다.

단점을 극복하기 위해 reference를 사용하지만 함수가 수행되는 중 값이 변하지 않을 경우 매개변수를 const로 선언하여 주는 것이 매우 좋고, 정 헷갈린다면 #define REF 를 선언하여 호출시에 붙여주는 것이 좋다.

 

또한 함수의 결과로 참조자가 반환될 수 있다.

이 때 반환값은 내가 어떤 것으로 받느냐에 따라 달라질 수 있다.

#include <iostream>
using namespace std;

int& func1(int& a) {
	a++;
	return a;
}
int main() {
	int a = 5;
	int& b = func1(a);
	b++;
	cout << "func1 : " << b << " a : " << a << "\n";

	a = 5;
	int c = func1(a);
	c++;
	cout << "func2 : " << c << " a : " << a << "\n";
	

}

참조값이 반환될 경우 2가지로 나뉘게 된다.

하나는 변수값으로 받을 경우, 하나는 참조값으로 받을 경우이다.

참조값으로 받을 경우를 잘 살펴보자.

위의 함수에서 a가 참조값으로 전달되었으므로 func1의 int &a = a; 로 전달이 된다. 

a를 1 증가시키고 참조값으로 반환했기 때문에 int& b= int &a =a; 와 다름이 없다. 그렇기 때문에 b를 하나 증가시켜도 a에 적용이 되어 같은 값이 나오게 된다.

 

반면 일반적인 변수로 값을 받게 될 경우는 다르다.

c는 일반적인 변수이고 참조값을 받게 된다. 하지만 c는 참조값이 아니기 때문에 결국 a의 value를 받게 되고,

c는 value를 복사한 새로운 변수가 되게 된다.

그렇기 때문에 c를 1 증가시켜도 a에는 적용되지 않고 별개로 작동하게 된다.

 

또한 함수 내의 지역변수를 참조값으로 반환하지 않도록 주의해야 한다.

#include <iostream>
using namespace std;

int& func1(int& a) {
	int n = a + 10;
	return n;
}
int main() {
	int a = 5;
	int& b = func1(a);
	b++;
	cout << "func1 : " << b << " a : " << a << "\n";

}

위의 경우는 제대로 결과가 나오므로 문제가 없어보인다.

하지만 n은 func1가 종료되면 즉시 해제가 된다. 

아주아주 다행히도 더 이상 n이 있던 공간을 쓰지 않는다면 문제가 발생하지 않겠지만,

이미 할당이 해제되었기 때문에 언제 메모리 공간이 다른 용도로 쓰일지 모르기 때문에 운이 없다면 

b는 쓰레기값을 가리키게 될 것이다.

항상 지역변수를 가리키지 않도록 주의하자. (JAVA와는 다르게 참조해도 해제됨에 주의하자.)

 

또한 앞서 언급한 것 중 이상한게 하나 있다.

 

단점을 극복하기 위해 reference를 사용하지만 함수가 수행되는 중 값이 변하지 않을 경우 매개변수를 const로 선언하여 주는 것이 매우 좋고,

 

이 문장은 언뜻보면 굉장히 이상하다.

하지만 다음은 어떤가?

const int a = 30;

const int& ref=  a;

위는 앞서 말했듯이 ref가 a값을 바꾸지 않겠다는 뜻이므로 성립한다.

 

그럼 다음 경우는 어떨까

const int &ref= 30;

 

참조자는 변수만을 가리킬 수 있다고 했는데 30은 상수가 아닌가?

 

가능하다.

왜냐하면 '임시변수'라는 것이 있기 때문이다.

 

식에 쓰이는 상수들은 결국 메모리 어딘가에 임시 변수로 저장이 된다. 

즉 int a= 2+3; 식이 있다면 2와 3이 어딘가에 저장되었다가 연산의 결과로 a에 들어간다는 뜻이므로, 

메모리 어딘가에는 상수 2,3이 존재하고 그 결과인 5 또한 메모리 어딘가에 존재한다.

const int &ref 는 그 메모리 어딘가를 가리키게 된다. 그러므로 

'값을 바꾸지 않겠다' + '임시 변수가 생성된 메모리 어딘가를 가리킴'의 두 조합으로 인해 가능한 결과라고 생각하면 편할 것 같다.

 

이것이 왜 필요한가하면... 함수에 변수가 아니라 상수값을 대입하여 값을 구하고자 할 때 필요하기 때문이다.

그냥 함수에 int func(int &a, int &b)로 선언했을 경우 func(1,2); 형식의 사용은 불가능하지만 func(const int &a, const int& b)로 선언시에 func(1,2)의 사용은 가능하다!! 그리고 이것은 후에 오버로딩에 많이 사용되므로 꼭꼭 알아두자.