Dada에서 공유(shared)가 어떻게 동작하는지, 그리고 필드 접근 시 권한이 전파되어 Rust에서 흔한 ‘임피던스 불일치’를 줄이는 방식을 살펴본다.
OK, _공유(sharing)_에 대해 이야기해 보자. 이 글은 Dada 블로그 글들 중에서 Rust와 깊은 수준에서 갈라지기 시작하는 첫 번째 글이고, Dada 방식이 주는 실질적인 장점(그리고 그 장점을 얻기 위해 내가 감수한 트레이드오프)도 처음으로 본격적으로 드러나는 글이라고 생각한다.
목표부터 시작하자. 앞에서 나는 Dada가 “as_ref를 절대 입력할 필요가 없는 Rust” 같다고 말했다. 하지만 내가 정말로 뜻한 바는 GC 같은 경험을—GC 없이—얻고 싶다는 것이다.
나는 Dada가 지향하는 경험을 설명할 때 “조합 가능(composable)”이라는 단어도 자주 쓴다. _조합 가능_이란 서로 다른 것들을 가져다가 함께 엮어서 새로운 무언가를 만들 수 있다는 뜻이다.
물론 Rust에도 조합 가능한 패턴이 많다—예를 들어 Iterator API 같은 것들. 하지만 내가 느낀 점은 Rust 코드가 종종 매우 취약(brittle) 하다는 것이다. 데이터 구조를 선언하는 방식에는 선택지가 많고, 어떤 선택을 하느냐가 그 데이터 구조를 어떻게 소비(consumed) 할 수 있는지를 좌우한다.
CharacterCharacter 타입 정의하기이 글 전반에서 반복 예시로 사용할 타입을 하나 만들자: Character. Rust에서는 다음처럼 정의할 수 있다:
rust#[derive(Default)] struct Character { name: String, class: String, hp: u32, }
Character 만들고 Arc로 감싸기이제, 어떤 이유로든 캐릭터를 프로그램적으로 구성한다고 해 보자:
rustlet mut ch = Character::default(); ch.name.push_str("Ferris"); ch.class.push_str("Rustacean"); ch.hp = 44;
여기까지는 좋다. 이제 이 Character struct를 깊은 복사 없이 여러 곳에서 참조할 수 있도록 공유하고 싶다고 해 보자. 그러려면 Arc에 넣으면 된다:
rustlet mut ch = Character::default(); ch.name.push_str("Ferris"); // ... let ch1 = Arc::new(ch); let ch2 = ch1.clone();
좋다! 이제 Character를 손쉽게 공유할 수 있다. 훌륭하다.
옆얘기지만, 이건 Rust가 _조합 가능_한 예다: Character를 완전 소유(fully-owned) 형태로 한 번 정의해 두고, 그것을 가변적으로(명령형으로) 사용해 점진적으로 구성한 다음, “얼려서(freeze)” 읽기 전용의 공유 가능한 Character를 얻었다. 이렇게 하면 명령형 언어의 장점(데이터 구성과 조작이 쉬움)과 함수형 언어의 장점(불변성이 여러 군데에서 참조될 때 버그를 막아 줌)을 동시에 얻는다. 좋다!
Character 만들고 Arc로 감싸기이제 독립적으로 작성된 다른 코드가 있다고 하자. 그 코드는 캐릭터의 _이름_만 저장하면 된다. 그런데 그 코드는 이름을 여러 곳으로 복사하게 된다. 그래서 Arc를 써서 캐릭터를 여러 곳에서 값싸게 참조했던 것처럼, 캐릭터의 _이름_도 여러 곳에서 값싸게 참조하려고 Arc를 쓴다:
ruststruct CharacterSheetWidget { // `String`이 아니라 `Arc<String>`을 쓰는 이유는, // 이 값을 여러 곳에 복사하게 되는데 // 매번 문자열을 깊은 클론으로 복사하고 싶지 않기 때문. name: Arc<String>, // ... 필드가 더 있다고 가정 ... }
좋다. 이제 문제가 생긴다. 공유된 캐릭터로부터 캐릭터 시트 위젯을 만들고 싶다:
rustfn create_character_sheet_widget(ch: Arc<Character>) -> CharacterSheetWidget { CharacterSheetWidget { // FIXME: 음, 이 간극을 어떻게 메우지? // 결국 이렇게 해야 하나. name: Arc::new(ch.name.clone()), // ... 필드가 더 있다고 가정 ... } }
아, 이거 짜증난다! 내가 원하는 건 name: ch.name.clone() 같은 것을 쓰면(사실은 ch.name이라고만 쓰고 싶지만 아무튼) Arc<String>이 나오는 것이다. 하지만 그럴 수가 없다. 대신 문자열을 깊게 클론하고, 거기에 새 Arc를 할당해야 한다. 물론 이후의 clone은 싸지겠지만, 썩 좋지는 않다.
Rust에서 이런 패턴을 자주 본다. 서로 다른 코드 조각 사이에 일종의 “임피던스 불일치(impedance mismatch)”가 생긴다. _해결책_은 제각각이지만 대체로 이런 식이다:
Character가 Arc<String>을 저장하도록 수정한다. 물론 그건 연쇄 효과가 있다. 예를 들면 더 이상 ch.name.push_str(...)를 쓸 수 없고, Arc::get_mut 같은 걸 써야 한다.&Option<String>을 Option<&String>으로 바꾸려고 opt.as_ref()를 쓰거나, &Arc<String>을 &str로 바꾸려고 &**r 같은 걸 쓴다.Dada의 목표는 이런 종류의 일을 없애는 것이다.
그럼 같은 Character 예제가 Dada에서는 어떻게 전개되는지 보자. 먼저 Character 클래스를 정의한다:
dadaclass Character( name: String, klass: String, # 아, 클래스 키워드가 있는 언어의 고난! hp: u32, )
Rust와 마찬가지로 캐릭터를 만들고 나서 나중에 수정할 수 있다:
dadaclass Character(name: String, klass: String, hp: u32) let ch: given Character = Character("", "", 22) # ----- 기억하자, "given" 권한은 # `ch`가 완전히 소유된 값임을 뜻한다 ch.name!.push("Tzara") ch.klass!.push("Dadaist") # - 그리고 `!`는 변경(mutation)을 표시한다
좋다. 이제 캐릭터를 여러 곳에서 참조할 수 있도록 공유하고 싶다. Rust에서는 Arc를 만들었지만, Dada에서는 공유가 “내장”되어 있다. .share 연산자를 사용하면 given Character(완전 소유 캐릭터)를 shared Character로 바꾼다:
dadaclass Character(name: String, klass: String, hp: u32) let ch = Character("", "", 22) ch!.push("Tzara") ch!.push("Dadaist") let ch1: shared Character = ch.share # ------ ----- # `share` 연산자는 `ch`를 소비(consumes)하고 # 동일한 객체를 반환하지만, 이제 *shared* 권한을 가진다.
이제 shared 캐릭터가 있으니 복사해서 여기저기 옮길 수 있다:
dadaclass Character(name: String, klass: String, hp: u32) # 시작부터 공유된 캐릭터 만들기 let ch1 = Character("Tzara", "Dadaist", 22).share # ----- # 또 다른 공유 캐릭터 만들기 let ch2 = ch1
공유된 객체가 있고 그 객체의 필드에 접근하면, 결과로 그 필드의 공유된(얕은) 사본을 얻게 된다:
dadaclass Character(...) # `shared Character` 만들기 let ch: shared Character = Character("Tristan Tzara", "Dadaist", 22).share # ------ ----- # `name` 필드를 꺼내면 `shared String`을 얻는다 let name: shared String = ch1.name # ------
Vec로 보는 전파이게 얼마나 멋지고 편리한지 더 강조하기 위해, .share로 공유한 Vec[String]이 있다고 하자:
let v: shared Vec[String] = ["Hello", "Dada"].share
그리고 v.share로 공유한다고 하자. 그 결과는 shared Vec[String]이다. 그리고 그 요소에 접근하면 shared String을 얻는다:
dadalet v = ["Hello", "Dada"].share let s: shared String = v[0]
이는 Rust에서 Arc<Vec<String>>를 가지고 있다가 Arc<String>을 꺼내는 것이 가능하다고 가정하는 것과 같다.
그럼 공유는 어떻게 구현될까? 답은 직관적이지 않은 메모리 레이아웃에 있다. 작동 방식을 보기 위해 Character가 메모리에 어떻게 배치되는지 살펴보자:
dada# 앞에서 본 Character 타입. class Character(name: String, klass: String, hp: u32) # String 타입은 대략 이런 식일 것이다. class String { buffer: Pointer[char] initialized: usize length: usize }
여기서 Pointer는 Dada의 unsafe 코드 시스템의 기반이 되는 내장 타입이다.1
given Character 레이아웃이제 이런 Character가 있다고 하자:
let ch = Character("Duchamp", "Dadaist", 22)
캐릭터 ch는 메모리에 대략 이렇게 배치된다(여기서는 name 필드에만 집중한다):
text[Stack frame] [Heap] ch: Character { _flag: 1 name: String { _flag: 1 { _ref_count: 1 buffer: ──────────►'D' initialized: 7 ... capacity: 8 'p' } } klass: ... hp: 22 }
설명해 보자. 먼저 모든 객체는 Rust에서 보듯이 메모리에 평평하게(flat) 배치된다. 따라서 ch의 필드들은 스택에 저장되고, name 필드도 그 안에서 평평하게 배치된다.
다른 객체를 소유하는 모든 객체는 숨겨진 필드 _flag로 시작한다. 이 필드는 객체가 공유되었는지 여부를 나타낸다(앞으로는 다른 권한을 표현하기 위해 더 많은 값을 추가할 예정이다). 값이 1이면 공유되지 않은 것이고, 2이면 공유된 것이다.
힙 할당 객체(즉, Pointer[]를 사용하는 것)는 실제 데이터 앞에 ref-count가 붙는다(정확히는 오프셋 -4에 있다). 여기서는 Pointer[char]이므로 뒤따르는 실제 데이터는 단순한 문자들이다.
shared Character 레이아웃대신 공유된 캐릭터를 만든다고 하자:
dadalet ch1 = Character("Duchamp", "Dadaist", 22).share # -----
메모리 레이아웃은 같지만, 캐릭터의 flag 필드가 이제 2가 된다:
text[Stack frame] [Heap] ch: Character { _flag: 2 👈 (이제 2다!) name: String { _flag: 1 { _ref_count: 1 buffer: ──────────►'D' initialized: 7 ... capacity: 8 'p' } } klass: ... hp: 22 }
shared Character 복사하기이제 같은 공유 캐릭터의 사본을 두 개 만들었다고 하자:
dadalet ch1 = Character("Duchamp", "Dadaist", 22).share let ch2 = ch1
이때는 _ch1의 모든 필드를 복사한 다음, _flag가 2이므로 내부의 힙 할당 데이터에 대해 ref-count를 증가시킨다:
text[Stack frame] [Heap] ch1: Character { _flag: 2 name: String { _flag: 1 { _ref_count: 2 buffer: ────────┬─►'D' 👆 initialized: 7 │ ... (이 값이 capacity: 8 │ 'p' } 2가 됨) } │ class: ... │ hp: 22 │ } │ │ ch2: Character { │ _flag: 2 │ name: String { │ _flag: 1 │ buffer: ────────┘ initialized: 7 capacity: 8 } class: ... hp: 22 }
이번에는 전체 캐릭터 대신 name 필드를 꺼내 복사한다고 하자:
dadalet ch1 = Character("Duchamp", "Dadaist", 22).share let name = ch1.name
…이때는 다음이 일어난다:
ch1을 따라가며 _flag가 2인 것을 보고, 따라서 ch1이 공유되었다고 판단한다.name에서 String 필드들을 복사한다. 캐릭터가 공유되었기 때문에:
_flag 필드를 2로 바꾼다그 결과는 다음과 같다:
text[Stack frame] [Heap] ch1: Character { _flag: 2 name: String { _flag: 1 { _ref_count: 2 buffer: ────────┬─►'D' initialized: 7 │ ... capacity: 8 │ 'p' } } │ class: ... │ hp: 22 │ } │ │ name: String { │ _flag: 2 │ buffer: ────────────┘ initialized: 7 capacity: 8 }
이 글에서는 Dada의 shared 값이 어떻게 동작하는지, 그리고 필드에 접근할 때 shared 권한이 어떻게 전파(propagate) 되는지를 보였다. _권한(permissions)_은 Dada가 객체 수명을 관리하는 방식이다. 지금까지 두 가지를 봤다:
given 권한은 유일 소유 값을 의미한다(Rust식으로 말하면 T).shared 권한은 복사 가능한 값을 의미한다(Rust에서 가장 가까운 동등물은 Arc<T>).앞으로의 글에서 ref와 mut 권한을 볼 텐데, 이것들은 대략 &와 &mut에 대응한다. 그리고 전체가 어떻게 맞물리는지도 이야기할 것이다.
이번 글은 Dada의 성격을 조금 더 본격적으로 보기 시작한 첫 글이다. 이전 몇 편을 읽고 나면, Dada가 익숙한 Rust 의미론 위에 얹은 귀여운 문법 정도로 보였을 수도 있다. 하지만 shared가 동작하는 방식에서 보듯이, Dada는 그것보다 훨씬 더 많은 것을 담고 있다.
나는 Dada를 어떤 의미에서는 “주관이 강한(opinionated) Rust”라고 생각한다. Rust와 달리, Dada는 일을 처리하는 방식에 대해 몇 가지 표준을 강제한다. 예를 들어 모든 객체(적어도 힙 할당 필드를 가진 모든 객체)는 _flag 필드를 가진다. 그리고 모든 힙 할당은 ref-count를 가진다.
이 규약들은 소소한 런타임 비용을 동반한다. 내 규칙은 기본 연산들이 “얕은(shallow)” 작업—예컨대 _flag를 토글하거나 각 필드의 ref-count를 조정하는 것—은 해도 된다는 것이다. 하지만 힙 구조를 순회해야 하는 “깊은(deep)” 작업은 할 수 없다.
이 규약을 받아들이고 그 비용을 치르는 대신, 나는 “조합 가능성(composability)”을 얻는다. 즉 Dada의 권한(예: shared)은 훨씬 자연스럽게 흐르고, 의미적으로 동등한(즉 같은 일을 할 수 있는) 타입들은 대체로 메모리에서 같은 레이아웃을 갖게 된다.