러스트 소유권 참조와 대여 방식에 대해 공부한 내용을 정리합니다.
이전 글에서는 러스트 소유권 원리에 대해 살펴보았다. 힙 메모리에 사용된 변수의 경우, 복제(clone)라는 방식을 통해서만 특정 변수를 복사할 수 있었다. 하지만 이 방식은 경우에 따라 효율적이지 못하다. 만약 특정 변수값을 전달하기만 하면 되는 경우에는 특히 더 비효율적이다. 그렇기에 러스트는 참조와 대여라는 방법을 제공한다. 즉, 도서관에서 책 빌려주듯이 러스트도 특정 변수를 범위 내의 다른 함수에 빌려줄 수 있는 것이다. 물론 해당 변수의 소유권은 그대로 가진 채 말이다.
변수 참조
참조(Reference)는 변수의 포인터, 길이, 용량 등의 값을 저장하고 있는 그 값들을 가져오는 방식이다. 아래의 코드를 보자.
1 | fn main() { |
위 코드에서는 check_length()
가 s
를 참조하여 길이 값을 리턴한다. 이는 main()
에서 정의된 변수 s
의 소유권을 가져오지 않으며 해당 변수의 정보를 사용할 수 있다는 것을 의미한다. 변수를 참조할 때는 변수이름 앞에 &(Ampersand)
을 붙임으로써 참조변수라는 것을 나타낸다. 즉 참조는 ‘read only’ 정도의 권한을 제공받는 것이라고 볼 수 있다. 읽을 수 있기에 위 코드처럼 길이는 리턴하는 것도 가능하며, 또한 아래 코드처럼 해당 변수를 그대로 프린트 할 수도 있다.
1 | fn main() { |
위 코드는 ‘hello’라는 결과값을 도출한다. 그렇다면 ‘read only’ 권한 상태에서 불가능한 것은 무엇인가? 바로 ‘수정(edit)’이다. 만약 참조받은 변수를 일부 수정하여 사용하고 싶다면 복제밖에 방법이 없는걸까?
가변 참조
물론 몇가지 값만 추가한다면 참조받은 변수를 변경할 수 있다. 우선 아래의 코드를 살펴보자.
1 | fn main() { |
위 코드의 결과는 어떻게 나올까? 과연 프린트되는 단어는 ‘hello’일까, 아니면 ‘hello world!’ 일까? 정답은 ‘hello world!’ 이다. 그 이유는 변수를 생성할 때 가변 참조가 가능하도록 생성했기 때문이다. 가변 참조
란 소유권 없이 참조만으로도 변수의 값을 변경할 수 있는 방법이다. 가변 참조 방법을 이용하면 참조받은 변수를 수정하여 리턴할 수 있다. 하지만 러스트는 데이터 경합(Data race)을 방지하기 위하여 가변 참조에 몇 가지 제한을 둔다.
데이터 경합이란
데이터 경합(Data race)은 병렬 프로그래밍을 할 때 범하기 쉬운 오류로, 멀티 스레드가 동일한 데이터를 이용하는 도중에 다른 스레드가 업데이트 하는 경우를 말한다. 즉, 멀티 스레드로 코드를 작동시키는 과정에서 각각의 스레드가 메모리에 올라간 동일한 변수를 가져다가 사용하고, 이를 변경하는 것이다. 예시로 이해해보자.
- 변수 ‘num’을 10으로 저장한다.
- ‘add()’는 변수에 1을 더해서 리턴한다. ‘add(num)’을 멀티 스레드로 실행시킨다.
- 그러면 두 스레드 모두 ‘num’ 값인 10을 받아서 11로 리턴한다.
- 즉, 병렬로 처리되는 12를 기대했지만 사실상 두 스레드 모두 11을 리턴한다.
데이터 경합은 왜 발생하는 것일까? 데이터 경합이 발생하는 조건은 다음과 같다.
- 두 개 이상의 포인터가 동시에 같은 데이터에 접근
- 그 중 최소 하나 이상의 포인터가 데이터를 쓰기로 사용
- 데이터 동시 접근에 대한 동기화 메커니즘이 없음
러스트는 해당 문제를 컴파일러단에서 미리 찾아 개발자에게 알려준다.
가변 참조 조건 1: 가변 변수로 생성
위 코드를 보면 변수 s
는 정의될 때 mut
이라는 단어가 앞에 붙음으로서 가변 참조가 가능한 변수로 생성되었다. 따라서 add_word()
가 참조만으로도 ‘world!’를 추가할 수 있던 것이다. 만약 변경하려는 변수 s
를 가변 변수로 생성하지 않는다면 어떻게 될까?
1 | fn main() { |
컴파일러가 가변 참조가 불가능하다고 에러를 리턴한다.
가변 참조 조건 2: 소유권 범위에 가변 참조는 1개만 존재
가변 참조는 실제 데이터의 값을 변경할 수 있다. 그렇기에 2개 이상의 갸변 변수가 발생하면 당연히 데이터 경합의 문제가 발생한다. 이를 방지하기 위해 러스트는 컴파일러단에서 가변 변수 갯수를 제한한다. 만약 가변 변수 2개를 생성하면 어떻게 될까? 아래의 코드에서 확인해보자.
1 | fn main() { |
위 코드는 가변 변수 s1
, s2
를 생성한다. 러스트는 컴파일 단에서 에러를 리턴한다. 그렇다면 만약 가변 변수의 소유 범위가 다르다면 어떻게 될까?
1 | fn main() { |
위 코드는 에러 없이 컴파일이 되며, 결과값으로 “hello world!”가 2번 프린트 된다. 그 이유는 변수 s1
은 소유 범위인 {}
를 벗어나면서 메모리 할당이 해제되고 실제 데이터에 어떤 포인터도 없는 상태에서 변수 s2
가 다시 가변 변수로 생성되기 때문이다.
가변 참조 조건 3: 불변 참조와 가변 참조는 동시 사용 불가
만약 불변 참조와 가변 참조를 동시에 진행하면 어떻게 될까? 아래의 코드를 작동시켜 확인해보자.
1 | fn main() { |
결과는 당연히 되지 않는다. 왜 불변 참조와 가변 참조는 동시에 진행되지 않을까? 바로 윗 글에서 답안을 찾을 수 있다. 바로 데이터 경합의 문제가 발생하기 때문이다. 불변 참조와 가변 참조가 동시에 한 데이터를 바라보고 있기에 2개 이상의 포인터가 존재하는 상황에서 하나의 포인터(가변 참조)는 쓰기 권한도 가지고 있다. 따라서 이 또한 러스트가 컴파일 단에서 에러를 일으킨다.
죽은 참조
죽은 참조(dangling pointer)란 이미 메모리에서 해제된 데이터를 참조하는 포인터를 말한다. 아래 코드를 통해 좀 더 자세히 알아보자.
1 | fn main() { |
위 코드는 에러를 일으킨다. 그 이유는 dangle()
에서 생성된 변수 s
는 함수가 종료되는 순간 소유 범위를 벗어났기에 메모리에서 해제되기 때문이다. 즉, 변수 s
데이터는 이미 존재하지 않지만 변수 s
를 참조하는 포인터가 생성된다. 즉 죽은 참조가 발생하는 것이다. 러스트는 컴파일 단에서 이를 확인하고 에러로 반환해준다. 만약 위 코드를 죽은 참조없이 사용하려면 어떻게 해야 할까?
1 | fn main() { |
위 코드는 문제를 일으키지 않는다. dangle()
에서 생성된 변수 s
가 변수 dangling
로 이동하면서 메모리 할당이 해제되지 않기 때문이다.
참조 사용 시 주의할 점
이번 글에서는 러스트에서 참조를 사용하는 방법들에 대해 알아보았다. 위 내용들을 바탕으로 참조를 사용할 때 주의해야 할 점들을 정리해보자.
- 불변 참조는 여러 개가 가능하지만 가변 참조는 하나만 가능하다.
- 불변 참조와 가변 참조는 동시에 사용될 수 없다.
- 가변 참조는 소유 범위 내에서 1개만 사용할 수 있다.
- 참조는 항상 유효해야 한다.