C++/C++ 기초

[C++] 참조 매개 변수 / 참조 리턴 (Reference parameter / Reference return) + Call-by-reference

Song 컴퓨터공학 2023. 7. 9. 17:38

참조 매개 변수와 참조 리턴을 이해하기 위해서는 프로그래밍 언어의 인자 전달 방식(argument passing) 에 대해 복습해야합니다.

 

https://blog.naver.com/songsite123/222938354059

 

두 변수의 값 서로 바꾸기(Call-by-value/reference)

위 포스팅의 내용을 알아야 오늘의 내용이 쉽습니다. 프로그래밍을 하다 보면 두 변수의 값을 바꿔야 할 일...

blog.naver.com

이전에 C 언어에서 다룰 때는 2가지로 구분하여 인자 전달 방식을 배웠습니다. 그 때의 용어와 오늘 다루게 될 용어가 다르니 혼돈하면 안됩니다.

 

위 포스팅에서는 

Call by value (값에 의한 호출) / Call by reference (주소에 의한 호출) 을 2가지로 구분하여 예제 코드까지 배웠습니다.

 

 

값을 전달하는 형태의 함수 호출 : Call-by-value

메모리의 접근에 사용되는 주소 값을 전달하는 형태의 함수호출 : Call-by-reference

 

 

라는 결론으로 포스팅을 마무리했고, 주소 값을 전달하기 위해 함수에서는 포인터 변수로 주소를 받아 값을 바꿔줬습니다. 그러나 책마다 이를 지칭하는 용어가 다른 경우가 있습니다. C++에서는 참조 변수 라는 것이 등장하기 때문에 인자 전달 방식을 3가지로 구분합니다.

 

  1. Call-by-value : 값에 의한 호출
  2. Call-by-address : 주소에 의한 호출 (위 포스팅에서의 call-by-reference 와 동일)
  3. Call-by-reference : 참조에 의한 호출 ( C에는 없는 C++ 에서 새로 등장하는 개념 )

 

참조변수를 배우기에 앞서 객체가 Call-by-value 로 호출될 때와 Call-by-address 로 호출될 때의 과정을 간단히 보겠습니다. 결론부터 말하면 위 포스팅에서의 기본 자료형과 유사한 방식으로 동작하게 됩니다. 객체 호출에 대한 내용을 넘기고 제목에 관한 내용은 4번 목차로 넘어가시면 됩니다.

 

[목차]

1. Call-by-value 객체 호출

2. Call-by-address 객체 호출

3. 객체 치환 / 객체 리턴

4. 참조 변수 (Reference variable)

5. Call-by-reference(참조에 의한 호출)

6. 참조 리턴 

 

 Call-by-value 객체 호출

// 값에 의한 호출 시 매개변수의 생성자 실행 X. 소멸자는 실행된다.
#include <iostream>
using namespace std;

class Circle {
private:
	int radius;
public:
	Circle(int r = 1);
	~Circle();
	double getArea() { return 3.14 * radius * radius; }
	int getRadius() { return radius; }
	void setRadius(int radius) { this->radius = radius; }
};

Circle::Circle(int r) {
	radius = r;
	cout << "생성자 실행 radius = " << radius << endl;
}

Circle::~Circle() {
	cout << "소멸자 실행 radius = " << radius << endl;
}

void increase(Circle c)
{
	int r = c.getRadius();
	c.setRadius(r + 1);
}

int main() {
	Circle waffle(30);
	increase(waffle);
	cout << waffle.getRadius() << endl;
}

 

Call-by-value 로 호출하는 경우, 객체가 복사되어 매개변수 객체가 스택에 생성됩니다. 이 때 복사에 의한 통한 객체 생성이기 때문에 생성자가 호출되지 않습니다. 호출된 함수 내에서 객체의 멤버 변수 값을 바꾸더라도, 매개변수에 복사된 객체의 멤버 변수를 변경시키는 것이기 때문에 원본 객체의 멤버 변수 값은 변하지 않습니다. 위 포스팅의 call-by-value swap 함수랑 동일한 원리입니다. 그러나 함수가 종료되면 매개 변수 객체가 소멸되기 때문에 소멸자는 호출됩니다. 위 실행 예시만 봐도 생성자는 1번 호출되었으나 소멸자는 2번 호출되는 것을 확인할 수 있습니다.

 

즉, 위 과정을 통해 컴파일러가 매개 변수 객체의 생성자는 실행되지 않고 소멸자만 실행되도록 컴파일 하는 것을 확인할 수 있습니다. 매개 변수 객체의 생성자가 실행되지 않도록 비대칭 구조로 컴파일 되는 이유는 만약 매개 변수 객체에서 생성자 Circle() 이 실행된다면 멤버 변수가 초기화되어 전달 받은 원본의 상태를 잃어버리게 되기 때문입니다. 이런 문제를 방지하기 위해 매개 변수 객체의 생성자는 실행되지 않고, 소멸자는 동일하게 실행됩니다. 그러나 이 비대칭 구조로 인한 문제점이 추후 발생하게 됩니다. 사실 call-by-value 방식으로 객체를 호출 시 매개 변수 객체의 생성자 대신 "복사 생성자" 라는 것이 실행됩니다. 복사 생성자(copy constructor)는 다음 포스팅에서 다루게 됩니다.

 

 

 

 Call-by-address 객체 호출

void increase(Circle* c)
{
	int r = c->getRadius();
	c->setRadius(r + 1);
}

int main() {
	Circle waffle(30);
	increase(&waffle);
	cout << waffle.getRadius() << endl;
}

Circle 클래스는 위와 동일한 것을 사용하시면 됩니다. 이번에는 increase() 함수에서 매개 변수로 객체 포인터를 사용하기 때문에 원본 객체의 주소를 넘겨 받아서 값을 변경합니다. 따라서 원본 객체의 멤버 변수가 정상적으로 바뀌고, 매개 변수 객체 포인터가 생성되고 소멸되는 것이지 매개 변수 객체가 생성되는 것이 아니기 때문에 생성자와 소멸자는 원본 객체에 대해서만 각각 1번씩 수행됩니다.

 

 

 

 객체 치환 / 객체 리턴

 

객체는 여러 모로 구조체와 유사한 특성이 많습니다. 객체에 객체를 대입하는 경우와 함수가 객체를 리턴하는 간단한 경우를 확인해보겠습니다.

Circle c1(19);
Circle c2(30);
c1 = c2;	// c2 객체를 c1 객체에 비트 단위로 복사. c1의 radius = 30

같은 Class 의 객체에 대해 대입 연산자를 사용하게 되면 객체의 복사가 이루어지게 됩니다. 이 때 c1과 c2는 개별적인 객체이며 내용만 같습니다. 이는 객체 치환(object assignment) 라고 부르는 객체의 특성입니다. 비트 단위의 복사가 이루어집니다.

 

Circle getCircle() {
	Circle c(100);
    return c; // 객체 c 리턴
}

Class 의 이름을 데이터 타입으로 함수를 선언하면 객체를 반환하는 함수를 사용할 수 있습니다. 이런 객체 리턴 함수에서 return 문이 실행되면 c의 복사본이 생성되고, 이 복사본이 getCircle() 함수를 호출한 곳으로 전달됩니다. 그 이후 c가 소멸됩니다. 이는 객체 리턴(object return) 이라 불리는 클래스 타입 함수의 특징입니다.

#include <iostream>
using namespace std;

class Circle {
private:
	int radius;
public:
	Circle(int radius = 1) { this->radius = radius; }
	~Circle() {}
	double getArea() { return 3.14 * radius * radius; }
	int getRadius() { return radius; }
	void setRadius(int radius) { this->radius = radius; }
};

Circle getCircle() {
	Circle temp(30);
	return temp; // 객체 리턴
}

int main()
{
	Circle c;
	cout << c.getArea() << endl;

	c = getCircle(); // 객체 치환
	cout << c.getArea() << endl;
}

 

 

 

 참조 변수 (Reference variable)

 

C++ 에서는 C에는 없는 참조(Reference) 라는 개념을 도입합니다. 참조 변수 라는 것이 있는데 포인터 변수를 사용할 때 *을 사용하는 것처럼 변수 선언시 &를 사용하면 그 변수는 참조 변수가 됩니다. &를 참조자 라고 부릅니다.

 

참조 변수(Reference variable) 는 이미 선언된 변수에 별명(alias) 를 붙이는 개념입니다.

int n = 2;
int &refn = n;

만약 위처럼 int형 변수 n을 선언하고, int형 참조 변수 refn 을 선언하게 되면 refn과 n은 같은 메모리를 공유합니다. 참조변수는 선언할 때 새로운 공간을 할당받지 않고 공유만 하기 때문에 필수적으로 선언과 동시에 초기화가 되어야 합니다.

정확한 비유는 아니지만 할당된 공간을 박스로 그리면 그 박스 안에 2를 넣어놨고, refn 을 선언하면서 그 공간에 n이라는 이름에 더불어 refn 이라는 이름도 붙여진겁니다. 참조 변수를 사용하는 방법은 일반 변수와 동일하며, 예시 코드를 통해 쉽게 이해할 수 있습니다.

// 기본 타입 변수 참조
#include <iostream>
using namespace std;

int main() {
	cout << "i" << '\t' << "n" << '\t' << "refn" << endl;
	int i = 1;
	int n = 2;
	int& refn = n;
	n = 4;
	refn++; // refn = 5, n = 5
	cout << i << '\t' << n << '\t' << refn << endl;

	refn = i; // refn = 1, n = 1
	refn++; // refn = 2, n = 2
	cout << i << '\t' << n << '\t' << refn << endl;

	int* p = &refn; // int*p = &n 과 같음
	*p = 20; // refn = 20, n = 20
	cout << i << '\t' << n << '\t' << refn << endl;
}

위의 예시에서 볼 수 있듯 참조 변수에 대해서도 포인터를 만들 수 있습니다. 또한 참조 변수는 배열로 선언할 수 없습니다.

변수 뿐 아니라 객체 또한 참조 변수를 만들고 접근할 수 있습니다.

// 객체에 대한 참조
#include <iostream>
using namespace std;

class Circle {
private:
	int radius;
public:
	Circle(int radius = 1) { this->radius = radius; }
	~Circle() {}
	double getArea() { return 3.14 * radius * radius; }
	int getRadius() { return radius; }
	void setRadius(int radius) { this->radius = radius; }
};

int main() {
	Circle circle;
	Circle& refc = circle;
	refc.setRadius(10);
	cout << refc.getArea() << " " << circle.getArea();
}

 

 

 

 Call-by-reference (참조에 의한 호출)

 

하나의 변수에 2개 이상의 이름을 붙이는 참조 변수는 그냥 보기에는 유용하지 않고 복잡해보이기만 합니다. 참조 변수는 C++의 새로운 인자 전달 방식인 Call-by-reference(참조에 의한 호출) 에 많이 사용됩니다.

 

Call-by-reference(참조에 의한 호출)함수의 매개 변수를 참조 타입으로 선언하여, 매개 변수가 함수를 호출 하는 쪽의 실인자를 참조하여 실인자와 공간을 공유하도록 하는 인자 전달 방식입니다. 참조 타입으로 선언된 함수의 매개 변수를 참조 매개 변수(Reference Parameter) 라고 합니다.

 

 

참조 매개 변수를 사용하면 call-by-address 를 사용할 때처럼 주소를 넘기는 것이 아니라 변수 그 자체를 넘겨도 되기 때문에 작성하기 쉽고 보기 좋은 코드가 됩니다. Call-by-value 는 복사한 객체가 넘어가기 때문에 인자의 변경 사항이 적용이 안되었고, 이를 해결하기 위해 포인터를 사용해 주소값을 넘겨서 변경시키는 방법이 Call-by-reference라면 참조 변수를 인자로 받는 다는 말은 복사된 인자가 아닌 그 자체 인자와 동일한 인자를 받는 방식이기 때문에 참조 매개 변수로 이루어진 모든 연산은 원본 객체에 대한 연산이 됩니다. 또한 참조 매개 변수는 이름만 생성되기 때문에 생성자와 소멸자 또한 실행되지 않습니다.

void increase(Circle &c)
{
	int r = c.getRadius();
	c.setRadius(r + 1);
}

int main() {
	Circle waffle(30);
	increase(waffle);
	cout << waffle.getRadius() << endl;
}

가장 맨 처음 Call-by-value 코드에서 increase() 함수의 매개변수를 참조 변수로 바꾼 코드입니다. Call-by-value 시에는 객체가 복사되기 때문에 원본 객체의 값이 변하지 않았지만, 참조변수로 받으면 원본객체의 값이 변하고 또한 호출 함수에서의 소멸자 또한 실행되지 않는 것을 확인할 수 있습니다.

 

Call-by-reference 는 참조 변수를 매개변수로 사용하는 방식이고 이를 사용하면 원본 객체의 값을 주소를 넘기지 않고도 변화시킬 수 있습니다.

 

 

 

 참조 리턴

 

C 언어에서는 함수가 리턴할 수 있는 것은 오직 값(Value) 입니다. 값에는 void를 포함한 정수, 문자, 실수 등의 기본 타입값과 주소(포인터)를 포함합니다. C++는 추가적으로 함수가 참조를 리턴할 수 있습니다. 이게 얼마나 유용한 기능이냐면 함수에 어떤 값을 대입하는 연산이 가능하다는 말이 됩니다. C에서는 상상하지 못할 일이죠.

// 참조 리턴
#include <iostream>
using namespace std;

// 배열 s의 index 원소 공간에 대한 참조를 리턴
char& find(char s[], int index) { return s[index]; }

int main()
{
	char name[] = "Song";
	cout << name << endl;

	find(name, 0) = 'B';
	find(name, 1) = 'l';
	find(name, 2) = 'o';

	char& ref = find(name, 3);
	ref = 'g';

	cout << name;
}

배열의 인덱스를 인자로 받아 배열의 원소공간의 참조를 리턴하기 때문에 위처럼 함수에 값을 대입하는 신기한 코드를 사용할 수 있습니다. 차후 배우게 될 연산자 함수 같은 경우 이 참조 리턴을 사용하지 않으면 작성하기 어려울 정도로 C++에서는 많이 사용되는 개념입니다. 헷갈릴 수 있기 때문에 몇 가지 예시를 좀 더 확인하고 마무리 하겠습니다.

#include <iostream>
using namespace std;
int& addConst(int& x, int y) {
	x = x + 200;	 // main()의 변수a와 x가 같은 기억장소를 공유한다.
	y = y + 200;	 // main()의 변수b와 y가 다른 기억장소를 갖는다.
	cout << "addConst함수에서 x, y를 출력한다 " << endl;
	cout << "&x = " << &x << " x = " << x << endl;
	cout << "&y = " << &y << " y = " << y << endl;
	return x;
}
int main() {
	int a = 100, b = 10;
	addConst(a, b) = 555; 	// addConst()가 리턴한 변수x와 공유 x,a,c에 555저장
	cout << "main 함수에서 addConst(a, b) = 555일 때 a, b " << endl;
	cout << "&a = " << &a << " a = " << a << endl;
	cout << "&b = " << &b << " b = " << b << endl;
	return 0;
}

 

 

addConst 함수에서 x는 참조 변수로 받고 y는 아니기 때문에 a의 값만 변하고, b의 값은 영향을 받지 않습니다. 또한 이런 참조 리턴을 할 때 매개변수로 전달 받은 참조 변수나 전역 변수가 아닌 지역 변수를 반환하는 경우 오류가 발생합니다. 이러한 현상을 댕글링 참조(Dangling Reference) 라고 합니다.

#include <iostream>
using namespace std;
int& localVar() {
	int lvar = 200;
	cout << "&lvar(함수,지역) = " << &lvar << " lvar = " << lvar << endl;
	return lvar;
}
int main() {
	int a = localVar();
	cout << "&a(메인,지역) = " << &a << " r = " << a << endl;
	int& tmp = localVar(); // 원래 오류가 발생해야 한다.
	cout << "&b(메인, 참조) = " << &tmp << " tmp = " << tmp << endl;
}

제가 알기로 주석 단 부분에서 원래 오류가 발생하고, tmp 를 출력하려고 하면 이상한 값이 나와야 하는 것으로 알고 있는데 오류만 발생하고 값은 제대로 나오네요. 컴파일러가 좋아져서 그런건지 모르겠지만 참조 리턴은 절대 지역 변수를 리턴하지 않도록 해야합니다.

 

#include <iostream>
using namespace std;
class Complex {
private:
	double re, im;
public:
	Complex(double r) { re = r; im = 0; }
	Complex(double r = 0, double i = 0) : re(r), im(i) {}
	friend ostream& operator<<(ostream& os, Complex&);
};
ostream& operator<<(ostream& os, Complex& c1) {
	os << "(" << c1.re << " + " << c1.im << "i" << ")" << endl;
	return os;
}
int main() {
	Complex c1(1, 2), c3(3, 5);
	Complex c2 = c1; // c1객체를 c2에 복사
	cout << "&c1 = " << &c1 << " c1 = " << c1;
	cout << "&c2 = " << &c2 << " c2 = " << c2;
	Complex& refc1 = c1; // c1객체를 refc1과 메모리 공유
	cout << "&refc1 = " << &refc1 << " refc1 = " << refc1;
	return 0;
}

또한 참조 변수는 당연히 객체에도 선언할 수 있으며, 참조 변수는 원래 변수와 같은 메모리를 공유하는 것을 확인할 수 있습니다.

참고 문헌 : 명품 C++ programming