2013년 초 공식 러스트 튜토리얼을 바탕으로, 당시 러스트 언어의 문법·타입 시스템·스레딩·빌드 환경을 현재와 비교해 정리하고 변화 과정을 되짚어 본다.
URL: https://purplesyringa.moe/blog/a-look-at-rust-from-2012/
Title: A look at Rust from 2012
2012년의 러스트를 돌아보며
얼마 전 brson의 러스트 인용구 데이터베이스를 훑어보다가 2013년 초 즈음의 공식 러스트 튜토리얼에 대한 링크를 발견했다. 구석에 Rust 0.6이라고 쓰여 있지만, 0.6에서 제거된 것들이 많이 문서에 남아 있어서 실제로는 0.5에 더 가까운 것으로 보인다.
예전 러스트에 대한 이야기들은 들어본 적이 있지만, 그 언어가 당시 프로그래머들에게 어떻게 느껴졌는지는 잘 몰랐다. 그래서 튜토리얼에 나온 당시 러스트를 (비교적) 짧게 정리해 보고, 그로부터 지금까지 우리가 얼마나 멀리 와 있는지 조금 떠들어 보면 재밌겠다고 생각했다.
첫인상은 중요한 법이고, 러스트는 그 기대를 저버리지 않는다:
현재 러스트 컴파일러는 tarball에서 직접 빌드해야 합니다. 단, 윈도우에서는 인스톨러 사용을 권장합니다.
…그리고는 고전적인 ./configure && make && make install 튜토리얼이 이어진다. 빌드 과정은 Python 2.6에도 의존했다. 윈도우에 러스트를 설치하려면 MinGW를 수동으로 설치해야 했다. 지금의 rustup은 정말 축복이다!
우리의 "Hello, world!"는 다음과 같다:
rustfn main() { io::println("hello?"); }
io는 core의 일부였고, core의 모듈들은 전역에서 보였다. alloc은 없었기 때문에 예를 들어 vec도 core의 일부였다. core와 std의 차이는 지금처럼 플랫폼 제약보다는 저수준 vs 고수준 정도의 차이에 가까웠다.
예쁜 에러 메시지는 아직 없었고, 친절한 진단 메시지는 그보다 나중의 추가였다:
texthello.rs:2:4: 2:16 error: unresolved name: io::print_with_unicorns hello.rs:2 io::print_with_unicorns("hello?"); ^~~~~~~~~~~~~~~~~~~~~~~
println!은 없었지만, sprintf 스타일 포맷 문자열을 받는 fmt!가 있었다(이걸 벗어난 건 정말 잘한 일이다):
rustio::println(fmt!("%s is %d", "the answer", 43)); // %? 는 아무 타입이나 편하게 출력해 준다 io::println(fmt!("what is this thing: %?", mystery_object));
매크로 얘기를 하자면, 현재의 macro_rules! 문법이 그때와 비교해 놀라울 만큼 적게 바뀌었다는 점이 인상적이다. 당시의 오늘날 매크로들은 "syntax extensions"라고 불렸고, "macros"라는 말은 선언적 매크로만 가리켰다.
내 개인적인 생각으론, 당시 책은 소유권과 빌림보다는 문법에 너무 초점을 맞추고 있었다. 하지만 지금의 모델이 그때는 존재하지 않았으니 이해할 만한 일이기도 하다. 현대의 Rustbook은 핵심으로 더 빨리 들어가고, 섹션 사이에 현실적인 예제들을 더 잘 녹여 넣는다.
usize는 uint, isize는 int로 쓰였는데, C 개발자들에게는 꽤 헷갈렸을 것 같다. 제약 없는 정수 리터럴의 기본 타입은 i32가 아니라 int였다. ()는 "nil type" 또는 "unit type"이라고 불리며 용어가 일관되지 않았다.
파이썬 스타일의 assert 문도 있었다:
rustlet x: float = 4.0; let y: uint = x as uint; assert y == 4u;
continue는 왜인지 loop라고 불렸다:
반복문 안에서
break키워드는 루프를 중단하고,loop는 현재 반복을 중단한 뒤 다음 반복으로 계속 진행합니다.
enum variant는 C처럼 스코프를 갖지 않았다:
rustenum Direction { North, East, South, West }이 선언은
North,East,South,West네 개의 상수를 정의하고, 이들은 모두Direction타입입니다.
variant가 스코프를 갖지 않았기 때문에, enum으로 튜플 비슷한 struct를 흉내 낼 수 있었다:
단일 variant를 가지는 enum에는 특별한 규칙이 있어서, 때때로 Haskell의 "newtype" 기능에서 따와 "newtype-style enums"라고 부르기도 합니다. […] 만약 이렇게 쓴다면:
rustenum GizmoId = int;이는 다음의 축약형입니다:
rustenum GizmoId { GizmoId(int) }
이게 왜 유용했을까? 내가 알기로는, 그 당시에는 튜플도 튜플 struct도 원소가 2개 미만일 수 없었다! (T,)는 존재하지 않았고, ()는 튜플로 취급되지 않았다. .0 같은 문법도 없어서 튜플 내용에 접근하려면 구조 분해를 해야 했다. 혹은 newtype-style enum은 *로 역참조할 수 있었다.
조금 앞서나가 보자면, 그때는 .clone() 대신 copy 연산자가 있었다:
만약 진짜로 owned box를 복사하고 싶다면, 명시적으로 말해 주어야 합니다.
rustlet x = ~10; // NOTE(purplesyringa): 신경 쓰지 않아도 됩니다 :) let y = copy x; let z = *x + *y; assert z == 20;
모든 배열을 "vector"라고 불렀다. [T; N]은 [T * N]이었는데, 나중에 [expr; N] 문법을 지원하기 위해 바뀌었다:
rust// 고정 크기의 스택 벡터 let stack_crayons: [Crayon * 3] = [Almond, AntiqueBrass, Apricot];
트레이트 구현은 impl Type: Trait라고 썼다. 이 문법은 지금 봐도 꽤 괜찮아 보인다.
rustimpl TimeBomb : Drop { fn finalize(&self) { for iter::repeat(self.explosivity) { // NOTE(purplesyringa): 신경 쓰지 마세요 :) io::println("blam!"); } } }
Drop의 메서드는 finalize라고 불렸는데, 곧 왜 그런지 설명이 될 것이다.
Self는 self로 썼는데, 이것도 혼동을 더했다:
rust// 트레이트 안에서 `self`는 self 인자이자 // 트레이트를 구현하는 타입 둘 다를 가리킨다 trait Eq { fn equals(&self, other: &self) -> bool; }
트레이트 경계 사이에는 +가 없었다:
rustfn print_all<T: Printable Copy>(printable_things: ~[T]) { // [...] }
use path as alias가 생기기 전에는 use alias = path가 있었다. 어느 쪽이 더 좋은지는 잘 모르겠다. as는 하나의 줄에서 여러 개를 import할 수 있게 해 주지만, 패턴에서처럼 :로 쓰지 않은 건 역시나 의문이다.
rust// `chicken`을 스코프로 가져오기 use farm::chicken; fn chicken_farmer() { // 같은 것을 불러오되 `my_chicken`이라는 이름을 붙인다 use my_chicken = farm::chicken; ... }
dyn Trait은 없고 그냥 Trait만 있었기 때문에, 어떤 포인터가 fat pointer인지 명시적으로 드러나지 않았다. 이 점은 꽤 남용되었다. Fn* 트레이트 대신에 fn()이 있었고, 대략 지금의 dyn FnMut()와 비슷했다. 콜백 타입으로는 보통 &fn(...) -> ...를 썼다. 클로저의 move는 추론되었다.
아마 fn() 앞의 &는 기호(sigil)가 없을 때 묵시적으로 붙는 것 같지만, 호출되는 쪽에서는 &를 쓸 필요가 없어서, 동적 디스패치임에도 호출 위치의 모양은 지금과 거의 비슷했다:
rustfn call_closure_with_ten(b: fn(int)) { b(10); } let captured_var = 20; let closure = |arg| println(fmt!("captured_var=%d, arg=%d", captured_var, arg)); call_closure_with_ten(closure);
러스트에 제어 흐름 구조를 구현하기 위한 기능이 있었던 걸 알고 있었는가?
do표현식은 고차 함수(클로저를 인자로 받는 함수)를 제어 구조처럼 다룰 수 있는 방법을 제공합니다. […] 정수 벡터를 순회하며 벡터의 각 정수에 대한 포인터를 넘기는 다음 함수를 생각해 봅시다:rustfn each(v: &[int], op: fn(v: &int)) { let mut n = 0; while n < v.len() { op(&v[n]); n += 1; } }호출하는 쪽에서 마지막 연산 인자를 클로저로 넘기면, 상당히 보기 좋은 블록 형태로 코드를 쓸 수 있습니다.
rusteach([1, 2, 3], |n| { do_some_work(n); });이 패턴은 너무나 유용하기 때문에, 러스트에는 이를 내장 제어 구조처럼 쓸 수 있는 특별한 형태의 함수 호출 문법이 있습니다:
rustdo each([1, 2, 3]) |n| { do_some_work(n); }
이런 스타일은 루비나 코틀린 같은 언어에서도 여전히 지원되고 있고, 꽤 멋지다. 하지만 이 패턴이 언어 차원에서 지원된다는 사실이 정말로 흥미로운 지점은 push 이터레이터다:
rustfn each(v: &[int], op: fn(v: &int) -> bool) { // NOTE(purplesyringa): `fn(...)` 안에 이름 붙은 인자! let mut n = 0; while n < v.len() { if !op(&v[n]) { break; } n += 1; } } // [...] for each([2, 4, 8, 5, 16]) |n| { if *n % 2 != 0 { println("found odd number!"); break; } }
for 루프는 여기에 bool 하나만 더해서 break와 루프 본문 내부에서의 return을 지원하는 식으로, 같은 메커니즘을 활용하고 있다. 러스트는 왜 pull 이터레이터로 전환했을까? 나도 잘 모르겠다! 그걸 뒷받침해 줄 만한 자료를 찾지 못했기 때문에, 누군가의 의견을 들어 보고 싶다.
11월 26일 수정: 메일링 리스트의 이 글이 그 전환의 촉매가 된 듯하다. 주요 논거는,
zip같은 다중 이터레이터 변환이 push 이터레이터와 잘 맞지 않고, 이터레이터 상태를 저장할 수 없어(결국 async와도 호환되지 않게 될 것이고), pull 이터레이터는 코루틴이 추가되면 그걸로 모사할 수 있다는 것이었다. 그리고 우리는 지금, 마지막 한 조각을 여전히 기다리고 있다.
옛 러스트에는 green thread가 있었다. 아마 그 어떤 언어보다 Erlang에 가까웠을 것이다.
러스트의 경량 태스크는 메모리를 공유하지 않고, 대신 메시지로 통신합니다.
(출처: Rust Tasks and Communication Tutorial) 러스트 태스크는 동적으로 크기가 변하는 스택을 가집니다. 태스크는 생명 주기 시작 시 적은 크기의 스택(플랫폼에 따라 수천 바이트 수준)을 가지며, 필요에 따라 더 많은 스택을 할당받습니다.
패닉은 exception이라고 불렸고 fail!()로 트리거했다. 태스크 전체를 다운시켰고, std::panic::catch_unwind는 존재하지 않았지만, 패닉을 잡기 위해 별도의 경량 태스크를 만들 수는 있었다:
rustlet result: Result<int, ()> = do task::try { if some_condition() { calculate_result() } else { die!(~"oops!"); } }; assert result.is_err();
…아직 Box<dyn Any + Send + 'static> 같은 에러 타입은 없었다. do 키워드 사용에 주목하자.
단일 생산자 단일 소비자(spsc) 파이프가 내장되어 있었고, 태스크가 다른 태스크를 자동으로 중단시킬 수도 있었다:
러스트 용어에서 _channel_은 파이프의 송신 쪽 끝점이고, _port_는 수신 쪽 끝점입니다. […] 모든 태스크는 기본적으로 서로 연결(linked) 되어 있습니다. 이는 모든 태스크의 운명이 서로 얽혀 있다는 뜻으로, 하나가 실패하면 나머지 모두도 함께 실패한다는 의미입니다.
rustlet (receiver, sender): (Port<int>, Chan<int>) = stream(); do spawn |move receiver| { // 양방향 링크 // 감독받는 자식 태스크가 존재할 때까지 기다린다. let message = receiver.recv(); // 자식과 부모 태스크 둘 다를 죽인다. assert message != 42; } do try |move sender| { // 단방향 링크 sender.send(42); sleep_forever(); // 강제로 깨워질 것이다 } // 이 지점까지 흐름이 오지 않는다 -- 부모 태스크도 함께 죽었기 때문.
태스크를 제거하기로 한 결정은 어쩌면 러스트의 미래를 그 무엇보다도 크게 바꿔 놓았다. 이 덕분에 언어 런타임을 제거할 수 있었고, 그로써 임베디드 환경, OS 커널, 기존 C 코드베이스에 러스트를 통합할 수 있게 됐다. 그리고 이제 러스트가 충분히 저수준 언어가 되었기 때문에, stackful 코루틴은 라이브러리 코드로 다시 가져올 수 있게 되었다.
cargo가 없었기에 Cargo.toml도 없었다. 크레이트 메타데이터는 현재의 lib.rs/main.rs에 해당하는 루트 파일 <cratename>.rc 안에 지정했다:
rust// 크레이트 링크 메타데이터 #[link(name = "farm", vers = "2.5", author = "mjh")]; // 라이브러리 생성 (기본은 "bin") #[crate_type = "lib"]; // 경고 켜기 #[warn(non_camel_case_types)] // 표준 라이브러리에 링크 extern mod std; // 다른 파일에서 모듈 로드 mod cow; mod chicken; mod horse; fn main() { ... }
std에 대한 명시적인 링크와 extern crate 대신 extern mod 사용에 주목하자. 크레이트를 특정 기준으로 검색할 수도 있었다:
rustextern mod farm; extern mod my_farm (name = "farm", vers = "2.5"); extern mod my_auxiliary_farm (name = "farm", author = "mjh");
…물론 이 크레이트들을 rustc로 직접 컴파일해서 라이브러리 경로를 수동으로 넘겨줘야 했다.
#[repr]이 없었기 때문에, 모든 struct는 C와 호환되는 레이아웃을 가졌다:
Struct는 C의 struct와 아주 비슷하며, 메모리 상에서 배치되는 방식도 동일합니다(따라서 C에서 러스트 struct를 읽을 수 있고, 그 반대도 가능합니다).
struct 필드는 mut로 표시해 가변성 여부를 나타낼 수 있었다. 이는 타입 시스템 전반에도 영향을 주었다. 지금의 &, &mut 대신 &, &mut, &const 세 가지가 있었다:
&const는 읽기 전용으로, 지금의 &와 비슷했다. 어떤 바인딩에도 &const를 취할 수 있었다.&mut는 지금의 &mut처럼 전체 객체를 교체할 수 있었고, let mut 바인딩이나 mut 필드(이 둘을 합쳐 _mutable memory_라 불렀다)에만 취할 수 있었다.&는 mut 필드는 수정할 수 있지만 immutable 필드는 수정할 수 없었으며, let 바인딩이나 immutable 필드(immutable memory)에만 취할 수 있었다. 예를 들어 &fn이 클로저가 자신의 환경을 수정할 수 있도록 허용했던 이유가 이것이다. 이 때문에 가변성을 추가하는 것이 능력을 단조롭게 확장시키지 않았고, 다시 말해 let vs let mut는 단순한 lint 이상을 의미했다.&는 꽤 범용적이어서 사실상의 "기본" 참조 타입이었다. 대부분의 메서드는 &self를 받았으므로 수신자 매개변수는 선택 사항이었다. 당시 문서에서 이런 패턴을 자주 볼 수 있다. 반대로 연관 함수는 명시적으로 표시해야 했다:
구현부는
self인자를 명시적으로 갖지 않는 정적(static) 메서드도 정의할 수 있습니다.static키워드는 정적 메서드와self를 가진 메서드를 구분합니다:rustimpl Circle { fn area(&self) -> float { ... } static fn new(area: float) -> Circle { ... } }
필드와 메서드는 기본이 pub이었고, 그래서 priv 가시성도 존재했다:
rustmod farm { pub struct Farm { priv mut chickens: ~[Chicken], priv mut cows: ~[Cow], farmer: Human } // 주의 - impl에 대한 가시성 한정자는 현재 효과가 없다 impl Farm { priv fn feed_chickens(&self) { ... } priv fn feed_cows(&self) { ... } fn add_chicken(&self, c: Chicken) { ... } } // [...] }
&T만 참조 타입이 아니었다. 나머지 두 종류인 @T, ~T는 사람들이 기호(sigil)를 싫어하게 된 거의 결정적인 이유로 보인다(이미 0.6 전까지 사라진 modes와 함께 악명을 공유했다).
@T는 태스크 로컬 가비지 컬렉티드 힙에 있는 객체를 가리켰다. 이런 참조는 자유롭게 복사할 수 있었지만, 다른 태스크로 보낼 수는 없었다. 지금의 Rc<T>에 가장 가깝고, GC를 단순화해 주었다. ~T는 전역, 송신 가능한(unique owner를 둔) 객체용이었고, 즉 Box<T>에 해당했다. 둘 다 &T로 변환할 수 있었는데, &T는 sendable이 아니었기 때문에, 태스크 간 통신의 유일한 방법은 ~T였다.
rust// 고정 크기의 스택 벡터 let stack_crayons: [Crayon * 3] = [Almond, AntiqueBrass, Apricot]; // 스택에 할당된 벡터에 대한 빌린 포인터 let stack_crayons: &[Crayon] = &[Aquamarine, Asparagus, AtomicTangerine]; // 로컬 힙(관리되는) 크레용 벡터 let local_crayons: @[Crayon] = @[BananaMania, Beaver, Bittersweet]; // 익스체인지 힙(owned) 크레용 벡터 let exchange_crayons: ~[Crayon] = ~[Black, BlizzardBlue, Blue];
~T/@T의 의미는 대부분 타입 T에 의해 결정되었다. ~[T]는 Box<[T]>가 아니라 Vec<T>에 해당했다. String은 ~str로 썼다. @[T]/@str은 그다지 잘 작동하지 않은 것 같다:
Note: […] 슬라이스와 스택 벡터에 대한 일부 연산은 아직 잘 지원되지 않습니다. owned 벡터가 가장 쓸 만한 경우가 많습니다.
NLL(Non-Lexical Lifetimes)은 없었다. 그때의 라이프타임(당시에는 종종 "region"이라고 불렸다)은 렉시컬이었고, 소스 코드의 특정 블록과 대응했다:
rustfn example3() -> int { let mut x = ~{f: 3}; if some_condition() { let y = &x.f; // -+ L return *y; // | } // -+ x = ~{f: 4}; ... }
라이프타임 표기는 &'r Point가 아니라 &r/Point처럼 썼고, 라이프타임 이름 r은 함수의 제네릭 매개변수로 명시적으로 나열할 필요가 없었다:
ruststruct Point {x: float, y: float} fn get_x(p: &r/Point) -> &r/float { &p.x }
이는 실제로는 꽤 일관적이다. 타입이 라이프타임 매개변수를 가질 수 없었기 때문이다. 지역 데이터에 대한 포인터를 저장하고 싶다면, &T 대신 @T를 사용해야 했다.
이 글의 나머지 부분은 borrow 튜토리얼을 이해해 보려 애쓴 기록이다. 이건 내 뇌를 튀겨 버렸고, 현대 러스트 실력을 후퇴시키는 부작용까지 있었다. 그러니 조심하길. 니코 마차키스가 이 난장판을 aliasing XOR mutability로 대체해 준 것이 얼마나 다행인지 모른다.
참조는 주로 aliasing을 막기보다 유효성(validity)을 추적하는 데 쓰였다. &mut조차도 유일한 접근을 의미하지 않았다. 하나의 객체에 대해 두 개의 &mut 참조를 만들고 둘 다에 쓸 수 있었고, 두 개의 & 참조를 만든 다음 둘 다를 통해 가변 필드에 쓸 수도 있었다. 옛 &T는 오늘날의 &UnsafeCell<T>에 가장 비슷하다.
그렇다면 왜 &T(또는 &mut T)를 통해 쓰는 것이 레이스가 아니었을까? &T는 태스크 로컬이어야 했으므로, 이전에 같은 태스크 안에서 @T(역시 태스크 로컬)나 ~T(유일성이 보장되어 오직 하나의 태스크만 접근 가능한 객체)에서 borrow되었을 것이다. 따라서 참조는 오직 하나의 태스크 안에서만 alias 될 수 있었다.
그렇다면 UAF는? &는 mutable memory에는 취할 수 없었으므로, &T를 받았다면 그 객체가 교체되지 않을 것임을 알 수 있었다. 따라서 투영 경로에 가변 필드나 가변 바인딩이 포함되지 않는다면, &T를 통해 struct 필드, enum variant, 배열 원소, ~/@ 등으로 projection 하는 것이 안전했다. variant는 바뀔 수 없고, 박스는 객체를 교체하지 않는 이상 다시 바인딩할 수 없기 때문이다.
만약 그 경로가 mutable memory 속의 @T를 지나간다면, borrow 기간 동안만 해당 @T를 로컬로 클론해서 참조 대상 객체의 refcount가 항상 양수로 유지되도록 했고, 그러면 그 prefix 부분의 가변성은 무시해도 됐다.
그래도 mutable memory가 여전히 경로에 남아 있다면, 컴파일러는 해당 borrow를 무효화할 수 있는 연산이 없다는 것을 보장해야 했다. 그러한 연산은 태스크 로컬에서만 일어날 수 있었으므로, borrow checker는 borrow가 이루어진 region 안에서 재할당을 찾기만 하면 되었다:
rustfn example3() -> int { struct R { g: int } struct S { mut f: ~R } let mut x = ~S {mut f: ~R {g: 3}}; let y = &x.f.g; x = ~S {mut f: ~R {g: 4}}; // 여기에서 에러 보고. x.f = ~R {g: 5}; // 여기에서 에러 보고. *y }
새로운 참조가 이전 예제처럼 필드와 ~만을 통해 얻어졌다면, 그 경로는 유일하다는 것이 보장되었고, borrow checker는 경로를 단순히 매칭하기만 하면 되었다. 예를 들어, 이런 방식으로 ~mut [T]에서 &T로 갈 수 있었다.
하지만 참조가 @나 &에서 시작되었다면, 그 경로는 유일하지 않을 수 있었다. 다른 참조를 통한 재할당 때문에 borrow가 dangling이 되는 것을 막기 위해, 해당 region 안에서의 mutation에는 @/&를 사용할 수 없었다. 허용되는 연산을 pure 라고 불렀고, 현재 프레임이 소유한 데이터에만 접근할 수 있었다. 함수를 pure로 어노테이션하면 이런 문맥에서 사용 가능해졌다. 인자는 호출자에 의해 검증되므로, 피호출자는 파라미터로부터 온 &T에 접근할 수 있었다:
ruststruct R { g: int } struct S { mut f: ~R } pure fn add_one(x: &int) -> int { *x + 1 } fn example5a(x: @S) -> int { let y = &x.f.g; add_one(y) // `pure`가 없다면 허용되지 않았을 것 }
보시다시피, 서로 다른 참조 타입들은 잘 조합되지 않았다. 만약 &~[T]에서 &T로 내려가려 하면, 그건 가능하지만 실수로 벡터를 비워 버리는 일을 막기 위해 pure 함수 내에서만 허용되었다. 해결책은 그냥 ~[T]나 &[T]를 사용하는 것이었다.
방금 우리가 겪은 이 모든 것과 비교해 보면, 지금의 러스트에 꽤 만족스럽다. 언어는 좋은 사람들의 손에 맡겨져 있다. 수년간 러스트를 다듬으며 지금처럼 사용자 친화적이고 단순한 언어로 만들어 준 모든 분들께 감사한다.