이번 강좌에서는
안녕하세요 여러분! 오랜만에 찾아온 Psi 입니다. 사실 이전 강좌에서 부터 강조해왔지만 C 언어에서 되던 것이 C++ 에서는 거의 100% 된다고 보셔도 무방합니다. 즉 기초적인 문법이 거의 똑같다는 것이지요. 이전 강좌에서는 기본적인 구문들, 예를 들어 변수의 정의 방법이나, 조건문( #include <iostream> int change_val(int *p) { *p = 3; return 0; } int main() { int number = 5; std::cout << number << std::endl; change_val(&number); std::cout << number << std::endl; } 성공적으로 컴파일 하였다면 실행 결과 5 3 와 같이 나옵니다. 저의 C 언어 강좌를 잘 따라오신 분이라면 위 코드를 무리없이 이해하실 수 있을 것입니다.
C 언어에서는 어떠한 변수를 가리키고 싶을 땐 반드시 포인터를 사용해야만 했습니다. 그런데 C++ 에서는 다른 변수나 상수를 가리키는 방법으로 또 다른 방식을 제공하는데, 이를 바로 참조자(레퍼런스 - reference) 라고 부릅니다. #include <iostream> int main() { int a = 3; int& another_a = a; another_a = 5; std::cout << "a : " << a << std::endl; std::cout << "another_a : " << another_a << std::endl; return 0; } 성공적으로 컴파일 하였다면 실행 결과 a : 5 another_a : 5 와 같이 나옵니다. int a = 3; 먼저 우리는 위와 같이 간단히 int& another_a = a; 그 후에 우리는 위 처럼 위와 같이 선언함으로써 우리는 another_a = 5; std::cout << "a : " << a << std::endl; std::cout << "another_a : " << another_a << std::endl; 따라서 위 처럼 어떻게 보면 참조자와 포인터는 상당히 유사한 개념입니다. 포인터 역시 다른 어떤 변수의 주소값을 보관함으로써 해당 변수에 간접적으로 연산을 수행할 수 있기 때문이죠. 하지만 레퍼런스와 포인터는 몇 가지 중요한 차이점이 있습니다. 레퍼런스는 정의 시에 반드시 누구의 별명인지 명시 해야 합니다. 따라서 int& another_a; 와 같은 문장은 불가능 합니다. 반면의 포인터의 경우 int* p; 는 전혀 문제가 없는 코드 입니다. 레퍼런스의 또 한 가지 중요한 특징으로 한 번 어떤 변수의 참조자가 되버린다면, 이 더이상 다른 변수를 참조할 수 없게 됩니다. 예를 들어서 int a = 10; int &another_a = a; // another_a 는 이제 a 의 참조자! int b = 3; another_a = b; // ?? 아래와 같은 코드를 살펴봅시다. 마지막에 참고로 &another_a = b; 요건 어떤가요? 라고 물어보실 수 도 있는데 위 문장은 그냥 반면에 포인터는 어떨까요. int a = 10; int* p = &a; // p 는 a 를 가리킨다. int b = 3; p = &b // 이제 p 는 a 를 버리고 b 를 가리킨다 위와 같이 누구를 가리키는지 자유롭게 바뀔 수 있습니다. 포인터의 경우를 생각해봅시다. 우리가 아래와 같이
포인터 int a = 10; int* p = &a; // p 는 메모리 상에서 당당히 8 바이트를 차지하게 됩니다.
int a = 10; int &another_a = a; // another_a 가 자리를 차지할 필요가 있을까? 만일 내가 컴파일러라면 #include <iostream> int change_val(int &p) { p = 3; return 0; } int main() { int number = 5; std::cout << number << std::endl; change_val(number); std::cout << number << std::endl; } 성공적으로 컴파일 하였다면 실행 결과 5 3 위 코드는 앞서 포인터를 사용해서 먼저 가장 중요한 부분으로 int change_val(int &p) { 와 같이 함수이 인자로 참조자를 받게 하였습니다. 여기서 아까 int& p 는 안된다고 하지 않으셨나요? 라고 물을 수 있는데 사실 change_val(number); 아무튼 위와 같이 참조자 int change_val(int &p) { p = 3; return 0; } 그 후 자 보세요. 어느 방식이 좀 더 깔끔하신 것 같나요? // 참조자 이해하기 #include <iostream> int main() { int x; int& y = x; int& z = y; x = 1; std::cout << "x : " << x << " y : " << y << " z : " << z << std::endl; y = 2; std::cout << "x : " << x << " y : " << y << " z : " << z << std::endl; z = 3; std::cout << "x : " << x << " y : " << y << " z : " << z << std::endl; } 성공적으로 컴파일 하였다면 실행 결과 x : 1 y : 1 z : 1 x : 2 y : 2 z : 2 x : 3 y : 3 z : 3 예상하고 계셨던 결과 인가요? int x; int& y = x; 먼저 위와 같이 int& z = y; 그렇다면 다음 문장을 봅시다. 간혹 아래와 같이 고개를 갸우뚱 할 수 도 있습니다. 아까 어떤 타입 좋은 질문 입니다. 하지만 참조자의 참조자 라는 말의 의미를 생각해보면 사실 말이 안된다는 것을 알 수 있습니다. 굳이 별명의 별명을 만들 필요는 없으니까요! 실제로 C++ 문법 상 참조자의 참조자를 만드는 것은 금지되어 있습니다. int& z = y; 즉, 위 문장은 결국 x = 1; std::cout << "x : " << x << " y : " << y << " z : " << z << std::endl; y = 2; std::cout << "x : " << x << " y : " << y << " z : " << z << std::endl; z = 3; std::cout << "x : " << x << " y : " << y << " z : " << z << std::endl; 결과적으로 위 문장들은 모두 아무래도 처음에 참조자를 접하시는 분들은 왜 굳이 포인터로 할 수 있는 것을 왜 참조자로 해야 하냐고 물을 수 있습니다. 하지만 참조자를 사용하게 되면 불필요한 예를 들어서 지난 강좌에서 변수 입력시 배웠던 cin 을 기억하시나요? 아마 사용자로 부터 변수에 값을 입력 받을 때 다음과 같이 했었을 것입니다. std::cin >> user_input; 그런데 무언가 이상하지 않으세요? 예전에 scanf 로 이용할 때 분명히 scanf("%d", &user_input); 와 같이 항상 주소값을 전달해 주었는데 말이죠. 왜냐하면 어떤 변수의 값을 다른 함수에서 바꾸기 위해서는 항상 포인터로 주소값을 전달해야하기 때문이니까요. 하지만 여기서는 cin 이라는 것에 그냥 왜 그럴까요? 바로 cin 이 레퍼런스로 #include <iostream> int main() { int &ref = 4; std::cout << ref << std::endl; } 위와 같은 소스를 살펴봅시다. 일단 컴파일 해보면 아래와 같은 오류가 나타날 것입니다. 컴파일 오류 error C2440: 'initializing' : cannot convert from 'int' to 'int &' 왜 오류가 나타날까요? 아마 여러분들은 다 알고 계시겠지요. 위 상수 값 자체는 리터럴 이기 때문에 (리터럴이 무엇인지 모르겠으면 여기로) 만일 위와 같이 레퍼런스로 참조한다면 ref = 5; 로 리터럴의 값을 바꾸는 말도 안되는 행위가 가능하게 됩니다. 따라서 C++ 문법 상 상수 리터럴을 일반적인 레퍼런스가 참조하는 것은 불가능하게 되어 있습니다. 물론 그 대신에; const int &ref = 4; 상수 참조자로 선언한다면 리터럴도 참조 할 수 있습니다. 따라서 int a = ref; 는 먼저 레퍼런스의 배열이 과연 가능한 것인지에 대해 부터 생각해봅시다. 앞서 말했듯이 레퍼런스는 반드시 정의와 함께 초기화를 해주어야 한다고 했습니다. 따라서 여러분의 머리속에는 다음과 같이 레퍼런스의 배열을 정의하는 것을 떠올렸을 것입니다. int a, b; int& arr[2] = {a, b}; 컴파일을 해보면 컴파일 오류 error C2234: 'arr' : arrays of references are illegal 레퍼런스의 배열을 불법(illegal) 이라고 합니다. 왜 불법인지 한 번 C++ 규정을 찾아 보면, 표준안 8.3.2/4 를 보면 놀랍게도 There shall be no references to references, no arrays of references, and no pointers to references 레퍼런스의 레퍼런스,레퍼런스의 배열, 레퍼런스의 포인터는 존재할 수 없다. 정말로 언어 차원에서 불가능 하다고 못 박아버렸습니다. 그러면 도대체 왜 안될까요? 왠지 위에서 int& arr[2] = {a, b}; 로 해서 이와 같은 주장을 하기 전에 먼저 C++ 상에서 배열이 어떤
식으로 처리되는지 생각해봅시다. 문법 상 배열의 이름은 ( 그런데 주소값이 존재한다라는 의미는 해당 원소가 메모리 상에서 존재한다 라는 의미와 같습니다. 하지만 레퍼런스는 특별한 경우가 아닌 이상 메모리 상에서 공간을 차지 하지 않습니다. 따라서 이러한 모순 때문에 레퍼런스들의 배열을 정의하는 것은 언어 차원에서 금지가 되어 있는 것입니다. 그렇다고 해서 그와 반대인 배열들의 레퍼런스 가 불가능 한 것은 아닙니다. #include <iostream> int main() { int arr[3] = {1, 2, 3}; int(&ref)[3] = arr; ref[0] = 2; ref[1] = 3; ref[2] = 1; std::cout << arr[0] << arr[1] << arr[2] << std::endl; return 0; } 성공적으로 컴파일 하였다면 실행 결과 231 먼저 가장 중요한 첫 두줄을 살펴봅시다. int arr[3] = {1, 2, 3}; int (&ref)[3] = arr; 위와 같이 따라서 따라서 int arr[3][2] = {1, 2, 3, 4, 5, 6}; int (&ref)[3][2] = arr; 역시 일차원 배열을 했을 때와 동일합니다. 먼저 아래 코드를 살펴봅시다. int function() { int a = 2; return a; } int main() { int b = function(); return 0; } 아마 여기 까지 따라 오신 분들이라면 무리 없이 이해할 수 있겠죠. 제가 주목하고 싶은 부분은 바로 이 부분 입니다. int b = function(); 여기서 무슨 일이 일어났을까요? 이미 잘 아시겠지만,
그 다음 예시를 살펴봅시다. int& function() { int a = 2; return a; } int main() { int b = function(); b = 3; return 0; } 만일 컴파일 한다면 아래와 같은 경고가 나오고 (컴파일 오류는 아닙니다.) 컴파일 오류 test.cc: In function ‘int& function()’: test.cc:3:10: warning: reference to local variable ‘a’ returned [-Wreturn-local-addr] 3 | return a; | ^ test.cc:2:7: note: declared here 2 | int a = 2; | ^ 실제로 실행해보면 실행 결과 [1] 7170 segmentation fault (core dumped) ./test 위와 같이 런타임 오류가 발생하게 되었습니다. 과연 뭐가 문제였을까요? int& function() { int a = 2; return a; }
int b = function(); 위 문장은 사실상 int& ref = a; // 근데 a 가 사라짐 int b = ref; // !!! 와 같은 의미 인데,
이와 같이 레퍼런스는 있는데 원래 참조 하던 것이 사라진 레퍼런스를 댕글링 레퍼런스 (Dangling reference) 라고 부릅니다. Dangling 이란 단어의 원래 뜻은 약하게 결합대서 달랑달랑 거리는 것을 뜻하는데, 레퍼런스가 참조해야 할 변수가 사라져서 혼자서 덩그러니 남아 있는 상황과 유사하다고 보시면 됩니다. 주의 사항 따라서 위 처럼 레퍼런스를 리턴하는 함수에서 지역 변수의 레퍼런스를 리턴하지 않도록 조심해야 합니다. 그렇다면 이 경우는 어떨까요? int& function(int& a) { a = 5; return a; } int main() { int b = 2; int c = function(b); return 0; } 이 int& function(int& a) { a = 5; return a; } 위와 같이 인자로 받은 레퍼런스를 그대로 리턴 하고 있습니다.
int c = function(b); 결국 위 문장은 그냥 그렇다면 이렇게 참조자를 리턴하는 경우의 장점이 무엇일까요? C 언어에서 엄청나게 큰 구조체가 있을 때 해당 구조체 변수를 그냥 리턴하면 전체 복사가 발생해야 해서 시간이 오래걸리지만, 해당 구조체를 가리키는 포인터를 리턴한다면 그냥 포인터 주소 한 번 복사로 매우 빠르게 끝납니다. 마찬가지로 레퍼런스를 리턴하게 된다면 레퍼런스가 참조하는 타입의 크기와 상관 없이 딱 한 번의 주소값 복사로 전달이 끝나게 됩니다. 따라서 매우 효율적이죠! 이번에는 반대로 함수가 값을 리턴하는데 참조자로 받는 경우를 생각해봅시다. int function() { int a = 5; return a; } int main() { int& c = function(); return 0; } 컴파일 하였다면 아래와 같은 오류가 발생합니다. 실행 결과 test.cc: In function ‘int main()’: test.cc:7:20: error: cannot bind non-const lvalue reference of type ‘int&’ to an rvalue of type ‘int’ 7 | int& c = function(); | ~~~~~~~~^~ 컴파일 오류를 읽어보면 상수가 아닌 레퍼런스가 int& c = function(); 왜 int& c = function(); c = 2; 와 같은 작업을 하게 된다면 앞서 보았던 런타임 오류를 보시게 될 것입니다. 하지만 C++ 에서 중요한 예외 규칙이 있습니다. 바로 다음 코드를 살펴보시죠. #include <iostream> int function() { int a = 5; return a; } int main() { const int& c = function(); std::cout << "c : " << c << std::endl; return 0; } 성공적으로 컴파일 하였다면 실행 결과 c : 5 와 같이 나옵니다. const int& c = function(); 이번에도 역시 std::cout << "c : " << c << std::endl; 그리고 심지어 그 리턴값도 제대로 출력됩니다. 원칙상 함수의 리턴값은 해당 문장이 끝나면 소멸되는 것이 정상입니다. 따라서 기존에 이번 강좌에서 다룬 것이 상당히 많은 데 간단히 정리해보자면 다음과 같습니다.
자 이렇게 C++ 상에서 레퍼런스를 사용하는 방법에 대해서 간단히 다루어보었습니다. 다음 강좌에서는 본격적으로 C++ 의 객체 지향 프로그래밍 개념에 대해서 다루어보겠습니다. 레퍼런스가 메모리 상에 반드시 존재해야 하는 경우는 어떤 경우가 있을까요? 그리고 메모리 상에 존재할 필요가 없는 경우는 또 어떤 경우가 있을 까요? (난이도 : 上) 강좌를 보다가 조금이라도 궁금한 것이나 이상한 점이 있다면 꼭 댓글을 남겨주시기 바랍니다. 그 외에도 강좌에 관련된 것이라면 어떠한 것도 질문해 주셔도 상관 없습니다. 생각해 볼 문제도 정 모르겠다면 댓글을 달아주세요. 현재 여러분이 보신 강좌는 <씹어먹는 C++ - <2. C++ 참조자(레퍼런스)의 도입>> 입니다. 이번 강좌의 모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요 |