C++과 Java를 공부하면서 헷갈렸던 것 중 하나가 call by value와 call by reference입니다. 흔히 Java가 call by reference가 가능한지에 대해 의견이 분분한 것을 보았습니다. 저도 이부분이 헷갈리기 시작하여 두 언어의 차이점에 대해 생각하며 결론을 내보았습니다. 이는 주관적인 생각이 포함되어 있으므로 의견이 다르거나 틀린 점이 있다면 지적 부탁드립니다!

먼저 결론부터 말씀드리면, Java는 call by reference를 할 수 없다고 생각합니다. 이에 대한 근거는 call by value부터 차근차근 살펴보면서 말씀드리겠습니다.

Call By Value

Call by value는 원본 값을 그대로 복사하여 매개변수로 전달하는 것입니다. 여기서 value란 무엇일까요?

Value는 말그대로 그 자체입니다. 따라서 값을 담을 수 있는 모든 타입이 가능합니다. 정수형, 문자형, 문자열, 실수형 그리고 주소값까지 모두 값이 될 수 있습니다.

Value에 대해 C++언어에서 예를 보겠습니다.

int a = 10;
char b = 'b';
float c = 1.5;
int* d = &a;
Person* p = new Person();

변수 a를 call by value로 하면 10이라는 값이 전달됩니다. p 변수를 call by value로 하면 p에 담긴 값 그대로 전달됩니다. 변수 p에는 어떤 값이 들어 있을까요? Person 객체를 선언한 주소값이 들어가겠죠? 그렇다면 이 주소값이 원본이며, 그대로 복사되어 전달됩니다.

좀 더 자세한 예제를 보겠습니다.

void CallByInteger(int argA) {
    cout << "Caller Integer: "<< argA << endl;
    argA = 20;
    cout << "Caller Integer After Changing: "<< argA << endl;
}

void CallByCharacter(char argB) {
    cout << "Caller Character: " << argB << endl;
    argB = 'c';
    cout << "Caller Character After Changing: " << argB << endl;
}

void CallByFloat(float argC) {
    cout << "Caller Float: " << argC << endl;
    argC = 10.5;
    cout << "Caller Float After Changing: " << argC << endl;
}

void CallByAddress(int* argD) {
    cout << "Caller Address: " << argD << endl;
    int temp = 30;
    argD = &temp;
    cout << "Caller Address After Changing: " << argD << endl;
}

int main()
{
    int a = 10;
    cout << "Before Callee Integer: " << a << endl;
    CallByInteger(a);
    cout << "After Callee Integer: " << a << endl << endl;

    char b = 'b';
    cout << "Before Callee Character: " << b << endl;
    CallByCharacter(b);
    cout << "After Callee Character: " << b << endl << endl;

    float c = 1.5;
    cout << "Before Callee Float: " << c << endl;
    CallByFloat(c);
    cout << "After Callee Float: " << c << endl << endl;

    int* d = &a;
    cout << "Before Callee Address: " << d << endl;
    CallByAddress(d);
    cout << "After Callee Address: " << d << endl;
    return 0;
}
Before Callee Integer: 10
Caller Integer: 10
Caller Integer After Changing: 20
After Callee Integer: 10

Before Callee Character: b
Caller Character: b
Caller Character After Changing: c
After Callee Character: b

Before Callee Float: 1.5
Caller Float: 1.5
Caller Float After Changing: 10.5
After Callee Float: 1.5

Before Callee Address: 0x7ffee074e9bc
Caller Address: 0x7ffee074e9bc
Caller Address After Changing: 0x7ffee074e944
After Callee Address: 0x7ffee074e9bc

위 예제는 main() 함수에서 int, char, float, int* 형의 값을 call by value로 각각의 함수의 매개변수로 전달하는 모습입니다.

int* d = &a; int*는 int 타입의 주소값을 저장하는 자료형입니다. &는 그 뒤에 나오는 변수의 주소값을 가져오는 키워드입니다.

Call by value의 가장 큰 특징은 원본에 영향을 미치지 않는다는 것입니다. 위 예제서도 볼 수 있듯이 caller에서 전달받은 매개변수의 값을 변경하여도, callee에서의 값은 변하지 않습니다. 이는 주소값을 전달하여도 같습니다. 마지막 예제를 보시면 callee에서 before, after 주소값이 같은 것을 볼 수 있습니다.

매개변수는 전달받은 값을 바탕으로 새로운 지역변수를 만듭니다. 즉, 원본과는 전혀 다른 메모리 공간에 새로운 변수를 만든다는 의미입니다. 따라서 매개변수가 원본의 value를 가진다면 원본과는 전혀 다른 변수라고 볼 수 있습니다. 하지만 원본의 주소값(reference)을 가진다면 원본에 접근이 가능하게 됩니다. 이는 call by reference에서 설명하겠습니다.

그렇다면 가장 헷갈렸던 주소값을 call by value로 전달하는 예제를 좀 더 살펴보겠습니다.

class Person
{
public:
    Person(string name, int age) : mName(name), mAge(age) {}
    void printInfo()
    {
        cout << "name: " << mName << ", age: " << mAge << endl;
    }
private:
    string mName;
    int mAge;
};

void ChangePersonCallByValue(Person *argP) 
{
    argP = new Person("Kim", 30);
}

int main()
{
    Person *p = new Person("Park", 25);
    ChangePersonCallByValue(p);
    p->printInfo();

    delete p;
    return 0;
}
name: Park, age: 25

Person 클래스는 이름과 나이를 가지고 있습니다. main()문을 살펴보면 이름 “Park”, 나이 25살이라는 Person 객체를 생성했습니다. 이를 call by value로 ChangePersonCallByValue() 함수에 전달했습니다. 그 후 해당 매개변수에 새로운 Person 객체를 할당하였습니다.

결과는 call by value이므로 원본에 영향을 미치지 못했습니다.

여기서 잠깐 원본에 대해 알아보겠습니다. 제가 call by value와 call by reference를 구분짓는 중요한 부분은 원본에 대해 제가 생각했던 정의입니다. 위 Person 예제에서 변수 p의 원본은 무엇일까요? 대부분 내부의 name과 age 값이라고 생각합니다. 물론 그렇게 생각할 수 있지만, 원본을 내부의 데이터까지 포함하면 헷갈리기 시작합니다.

원본은 첫 번째 예제에서 살펴본 것과 같이 변수 p 그 자체입니다. 변수 p에는 생성한 Person 객체의 주소값이 있습니다. 제가 정의한 원본은 이 주소값에 한정합니다. 왜냐하면 변수 pPerson* 자료형이며, 이는 Person 객체의 주소값을 저장하는 역할입니다. 변수 p 입장에서는 내부 데이터와는 연관이 없을 수 있겠다고 생각했습니다.

말씀드린 원본의 정의를 위와 같이 하면 call by value와 call by reference는 정확히 구분된다고 생각합니다. 원본을 내부 값까지 다 포함한다고 생각하면, 아래 예제처럼 주소값이 전달되는 경우 원본을 변경할 수 있으므로 call by reference라고도 볼 수 있어 기준이 흔들립니다.

class Person
{
public:
    Person(string name, int age) : mName(name), mAge(age) {}
    void changePerson(string name, int age)   // 1)
    {
        mName = name;
        mAge = age;
    }
    void printInfo()
    {
        cout << "name: " << mName << ", age: " << mAge << endl;
    }
private:
    string mName;
    int mAge;
};

void ChangePersonCallByValue(Person *argP) 
{
    argP->changePerson("Kim", 30);
}

int main()
{
    Person *p = new Person("Park", 25);
    ChangePersonCallByValue(p);
    p->printInfo();

    delete p;
    return 0;
}
name: Kim, age: 30

위 예제는 똑같이 call by value로 전달하였는데, 결과는 내부 값이 변경되었습니다. 하지만 아래 main() 함수로 변경하여 실제 원본 값을 비교해보면 원본은 변경된 것이 아닙니다.

int main()
{
    Person *p = new Person("Park", 25);
    cout << "Before call by value: p = " << p << endl;
    ChangePersonCallByValue(p);
    p->printInfo();
    cout << "After call by value: p = " << p << endl;

    delete p;
    return 0;
}
Before call by value: p = 0x7f8a8c405830
name: Kim, age: 30
After call by value: p = 0x7f8a8c405830

현재까지는 객체만을 살펴봤지만, 배열도 같습니다. 배열 역시 이를 사용하는 변수는 가장 첫 번째를 가리키는 주소값이 저장되어있습니다. 그 외의 요소는 위에서 살펴본 객체 예제처럼 내부 값이라고 생각할 수 있습니다.

정리하면, call by value는 원본을 변경할 수 없습니다. value가 주소값을 포함해서 어떤 자료형의 값이 와도 실제 원본은 변경할 수 없습니다.

Call By Reference

Call by reference는 원본의 주소값을 전달하므로, 원본을 변경할 수 있습니다. 왜냐하면 주소값을 통해 원본에 접근할 수 있기 때문입니다. 그런데 중요한 점이 한 가지 있습니다. 주소값을 저장할 수 있는 자료형을 제공해야 call by reference를 사용할 수 있습니다.

원본의 주소값이라고 하면 원본이 할당되어 있는 메모리의 주소값을 말합니다. 따라서 원본의 값이 주소값인 것과 상관없이 어떤 종류의 값이라도 이에 할당되어 있는 메모리 주소가 있을 것입니다.

원본의 주소값을 가질 수 있는 방법은 이를 저장할 수 있는 자료형이 존재한다는 의미와 동일합니다. 먼저 가장 대표적인 Swap() 예제를 살펴보겠습니다.

void Swap(int *num1, int *num2)
{
    int temp = *num1;
    *num1 = *num2;
    *num2 = temp;
}

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

    cout << "Before Swap: " << "a = " << a << ", b = " << b << endl;
    Swap(&a, &b);
    cout << "After Swap: " << "a = " << a << ", b = " << b << endl;

    return 0;
}
Before Swap: a = 10, b = 20
After Swap: a = 20, b = 10

Swap() 함수는 int형 변수 두 개를 call by reference로 전달하여 서로 맞바꾸는 역할을 합니다. 결과를 보면 caller 함수 내부의 변경으로 callee 함수의 원본에 영향을 끼친 것을 알 수 있습니다.

이가 가능한 것은 위 예제에서 사용한 C++언어가 제공하는 *, & 키워드 때문입니다. 이 키워드에 대해서는 가장 첫번째 예제에서 설명했습니다. 짧게 요약하면 변수의 주소값을 추출하고, 이를 저장할 수 있는 키워드입니다.

위 예제를 스택 메모리로 살펴보면 더 쉽게 이해할 수 있을 것입니다.

아래 메모리 그림은 매우 단순화한 것입니다.

  • Swap() 함수 실행 전

swap1

  • Swap() 함수 실행 후

swap2

위 그림의 주소값 2000부터 위로는 Swap() 함수의 메모리 공간입니다.

이렇게 C/C++과 같이 변수의 주소를 추출하고, 저장할 수 있는 키워드를 제공하는 언어는 call by reference를 할 수 있습니다. 하지만 이를 할 수 없는 Java는 call by reference를 할 수 없습니다.

그렇다면 C++에서 객체를 call by reference로 전달하는 예제를 살펴보겠습니다.

class Person
{
public:
    Person(string name, int age) : mName(name), mAge(age) {}
    void printInfo()
    {
        cout << "name: " << mName << ", age: " << mAge << endl;
    }
private:
    string mName;
    int mAge;
};

void ChangePersonCallByReference(Person **argP)
{
    delete (*argP);
    (*argP) = new Person("Kim", 30);
}

int main()
{
    Person *p = new Person("Park", 25);
    ChangePersonCallByReference(&p);
    p->printInfo();

    delete p;
    return 0;
}
name: Kim, age: 30

위 예제는 call by value에서는 할 수 없었던 caller 함수 내부에서 원본에 새로운 Person 객체를 할당하는 모습입니다.

Person* 자료형의 주소를 저장하기 위해 *키워드를 사용하여 Person** 자료형을 사용하였고, 이 주소를 추출하기 위해 & 키워드를 사용했습니다. 그렇다면 실제 주소값이 변경되는지 살펴보겠습니다.

int main()
{
    Person *p = new Person("Park", 25);
    cout << "Before call by reference: p = " << p << endl;
    ChangePersonCallByReference(&p);
    p->printInfo();
    cout << "After call by reference: p = " << p << endl;

    delete p;
    return 0;
}
Before call by reference: p = 0x7ff858c05830
name: Kim, age: 30
After call by reference: p = 0x7ff858c05850

After에서 원본 값이 변경된 것을 볼 수 있습니다.

위 변수 p의 값이 같게 나오시는 분들은 ChangePersonCallByReference() 함수안의 delete (*argP);를 지우고 실행해보시길 바랍니다. 워낙 간단한 로직이다 보니 delete한 메모리에 바로 새로운 객체를 할당하여 같은 주소값이 되는 경우가 있었습니다.

이 예제 역시 Swap() 함수 예제처럼 메모리를 살펴보면 좀 더 쉽게 이해할 수 있을 것입니다.

  • ChangePersonCallByReference() 함수 실행 전

before_calling_method

  • ChangePersonCallByReference() 함수 실행 후

after_calling_method

C++에서 new 키워드로 객체를 생성하면 Heap 메모리에 할당됩니다.

정리

지금까지 살펴본 것을 정리해보겠습니다.

  • Call By Value: 원본 값을 그대로 복사하여 전달하며, 원본에 영향을 미칠 수 없습니다.
  • Call By Reference: 원본의 주소값을 복사하여 전달하며, 원본에 영향을 미칠 수 있습니다.
    • 변수의 주소값을 추출 및 저장하는 기능을 언어 차원에서 제공해주어야 call by reference가 가능합니다.
  • C/C++는 Call By Value, Call By Reference 둘 다 가능하지만, Java는 Call By Value로만 동작할 수 있습니다.

C++로 call by reference를 사용하여 코딩하며 생각한 것이지만, call by reference를 사용하면 가독성이 안좋아진다고 생각합니다. 함수를 볼 때마다 매개변수로 전달받은 값이 원본에 영향을 주는지 주의깊게 살펴봐야합니다. 이러한 부분은 컨벤션을 정해놓고 코딩하는 것이 좋을 것 같습니다. (제가 알기로는 C++을 사용하는 회사의 컨벤션에 이와 관련된 것이 있는 것으로 알고 있습니다.)