옛날 옛적에…#

이제 C를 좀 안다 생각하고 C++ 로 배우기로 한 적이 있었다.

C에는 없는 C++에서 새롭게 소개되는 여러가지 개념들이 있었는데, 그 중 앞부분에 나오는 것이 참조라는 녀석이다. 주로 & 라는 기호가 같이 붙어 있고, 사용하는 대표적인 예제가 swap 함수를 포인터 없이 구현하는 것이었다.

void swap(int& x, int& y) {
  int t = x;
  x = y;
  y = t;
}

C 에서도 아래처럼 & 연산자를 사용할 수는 있지만, 이는 주소를 얻는 연사자이지 참조 타입을 만드는 것은 아니다.

int x = 1;
int* y = &x;

저 예제만 보고, 뭐야 포인터 보다 간단하게 사용할 수 있어 편해졌네? 정도였고, 그 첫 경험의 영향으로 참조에 대한 개념이 한번도 제대로 머리에 들어오질 않게 된것 같다. 그러고도 20년 넘게 잘 살았는데, 아마도 밥먹고 살려고 사용한 언어들은 주로 VB, C#, JavaScript, Java, Kotin 같은 것들이었고, 간혹 이것들로 해결되지 않고 조금더 native 에 접근해야 하는 경우에는 (C++ 로 시도하다 결국은) C로 개발했던거 같다.

그렇게 살다 Rust를 접하고 배우던 와중, 오늘에서야 C++ 의 참조타입이 무엇인지 살짝 느낌이 와 버렸다.

C++ 의 참조는 alias 이다#

어디선가 alias 라는 말을 많이 봤던거 같은데, 그냥 같은 변수를 지칭하는 것인가보다 정도로만 생각하고 말았다. C++ 에서 다음 예제를 보자.

int x = 5;
int& r = x;
r = 10;

위를 수행하면 r 의 값은 당연히 10 이고, 신기한 것은 x10 이다라는 것이다. 신기하다라고 표현하는 것만 봐도 내가 얼마나 C++ 의 참조라는 개념을 모르고 있었던지가 드러난다.

약간의 변명을 하자면, 그 이후로 주로 사용한 Java 같은 언어에서는 모든 변수를 참조라고 부른다. 클래스 필드가 다른 객체 타입인 경우, 이 클래스는 참조를 가지고 있네 라는 식으로 말이다. 그렇지만, Java와 C++의 참조는 사실 다른 개념이었던 것이다.

Java 에서 아래 예제를 보면,

Emp a = new Emp("Steve");
Emp b = a;
b = new Emp("Bob");

위가 수행되면, 당연히 ab 는 서로 다른 객체를 나타낸다. 메모리 공간에 두 개의 Emp가 만들어지고 a와 b는 서로 다른 메모리 영역을 가리키게 된다. C++ 에서는 다르다. (위에서 보여준 예제와 동일한 개념이다)

Emp a("Steve");
Emp& b = a;
b = Emp("Bob");

마지막에 b = Emp("Bob"); 라고 하면, 참조 b 가 가리키는 대상이 바뀌는 게 아니라, b (즉 a 의 메모리)에 새로운 데이터가 ‘덮어씌워지’게 되는 것이다. 참고로, C++ 참조는 한 번 만들어지면 대상을 변경할 수 없는 alias 이다.

신기하다. 포인터를 사용했다면 당연해 보이는 코드가 참조를 사용하니 너무 달라보인다. 그러고 보면 Java에서 참조라고 얘기하는 것들은 사실 참조보다는 포인터에 더 가까운 녀석들 이었던 것이었다.

러스트#

이 모든게 러스트를 공부하면서 얻게된 깨닮음인지라, 얘기를 조금 해 보면 러스트에서는 C++ 와 달리 참조 타입을 만들기 위한 연산자가 존재한다.

let x: i32 = 10;
let y: &i32 = &x;

println!("{}", y);

위와 같이 &x (shared referece) 라고 써줘야 한다. 만일 참조가 쓰기가 가능하게 하려면 &mut x (mutable reference) 라고 사용해야 하는데, 이는 레퍼런스를 사용하는 러스트와 C++ 간의 핵심적인 차이일 것이다.

let mut x: i32 = 10;
let y: &mut i32 = &mut x;

*y = 20;

println!("{}", x);

러스트를 처음 배울때 저 참조를 별로 깊게 생각하지 않았다. 이 또한 변명이 가능한게, 러스트에서의 참조 사용법이 읽기에서는 C++ 의 레퍼런스처럼, 쓰기에서는 C 의 포인터처럼 사용되다 보니 alias 라는 개념이 바로 들어오지 않았던거 같다.

조금 더 자세히 얘기하면, 러스트에는 컴파일러가 수행하는 숨겨진(이 동네에선 주로 ergonomic 이라고 함) 기능인 자동 역참조(Deref coercion) 가 있는데, 이 녀석 덕분에 함수 호출이나 trait resolution 시 * 를 붙이지 않아도 된다. 아래처럼 레퍼런스가 참조하는 값을 다른 변수에 할당할 때는 여전히 * 역참조 연산자를 사용해야 한다.

let mut x: i32 = 10;
let y: &mut i32 = &mut x;

let z = *y;   // z 는 i32
let w = y;    // w 는 &i32

나가며#

팀에서 call by value, call by reference 로 얘기를 나누다 살짝쿵 민망한 적이 있었는데, 결국 이것도 요 개념들을 제대로 이해하지 못했던 무지였던거 같다.

이 글을 쓰게된 시작점은 use <‘lifetimes> for <‘what> - Ethan Brierley 4분 쯤의 내용.