본문 바로가기
C++/강좌

[C++ 강좌] 참조자(Reference)의 개념과 함수 활용

by EZC 2021. 1. 15.
반응형

 

C++ 공식 웹사이트인 isocpp.org에 FAQ로 아래와 같은 유명한 말이 있습니다.

 

Q. When should I use references, and when should I use pointers?
(언제 참조자를 쓰고 언제 포인터를 써야 할까요?)

A. Use references when you can, and pointers when you have to.
(가능하면 참조자를 쓰고 필요하다면 포인터를 쓰십시오.)

이처럼 참조자(Reference)는 C++를 사용한다면 반드시 익혀야 하는 매우 중요한 개념입니다. 하지만 구조적으로 포인터와 유사하여 혼동이 생기기 쉬운 개념이기도 하죠.

 

그럼 아래 목차대로 참조자에 대해 하나하나 알아보도록 합시다.

 

목차 (클릭 시 이동)

 

     

    1. 참조자(Reference)의 종류

    • 비-상수 참조(non-const reference): 보통 참조자라고 하면 이것을 말합니다.
    • 상수 참조(const reference): 상수를 참조하기 위한 것이지만 비-상수도 참조가 가능합니다.
    • 우측 값 참조(rvalue reference): 이동 생성자와 관련이 있기 때문에 여기서는 논외로 하겠습니다.

     

    2. 참조자(Reference)와 규칙

    참조자란 대상에게 또 다른 이름을 붙여주는 것을 말합니다. 별명 또는 별칭을 붙여준다고도 하죠.

    #include <iostream>
    
    int main(void)
    {
    	int num = 1;
    	int& ref = num;
    
    	ref = 2;
    	
    	std::cout << "num value: " << num << std::endl; // num value: 2
    	std::cout << "ref value: " << ref << std::endl; // ref value: 2
    
    	std::cout << "num address: " << &num << std::endl; // num address와
    	std::cout << "ref address: " << &ref << std::endl; // ref address는 동일
    
    	return 0;
    }

    int num = 1;은 메모리(스택) 특정 영역에 int 만큼의 크기를 할당하고 1이라는 데이터를 복사하는 것입니다. 그리고 할당된 메모리의 이름은 num이 되는데 이것을 변수라고 합니다.

     

    int& ref = num;num을 참조하는 참조자 ref를 선언하는 것입니다. 그럼 이 변수의 이름은 num 뿐만 아니라 ref도 되는 것이죠. 즉, 하나의 메모리 공간에 이름이 2개 생기게 되고 이 둘의 주소 값이 같다라는 의미입니다. (하나의 대상을 두고 2개가 아니라 그 이상으로도 참조자를 만들 수 있습니다.)

     

    ref = 2;를 하고 값과 주소를 모두 출력해보면 numref 모두 2로 변경되고 주소가 같은 것도 확인할 수 있습니다.

     

    2.1) 선언 방법

    int num = 1;
    int& ref1 = num; // 참조자 선언
    int& ref2;       // Compile Error: 초기화 필요

    참조자를 선언하는 방법은 선언 시 & 연산자를 붙여야 하며 선언과 동시에 참조할 대상을 지정하여 초기화해야 합니다.

     

    2.3) 참조 대상 변경 불가

    int num1 = 1;
    int num2 = 2;
    
    int& ref = num1; // ref와 num1은 같음
    ref = num2;      // num2를 참조하는 것이 아니라 num1 = num2;와 같은 의미

    한 번 누군가를 참조했으면 참조의 대상을 바꿀 수 없습니다. refnum1을 참조한 상태에서 ref = num2;를 해도 refnum2를 참조하는 것이 아니라 num1 = num2;를 하는 것입니다.

     

    2.4) 상수 참조

    const int num = 10;    // 상수
    int& ref1 = num;       // Compile Error: 비-상수 참조자로 상수 참조 불가
    const int& ref2 = num; // Pass
    const int& ref3 = 100; // Pass

    비-상수 참조자(non-const reference)는 상수를 참조할 수 없지만 const를 사용하면 가능합니다.

     

    또한 const int& ref3 = 100;처럼 리터럴 상수도 참조할 수 있습니다. 여기서 100이라는 리터럴 상수는 임시적인 값으로써 메모리에 이름도 없이 존재 했다가 다음 행에서 소멸하는 것을 말합니다. 때문에 non-const 참조자는 상수를 참조할 수 없습니다. 메모리에서 이름도 없이 바로 사라지는 임시 값이니까요.

     

    하지만 위와 같이 상수를 const 참조자로 참조하면 '임시 변수'라는 것이 생기면서 참조가 가능합니다.

     

    "그렇다면 왜 굳이 const 참조자까지 사용하면서 상수를 참조할까요?"

     

    #include <iostream>
    
    int Add(const int& num1, const int& num2) // const 참조자형 매개변수
    {
    	return num1 + num2;
    }
    
    int main(void)
    {
    	std::cout << Add(1, 2) << std::endl; // 상수를 전달
        
        return 0;
    }

    Add 함수의 매개변수를 const 참조자로 받고 있습니다. 그러면 Add(1, 2) 처럼 곧바로 상수로 함수 호출이 가능하죠. 즉, const int& num1 = 1; const int& num2 = 2;가 되는 것입니다.

     

    "하지만 만약 const 참조자가 없다면?"

     

    #include <iostream>
    
    int Add(int& num1, int& num2) // non-const 참조자형 매개변수
    {
    	return num1 + num2;
    }
    
    int main(void)
    {
    	int num1 = 1;
        int num2 = 2;
        
    	std::cout << Add(num1, num2) << std::endl; // 변수를 전달
        
        return 0;
    }

    위와 같이 그저 상수 값을 더하고 싶은데도 번거롭게 num1, num2를 선언하여 변수를 전달해야 합니다.

     

    "그렇다면 참조자는 NULL을 참조할 수 있을까요, 없을까요?"

     

    2.5) NULL 참조 불가

    int& ref = NULL; // Compile Error: 비-상수 참조자로 상수 참조 불가

    const 참조자에 관해서 설명 드렸으니 알 수 있는 부분이죠? NULL은 상수 값 0define 해 놓은 것이므로 NULL0은 같습니다. 따라서 일반 참조자는 NULL 참조가 불가능합니다.

     

    이것이 포인터와의 차이점 중 하나이기도 하죠. 참조자의 매개변수나 반환으로 함수를 다루는 경우 NULL Check를 꼼꼼하게 하지 않으면 런타임 에러의 위험이 있습니다.

     

    3. 참조자(Reference)와 함수

    3.1) 참조자가 매개변수인 함수

    #include <iostream>
    
    void SwapByPTR(int* arg1, int* arg2)
    {
    	int tmp = *arg1;
    	*arg1 = *arg2;
    	*arg2 = tmp;
    }
    
    void SwapByREF(int& arg1, int& arg2)
    {
    	int tmp = arg1;
    	arg1 = arg2;
    	arg2 = tmp;
    }
    
    int main(void)
    {
    	int num1 = 1;
    	int num2 = 2;
    	int num3 = 3;
    	int num4 = 4;
    
    	SwapByPTR(&num1, &num2);
    	std::cout << num1 << " " << num2 << std::endl; // 2 1
    
    	SwapByREF(num3, num4);
    	std::cout << num3 << " " << num4 << std::endl; // 4 3
    
    	return 0;
    }

    "값에 의한 호출, 참조에 의한 호출(Call By Value, Call By Reference)"에 대해 많이 들어보셨을 것입니다. C에서 참조에 의한 호출은 포인터를 활용하는 방법 밖에 없었지만, C++에서는 참조자로도 "참조에 의한 호출"을 할 수 있습니다.

     

    (포인터 Swap 함수 설명은 생략하고) SwapByREF는 참조자를 매개변수로 받고 있습니다. 그래서 함수 호출 시 num3, num4를 그대로 전달하면 함수 내부에서 arg1, arg2를 Swap 하는 것이지만 실제로는 arg1, arg2가 참조하고 있는 num3, num4가 Swap 되는 것이죠.

     

    "그럼 참조자를 이용한 함수 호출의 장점은 무엇일까요?"

     

    #include <iostream>
    
    void PrintNum(const int arg) // 매개변수에 const를 붙여 의도치 않은 변형을 방지
    {
    	std::cout << arg << std::endl;
    }
    
    int main(void)
    {
    	int num = 1;
    
    	PrintNum(num); // const int arg = num;
    
    	return 0;
    }

    PrintNum 함수를 호출할 때 num을 매개변수로 전달하는 것은 const int arg = num;과 동일한 의미입니다. 즉, num 크기 만큼 메모리 재할당이 일어나는 것입니다. 우리는 그저 num의 값만 전달하고 싶었는데 말이죠.

     

    그런데 만약 전달하는 데이터가 int형이 아니라 엄청나게 큰 용량의 객체라면? 그만큼 거대한 용량의 메모리를 재할당 해야 하기 때문에 메모리가 낭비 되는 것입니다.

    #include <iostream>
    
    void PrintNum(const int& arg) // 매개변수에 const를 붙여 의도치 않은 변형을 방지
    {                             // 매개변수를 참조자로 변경
    	std::cout << arg << std::endl;
    }
    
    int main(void)
    {
    	int num = 1;
    
    	PrintNum(num); // const int& arg = num;
    
    	return 0;
    }

    이 경우 PrintNum함수의 매개변수를 값이 아니라 참조자로 받으면 해결됩니다. 그러면 num의 동일한 메모리에 arg라는 또 다른 이름만 부여하는 것이기 때문에 메모리 재할당이 없는 것이죠.

     

    사실 이렇게 메모리 재할당을 방지하는 것은 포인터로도 동일하게 할 수 있습니다. 다만, 참조자는 포인터와 달리 주소값 연산을 이용하여 해당 변수의 메모리가 아닌 다른 메모리에 접근할 수가 없기 때문에 안전하다는 장점이 있습니다.

     

    참고로 C에서 사용되는 자료형과 호환이 되는 자료형(char, int 등)을 POD(Plain Old Data)라고 하는데 POD 형이 아닌 데이터를 매개변수로 받을 때는 메모리 재할당의 성능 저하를 방지하기 위해 참조자로 받는 것이 일반적입니다.

     

    그런데 참조자의 근본적인 문제점이 하나 있습니다.

    int num1 = 1;
    int num2 = 2;
    
    DoSomething(num1, num2);

    DoSomething이 끝나면 num1, num2의 값은 어떻게 될까요? 답은 '모른다'입니다. 함수 호출자 입장에서는 이 함수가 매개변수로 일반 변수를 받는지, 참조자를 받는지 알 수 없습니다. 이 경우 C++ 언어적 차원에서 근본적인 해결 방안은 현재까지 없습니다.

     

    따라서 함수 작성자는 함수의 매개변수가 참조자이고 이 매개변수가 변형될 일이 없다면 매개변수를 const 참조자로 작성해야 하며, 함수 호출자 또한 함수가 매개변수를 어떤 데이터형으로 받는지 확인해야 합니다.

     

    3.2) 반환형이 참조자인 함수

    #include <iostream>
    
    int AddNum(int& arg) // 값 반환
    {
    	arg++;
    
    	return arg;
    }
    
    int main(void)
    {
    	int num1 = 1;
    	int num2 = AddNum(num1); // num1과 num2는 별개의 변수가 됨
        
    	num1++;
    
    	std::cout << num1 << std::endl; // 3
    	std::cout << num2 << std::endl; // 2
    
    	return 0;
    }

    AddNum 함수는 매개변수를 참조자로 받고 int형 값을 반환하고 있습니다. int num2 = AddNum(num1);int num2 = 2;와 마찬가지인 것이죠. (num11로 초기화 되고 AddNum에서 2가 되었으니까요.)

     

    따라서 num1num2는 완전히 별개의 변수가 된 것입니다. 결과를 출력해보면 알 수 있죠.

     

    이번엔 참조자를 반환해봅시다.

    #include <iostream>
    
    int& AddNum(int& arg) // 참조자 반환
    {
    	arg++;
    
    	return arg;
    }
    
    int main(void)
    {
    	int num1 = 1;
    	int num2 = AddNum(num1); // num1과 num2는 별개의 변수가 됨
    
    	num1++;
    
    	std::cout << num1 << std::endl; // 3
    	std::cout << num2 << std::endl; // 2
    
    	int& num3 = AddNum(num1); // 참조자로 받았기 때문에 num1과 num3은 같음
    
    	num1++;
    
    	std::cout << num1 << std::endl; // 5
    	std::cout << num3 << std::endl; // 5
    
    	return 0;
    }

    13번째 줄을 보시면 일반 변수로 참조자를 반환 받고 있는데 당연히 가능한 일입니다. num2arg의 값만 복사가 되는 것이죠. 따라서 num1, num2는 마찬가지로 별개의 변수가 됩니다.

     

    20번째 줄을 보시면 이번엔 참조자로 참조자를 반환 받고 있습니다. 즉, AddNum(num1)을 했을 때 num1에 또 다른 이름인 arg가 붙었었는데 이번에도 또 다른 이름인 num3이 붙은 것입니다. 이는 int& num3 = num1;이 되는 것이고 따라서 num1, num3은 같은 메모리 위치에 있는 것이죠.

     

    그런데 함수에서 참조자를 반환할 때 주의해야 하는 부분이 있습니다.

    #include <iostream>
    
    int& GetNum()
    {
    	int ret = 1;
    
    	return ret; // Compile Warning
    }
    
    int main(void)
    {
    	int& num = GetNum();
    
    	num = 2; // Runtime Error
    
    	return 0;
    }

    GetNum 함수의 반환형이 참조자인데 지역 변수를 반환하고 있습니다. 이렇게 되면 numret를 참조하고 싶은데 ret은 지역 변수이기 때문에 GetNum 함수가 끝나면서 메모리에서 소멸하게 됩니다. 그러면 소멸된 메모리를 참조하려는 오류를 범하는 것이죠.

     

    그래서 ret을 반환하는 부분에서 컴파일 경고가 발생합니다. (컴파일 에러가 아닙니다.) 그리고 num에 접근하려고 하면 런타임 에러가 발생하게 되죠. 이렇게 해제된 메모리를 참조하는 참조자를 댕글링 레퍼런스(Dangling Reference)라고 합니다. 함수에서 참조자를 반환할 때는 지역 변수를 반환하여 댕글링 레퍼런스가 발생하지 않았는지 각별히 주의하여야 합니다.

     


    여기까지 참조자란 무엇인지, 어떤 규칙들이 있는지, 함수의 매개변수와 반환형으로 어떻게 활용 되는지, 그리고 어떠한 방식으로 사용해야 하고 무엇을 조심해야 하는지 등을 알아 보았습니다.

     

    틀린 부분이나 궁금한 점이 있으시면 댓글 남겨주시기 바랍니다. 감사합니다! :)

     

    반응형

    댓글