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

Call by Value와 Call by Reference

by 별준 2020. 11. 22.

Call by Value와 Call by Reference에 대해서 프로그래밍을 공부하는 분이라면 많이 들어보셨을테고, 어떤 개념인지 잘 아실 것이라고 생각합니다.

 

  • Call by Value

Call by Value는 값에 의한 호출을 의미하며, 원본 값을 복사하여 함수 매개변수로 전달하는 것입니다. 

기본적으로 C/C++은 함수로부터 객체를 전달받거나, 함수에 객체를 전달할 때 'Call by Value' 방식을 사용합니다. 여기서 Value는 값을 담을 수 있는 모든 타입이 해당됩니다. (정수형, 문자형, 실수형, 주소값, Class 등)

 

익숙한 예시인 swap 함수를 살펴봅시다.

#include <iostream>

using namespace std;

void Swap(int a, int b)
{
	int temp = a;
	a = b;
	b = temp;
}
int main()
{
	int a = 5;
	int b = 10;

	printf("--- before swapping ---\n");
	printf("a의 값 : %d\n", a);
	printf("b의 값 : %d\n", b);

	Swap(a, b);

	printf("--- After swapping ---\n");
	printf("a의 값 : %d\n", a);
	printf("b의 값 : %d\n", b);

	return 0;
}

swap 함수에서 a와 b의 값을 서로 바꾸어 줍니다. 하지만 다들 예상하셨듯이 결과를 살펴보면 a, b는 변경되지 않습니다.

--- before swapping --- 
a의 값 : 5 b의 값 : 10 
--- After swapping --- 
a의 값 : 5 b의 값 : 10

swap 함수가 호출될 때 전달받은 인자 a, b는 main함수의 a, b의 사본이기 때문이죠. 

이처럼 C++은 함수 매개변수는 실제 인자의 '사본'을 통해서 초기화되고, 이는 함수가 반환하는 것에서도 동일합니다. 

어떤 함수가 호출되어서 어떤 값을 return할 때, 그 값의 사본을 호출한 쪽으로 전달하게 되는 것이죠.

 

그리고 함수가 호출될 때는 그 함수에 속하는 Stack Memory가 생성되고 이 Stack Memory에 전달받은 인자의 사본이 저장되어서 사용됩니다.

 

+) 주소값도 마찬가지 입니다.

void changeAddress(int* addr)
{
	int temp = 30;
	printf("바꿀 주소값 : %d\n", &temp);
	addr = &temp;
}
int main()
{
	int a = 5;
	int* d = &a;

	printf("--- before swapping ---\n");
	printf("a의 주소값 : %d\n", d);

	changeAddress(d);

	printf("--- After swapping ---\n");
	printf("a의 주소값 : %d\n", d);

	return 0;
}

위 함수의 결과는 다음과 같습니다.

--- before swapping --- 
d의 주소값 : 9697792 
바꿀 주소값 : 9697548 
--- After swapping --- 
d의 주소값 : 9697792

이처럼 changeAddress 인자로 d의 주소값을 전달받지만, 실제 d의 주소값의 사본을 전달받게 됩니다. 따라서 전달받은 인자의 주소값을 변경하여도, 실제 d가 가리키는 주소값을 변경하는 것이 아닌, Stack에 저장된 d의 주소값의 사본이 temp의 주소로 변경되는 것입니다. 따라서, 실제 주소값은 변경이 되지 않습니다.

 

  • Call by Reference

Call by Reference는 원본의 주소값을 전달해서, 원본의 값을 변경할 수 있습니다. 위에서 주소값의 call by value를 보셔서 아시겠지만, 전달하는 주소값은 실제 주소값의 사본입니다. 우리는 이 주소값을 변경할 수는 없지만, 이 주소가 가리키는 값은 실제 원본의 값이 저장되어 있기 때문에, 전달받은 주소값이 가리키는 값을 변경하면 실제 원본의 값이 변경되는 것이죠. 

다시 swap 예제로 살펴보겠습니다.

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

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

	printf("--- before swapping ---\n");
	printf("a의 값 : %d\n", a);
	printf("b의 값 : %d\n", b);

	Swap(&a, &b);

	printf("--- After swapping ---\n");
	printf("a의 값 : %d\n", a);
	printf("b의 값 : %d\n", b);

	return 0;
}
--- before swapping ---
a의 값 : 5
b의 값 : 10
--- After swapping ---
a의 값 : 10
b의 값 : 5

swap 함수는 인자로 원본의 주소값의 사본을 전달받고, 호출된 swap 함수는 전달받은 주소값이 가리키는 원본의 값을 변경하게 되어서, 원본의 값이 변경됩니다.

 

여기까지가 기본적인 call by value와 call by reference에 대한 내용입니다.

 

하지만... 저는 여기까지 설명했다가 이정도는 인터넷에서 검색하면 나오는 것이라고 면접에서 털린 기억이 있기 때문에, 추가로 더 알아보려고 합니다.(Effective C++을 참조했습니다.)

 

만약 전달받는 인자들이 단순한 자료형(int, float 등)이 아닌 Class 객체라면 어떨까요 ?

아래와 같은 class와 Student를 인자로 받고 이 인자가 유효한지 판단하는 함수(validateStudent)가 있습니다.

(설명을 위해서 대강 구현해보았습니다...)

class Person {
public:
	Person(string name, string address) : name(name), address(address) 
	{
		cout << "Person Constructor is called\n";
	}
	Person(Person& p)
	{
		cout << "Person Copy Constructor is called\n";
		this->name = p.name;
		this->address = p.address;
	}

	virtual ~Person()
	{
		cout << "Person Destructor is called\n";
	}

private:
	string name;
	string address;
};

class Student : public Person {
public:
	Student(string name, string addr, string s_name, string s_addr) : Person(name, addr), schoolName(s_name), schoolAddress(s_addr) 
	{
		cout << "Student Constructor is called\n";
	}
	Student(Student& p) : Person(p)
	{
		cout << "Student Copy Constructor is called\n";
		this->schoolName = p.schoolName;
		this->schoolAddress = p.schoolAddress;
	}

	~Student()
	{
		cout << "Student Destructor is called\n";
	}
	string getSchoolName() const { return this->schoolName; }
private:
	string schoolName;
	string schoolAddress;
};

bool validateStudent(Student s)
{
	string s_name = s.getSchoolName();

	if (s_name == "")
		return false;

	return true;
}

 

만약 validateStudent 함수가 호출될 때 어떻게 될까요?

int main()
{
	cout << "--- Create Student Object ---\n";
	Student plato("plato", "deagu", "ABC", "seoul");
	cout << "--- finish to create class object ---\n";

	cout << "--- calling validateStudent function ---\n";
	bool platoIsOK = validateStudent(plato);
	cout << "--- finish main function ---\n";
    
	return 0;
}

확실한 것은 함수가 호출될 때 전달되는 인자는 매개변수의 사본을 전달받는다라는 것입니다. 따라서 plato의 사본을 만들기 위해서 복사 생성자(Copy Constructor)가 호출됩니다. 또한, 함수 호출이 끝날 때, 소멸되므로 소멸자도 호출됩니다.

즉, 함수 호출을 하게 되면, Student의 복사 생성자 호출 한 번, 소멸자 호출 한 번이 발생합니다.

 

하지만, 여기서 끝이 아닙니다.

Student 객체에는 멤버 변수로 string  객체가 두 개가 존재하기 때문에, Student 객체가 복사될 때마다 string 객체 또한 같이 생성이 됩니다. 또한, Student 객체는 Person 객체에서 파생되었기 때문에 Student 객체가 생성되면 Person 객체가 먼저 생성되어야 합니다. Person 객체 안에 string 객체가 두 개 있으므로, string 객체 생성자가 또 두 번 호출되게 됩니다.

즉, Student 객체 하나를 인자로 전달하게 되면, Person (복사)생성자/소멸자 호출이 한 번씩, Student (복사)생성자/소멸자 호출이 한 번씩, string 객체 생성자/소멸자 호출이 4번씩 일어나게 됩니다. 총 생성자 6번, 소멸자 6번씩 호출되는 것이죠.

--- Create Student Object ---
Person Constructor is called
Student Constructor is called
--- finish to create class object ---
--- calling validateStudent function ---
Person Copy Constructor is called
Student Copy Constructor is called
Student Destructor is called
Person Destructor is called
--- finish main function ---
Student Destructor is called 
Person Destructor is called 
계속하려면 아무 키나 누르십시오 . . .

 

즉, 이 방법은 단순히 객체의 valid만 판단하는 함수에서 꽤 비효율적으로 느껴집니다.

하지만, 상수객체에 대한 참조자(reference-to-const)로 전달하게 되면, 이러한 비효율적인 문제가 해결이 됩니다.

bool validateStudent(const Student& s)
--- Create Student Object ---
Person Constructor is called
Student Constructor is called
--- finish to create class object ---
--- calling validateStudent function ---
--- finish main function ---
Student Destructor is called
Person Destructor is called
계속하려면 아무 키나 누르십시오 . . .

위와 같이 변경하게 되면, 인자를 복사하지 않기 때문에 새로 만들어지는 객체가 없어져서 훨씬 효율적으로 변경됩니다. 다만, call by reference로 호출하게 된다면, 원본이 변경될 수 있고, 우리는 validateStudent에서 원본이 변경되지 않아야한다는 점을 알고 있으므로, const로 원본이 안전장치를 두고 있습니다.

 

call by reference로 매개변수를 전달하게 되면 slicing problem(복사손실 문제)가 없어지는 장점도 있습니다.

 

아래와 같은 class가 있고, 어떤 작업을 하고 있다고 가정해봅시다.(내부 구현은 생략)

class Window {
public:
	string name() const
	{
		return this->str;
	}
	virtual void display() const
	{
		cout << "display(of Window) is called\n";
	}
	string str;
};

class WindowWithScrollBars : public Window {
public:
	virtual void display() const
	{
		cout << "display(of WindowWithScrollBars) is called\n";
	}
};

Window 클래스로 만들어지는 객체는 이름인 str을 가지고 있고, 이 str은 name이라는 멤버 함수 호출로 얻을 수 있습니다.(편의를 위해서 str도 public으로 선언했습니다.) 그리고 display 함수로 화면 표시까지 가능하고, 이 함수는 가상 함수로 선언되어 있으므로, WindowWithScrollBars 객체에서 더 세부적으로 구현해서 나타낼 수 있는 것임을 알 수 있습니다.

 

이제 이렇게 구현한 클래스를 가지고 어떤 윈도우의 이름을 출력하고 화면에 표시하는 함수를 하나 생성해봅시다.

void printNameAndDisplay(Window w)
{
	cout << w.name() << endl;
	w.display();
}

이 함수에 매개변수로 WindowWithScrollBars 객체를 전달하면 어떻게 될까요?

int main()
{
	WindowWithScrollBars wwsb;
	wwsb.str = "name";

	printNameAndDisplay(wwsb);

	return 0;
}
name
display(of Window) is called
계속하려면 아무 키나 누르십시오 . . .

매개변수 w가 생성되는데, window 객체로 전달되면서 WindowWithScrollBars 객체를 위한 정보가 사라지게 됩니다. (Window의 생성자로 만들어짐) 따라서 printNameAndDisplay에서 호출되는 display는 항상 Window::display가 됩니다.

 

어떤 생성자가 불리는지 직접 확인하기 위해서, class 구현부분을 약간 변경해보았습니다.

class Window {
public:
	Window() {}
	Window(const Window& w)
	{
		this->str = w.str;
		cout << "Window Copy Constructor is called\n";
	}
	string name() const
	{
		return this->str;
	}
	virtual void display() const
	{
		cout << "display(of Window) is called\n";
	}
	string str;
};

class WindowWithScrollBars : public Window {
public:
	WindowWithScrollBars() {}
	WindowWithScrollBars(const WindowWithScrollBars& wwsb) : Window(wwsb)
	{
		cout << "WindowWithScrollBars Copy Constructor is called\n";
	}
	virtual void display() const
	{
		cout << "display(of WindowWithScrollBars) is called\n";
	}
};

이렇게 수정하고 main을 다시 실행해보았습니다.

Window Copy Constructor is called
name
display(of Window) is called
계속하려면 아무 키나 누르십시오 . . .

Window의 복사 생성자가 호출된 것을 확인할 수 있습니다.

 

이 문제를 해결하려면, w를 상수객체에 대한 참조자(reference-to-const)로 전달하도록 하면 됩니다.

void printNameAndDisplay(Window& w)
{
	cout << w.name() << endl;
	w.display();
}
name
display(of WindowWithScrollBars) is called
계속하려면 아무 키나 누르십시오 . . .

정상적으로 WindowWithScrollBars 의 display가 호출됩니다.

 

call by reference에서 어쩌다 여기까지 오게 됬는데, 사실 메모리 측면에서 call by value와 call by reference를 설명해보라는 질문에 제대로 답을 하지 못해서 조금 더 알아보게 됬지만... 

결국에는 메모리 측면에서의 명확한 대답을 찾지는 못한 것 같고, 더 알아보려면 컴파일러 쪽에서는 어떻게 취급하는지 더 공부가 필요할 것 같습니다... ! 

 

그리고 C언어에서는 참조변수라는 것이 없으므로, 우리가 일반적으로 알던 call by reference는 공식적으로 C언어에서는 제공하지 않습니다. 대신 call by address라는 용어를 사용하지만, 엄밀히 따지자면 주소값을 복사해서 넘겨주므로 call by value와 동일합니다. 따라서 편의상 주소값을 포인터로 넘겨주는 것을 call by reference라고 하는 곳도 있는 것 같습니다.

댓글