소프트웨어 엔지니어링

Call by value vs Call by reference

삐멜 2019. 12. 1. 17:09

이번 포스트에서는 메서드 파라미터의 평가 전략(Evaluation Strategy)중 대표적으로 소개되는 Call by value와 Call by reference에 대해 설명하도록 한다. 

Introduction

독자에 대한 몇 가지 가정

1. 이 글의 독자는 Java, C, C++를 어느정도 알고 있거나, 새 언어를 읽는데 큰 어려움이 없다. 

2. 이 글의 독자는 포인터 *, &에 대해 약간 알고있다. (많이는 몰라도 됨..)

3. 독자의 이해를 최대한 돕기 위해 스택(Stack)과 힙(Heap)을 구분하지 않는다. 본인이 컴퓨터 아키텍쳐에 대해 잘 안다면 아래 내용중에 무엇이 스택에 생성되고 무엇이 힙에 생성되는지 스스로 찾아낼 수 있을 것이다.

4. 설명을 최대한 단순하게 하기 위해 레퍼런스와 주소를 동일한 개념으로 사용한다.

전제

1. 모든 변수는 메모리 공간에 존재하고, 메모리 주소가 있다.

예를들어 int variable = 3; 일 때, variable(=3)이 실제 존재하는 곳은 어떤 메모리 공간이고 그 메모리공간에는 주소가 있다.

Address [ Value ]
0x0000 [ 3 ] // int variable = 3 ;
0x0001 [ 5 ] // int another_variable = 5;

2. Caller vs Callee method/function

Caller method/function : 어떤 메서드를 call 하는 메서드.

Callee method/function : 어떤 다른 메서드에 의해 call되는 메서드.

e.g, 언어 = 자바

public void adderWrapper() { // adder caller
int a = 1;
adder(a);
}

public int adder(int x) { // adderWrapper callee
x = x + 1;
return x;
}

3. 메서드나 함수의 파라미터 변수(parameter variables)는 그 메서드의 로컬 변수(local variable)이다. 실제 로컬 변수와 다른 점은 caller 메서드/함수가 어떤 값을 넘겨주면 그 값으로 파라미터 로컬 변수를 초기화 해 준다는 점이다. 예를들어 위의 예를 다시 들면, 자바에서는 add(1); 의 경우 x = 1로 초기화한다. 로컬변수이기 때문에, 메서드가 리턴하면 로컬 변수는 사라진다.

3까지 이해하고 있다면 넘어가도 좋다.

Call by value

 Call by value란 메서드를 call할때 넘겨주고 싶은 변수를 지정하면, 파라미터 로컬변수가 그 caller가 지정한 변수 값의 복사본으로  초기화되는 것이다. Call by value의 대표적인 언어인 Java로 설명 해 보도록 하겠다.

public void adderWrapper() { // adder caller
int a = 1;
System.out.println("value of a before adder :" + a);
adder(a);
System.out.println("value of a after adder :" + a);
}

public int adder(int x) { // adderWrapper callee
x = x + 1;
System.out.println( "value of x :" +x);
return x;
}

위 코드의 초기 메모리 주소/값은 아래와 같다.

Address [ Value ]

0x0000 [ 1 ] // int a = 1; 이라고 했다.

0x0001 [ ] // int x;

adder(a); 처럼 파라미터에 넘겨주고 싶은 변수 이름인 'a'를 지정했다. 이 때 adder 메서드의 x는 무엇으로 초기화 되는가? 1 초기화 된다. a의 값인 1을 복사한 값으로 초기화 했으므로 call by value이다. 

Address [ Value ]

0x0000 [ 1 ] // int a = 1; 이라고 했다.
0x0001 [ 1 ] // int x 1로 초기화됨;

위 코드의 결과 값을 보자. 

value of a before adder :1
value of x :2
value of a after adder :1

왜 a의 값이 바뀌지 않았는가? adder메서드에서 어떤 변수의 값을 바꾸었나? 0x0001(x 의 주소)공간에 존재하는 값을 바꾸었다. 위의 메모리 구조를 보았을 때, 0x0001 공간에 있는 값을 바꾸는 것이 0x0000(a의 주소)에 어떤 영향을 미치는가? 그렇지 않다. 

Address [ Value ]
0x0000 [ 1 ] // int a = 1; 이라고 했다.
0x0001 [ 2 ] // x의 값이 x + 1로 업데이트 됨.

이번에는 자바의 오브젝트를 예로 들어보자.

public void toStringObject() {
Object o = new Object();
printObject(o);
}

public void printObject(Object objToPrint) {
System.out.println(objToPrint.toString());
}

위 코드의 초기 메모리 주소 값은 다음과 같다.

Address [ Value ]
0x0000 [ 0x0001 ] // Object o = new Object(); // Object 생성 후, 그 레퍼런스를 o에 저장함.
0x0001 [ new Object() ] // new Object()의 인스턴스가 존재하는 공간.
0x000A [ ] // Object objToPrint

이제 printObject(o)에 넘겨주고싶은 변수 이름인 'o'를 지정했다. 이 때, printObject 메서드의 objToPrint는 무엇으로 초기화 되는가? 0x0001즉 o안에 들어있는 값으로 초기화된다. o의 을 넘겨줬으므로 call by value이다.

Address [ Value ]
0x0000 [ 0x0001 ] // Object o = new Object(); // Object 생성 후, 그 레퍼런스를 o에 저장함.
0x0001 [ new Object() ] // new Object()의 인스턴스가 존재하는 공간.
0x000A [ 0x0001 ] // Object objToPrint new Object의 레퍼런스인 0x0001로 초기화됨.

믿기지 않는다면 아래의 예제를 확인해 보자.

public void adder(Integer x) {
System.out.println( "value of x :" +x);
x = new Integer(100);
System.out.println( "value of x after new Integer(100) :" +x);
}

public void adderWrapper() {
Integer a = new Integer(3);
System.out.println("value of a before adder :" + a);
adder(a);
System.out.println("value of a after adder :" + a);
}

처음 Integer a = new Integer(3)을 선언하면 메모리 주소/값은 아래와 같을 것이다.

Address [ Value ]
0x0000 [ 0x0001 ] // Integer a = new Integer(3) // Integer 생성 후, 그 레퍼런스를 a에 저장함.
0x0001 [ new Integer(3) ] // new Integer(3)의 인스턴스가 존재하는 공간.

다음 adder(a)를 call하면 어떻게 될까?

Address [ Value ]
0x0000 [ 0x0001 ] // Integer a = new Integer(3) // Integer 생성 후, 그 레퍼런스를 a에 저장함.
0x0001 [ new Integer(3) ] // new Integer(3)의 인스턴스가 존재하는 공간.
0x000A [ 0x0001 ] // Integer x new Integer(3) 레퍼런스인 0x0001로 초기화됨.

 위처럼 x를 위한 0x000A라는 공간에 a의 값인 0x0001을 복사해 넣을 것이다. 그리고 System.out.println("value of x : "+ x);를 실행 할 것이다. 그 후 x = new Integer(100)을 하면 어떻게 되는가?

Address [ Value ]
0x0000 [ 0x0001 ] // Integer a = new Integer(3) // Integer 생성 후, 그 레퍼런스를 a에 저장함.
0x0001 [ new Integer(3) ] // new Integer(3)의 인스턴스가 존재하는 공간.
0x000A [ 0x000B ] // x new Integer(100) 레퍼런스인 0x000B로 없데이트됨.
0x000B [new Integer(100)] // new Integer(100)의 인스턴스가 존재하는 공간.

new Integer(100)이 메모리 어느 공간(0x000B)에 생성되고 그 장소의 레퍼런스인 0x000B가 x에 할당될 것이다. 이후 메서드는 리턴한다. 메서드가 리턴하면 그 메서드에 존재하던 로컬 변수는 어떻게 되는가? 사라진다.

Address [ Value ]
0x0000 [ 0x0001 ] // Integer a = new Integer(3) // Integer 생성 후, 그 레퍼런스를 a에 저장함.
0x0001 [ new Integer(3) ] // new Integer(3)의 인스턴스가 존재하는 공간.

0x000B [new Integer(100)] // new Integer(100)의 인스턴스가 존재하는 공간.

따라서 x는 사라지고 a의 값은 여전히 변하지 않은채로 있다.

Call by reference

 Call by reference란 메서드를 call할 때 넘겨주고 싶은 변수를 지정하면, 그 변수의 레퍼런스로 파라미터 로컬변수를 초기화 하는것이다.  Call by reference를 지원하는 언어 중 가장 대표적인 c++언어의 예를 보자. 

void adder(int& x) { // 2) c++에선 int&로 선언된 변수가 레퍼런스 변수이다.
x = x + 1;
cout << "value of &x:" << &x << endl;
cout << "(referenced) value of x :" << x << endl;
return;
}

int main() {
int a = 3;
cout << "Address of a :" << &a << endl;
cout << "value of a before adder :" << a << endl;
adder(a); // 1) 나는 a(= 3)을 넘기는 것 같은데
cout << "value of a after adder:" << a << endl;
}

위 코드의 초기 메모리 주소/값은 다음과 같다.

Address [ Value ]
0x0000 [ 3 ] // int a = 3;
0x000A [ ] // int& x;

[참고! int& x; 의 뜻은 x안에는 메모리 주소가 들어있고, x를 이용할 때는 컴퓨터가 자동으로 x안에 들어있는주소를 참조(레퍼런스)한다는 뜻이다. 따라서 x에 들어있는 레퍼런스를 따라간 값을 이용하고 싶다면 그냥 x를 이용하면 된다. 이런 변수를 레퍼런스 변수라고 부른다. (이제 위 예제가 왜 call by reference인지 알겠는가?)]

[참고! 같은 맥락에서 봤을 때 자바의 인스턴스 변수는 모두 레퍼런스 변수이다.]


이제 adder(a)에 넘겨주고싶은 변수 이름인 'a'를 지정했다. 이 때, adder 메서드의 &x는 무엇으로 초기화 되는가? 0x0000 즉 변수 a(=3)이 존재하는 공간의 메모리 주소인 0x0000 값을 초기화된다. 그리고 메서드 내부에서 참조된 값인 x를 이용할 수 있다. call by value와의 차이가 무엇일까?

메서드가 처음 call되면 아래와 같은 상태로 초기화 될 것이다.

Address [ Value ]
0x0000 [ 3 ] // int a = 3;
0x000A [ 0x0000 ] // int& x = 0x0000

x = x + 1가 실행된 후에는 어떻게 되는가?

x = x + 1을 하면 &x에 있는 주소인 0x0000를 참조해 3을 가져오고 1을 더해서 다시 0x0000를 참조한 공간에 4를 넣을 것이다.

x는 int&형이므로 x = x + 1을 하는 경우 0x0000에 1 을 더는 것이 아니라 0x0000을 참조하여 얻은 값인 3에 1을 더해 0x0000주소의 공간에 할당한다.

Address [ Value ]
0x0000 [ 4 ] // int a = 4;
0x000A [ 0x0000 ] // x = x + 1;

따라서 0x0000(a)의 값은 4가 된다.

위 코드를 실행 해 보면 아래와 같은 결과가 나온다. a의 주소값과 x의 주소값이 같은 것을 확인할 수 있다.

Address of a :0x7ffebace936c
value of a before adder :3
value of &x :0x7ffebace936c
(referenced) value of x :4
value of a after adder:4

이처럼 어떤 변수를 메서드/함수에 파라미터로 넘겼을 때, 이 변수의 레퍼런스가 복사되어 넘어가는 것을 call by reference라고 부른다.

QnA

*) 위의 call by value의 오브젝트 예에서 reference를 넘겨줬는데 왜 call by reference가 아니고 call by value인가? 

 많은 사람들이 이 부분을 혼동한다. 우리가 주목해야 할 것은 '누구의' value를 또는 '누구의' reference를 넘겨주냐이다. 어떤 변수를 넘겨주는데 그 변수안에 들어있는 값이 복사되어 들어간다면 call by value, 그 변수의 레퍼런스가 복사되어 들어간다면 call by reference이다. 그 변수의 값 자체가 레퍼런스인지 기본형(primitive)인지는 중요하지 않다. 오브젝트 예의 경우에는 어떤 변수의 값 자체가 레퍼런스인 경우이다.


*) C언어에서 포인터를 넘겨주는것은 주소를 넘겨주는 것이므로 call by reference가 아닌가? 아니다. 이유는 위의 자바와 비슷하다.

아래를 보자. 편의상 C++로 작성했으나 cout을 제외한 문법은 C와 같다.

void adderWithPointer(int* x) {
     *x = *x + 1;
cout << "value of x :" << x << endl;
return;
}

int main() {
int a = 3;
int *p = &a;
cout << "value of p :" << p << endl;
cout << "value of a before adder :" << a << endl;
adderWithPointer(p);
cout << "value of a after adder:" << a << endl;
}

결과 값이 어떻게 나오는가? (주소는 매 실행마다 다름.)

value of p :0x7ffd46fdbc3c
value of a before adderWithPointer :3
value of x :0x7ffd46fdbc3c
value of a after adderWithPointer:4

adderWithPointer로 넣어주는 변수는 무엇인가? p이다. p라는 로컬변수에는 어떤 값(&a)이 담겨있다. 그 값은 0x7ffd46fdbc3c이다. p의 값 0x7ffd46fdbc3c이 복사되어 x에 들어갔으므로 이는 call by value이다. 하지만 여전히 p가 a를 레퍼런스할 수 있다는 점은 똑같지 않은가? 맞다. 이는 C언어가 포인터와 call by value의 특성을 이용해 call by reference를 시뮬레이션한 call by address이다. 이런 특성 때문에 포인터를 넘기는 것을 때때로 call by reference라고 부르기도 한다(하지만 엄밀히 말하면 call by value/call by address이다).  

call by value와 call by reference는 사람들이 많이 헷갈려한다. 또 인터넷에도 포인터 주소나 레퍼런스를 넘기면 call by reference라는 둥 정확한 설명이 많지 않고 정확히 설명한 경우가 드물다. 왜냐하면 call by value/call by reference를 더 깊기 이해하기 위해서는 메서드/함수 실행을 위한 스택이 어떻게 생성되는지, 로컬변수와 파라미터가 어떻게 생성되는지 등을 알아야 하고, C/C++의 포인터나 자바의 인스턴스 레퍼런스와 같은 개념을 알아야 하기 때문이다 (운영체제/시스템+컴파일러의 복합적인 이해가 필요하다). 따라서 위의 내용이 전부 이해되지 않더라도 괜찮다. 이에 대해 더 공부하고 싶다면 Computer Systems: Programmer's perspective같은 시스템 관련 서적이나 운영체제/컴파일러를 참고하도록 해라.