Rust


fn main() {
println!("hello world");

}

1 개요

모질라 재단에서 연구 목적으로 제작중인 프로그래밍 언어. CC++, Go와 같은 시스템 프로그래밍 언어에 속하며, 특히 Go와는 비슷한 시기에 등장했다는 점과 두 언어 모두 C++를 서로 다른 방향에서 대체하려 한다는 점 때문에 라이벌 관계로 엮이기도 한다. 멀티코어 프로세싱이 중요시되는 현 추세에 따라 병렬 처리, 동시성 프로그래밍에도 강점을 가지고 있다.

원래는 모질라 소속의 개발자인 그레이던 호어의 개인 프로젝트였으나, 모질라 재단의 차기 웹 브라우저 엔진 프로젝트인 서보(Servo)를 개발하는 데에 쓰기 위해 함께 연구 프로젝트로 편입되었다.[1] 자세한 내용은 서보 참고.

2015년 5월 15일에 1.0 정식 버전이 발표되었다.
2016년 10월 23일 1.12.1 버전이 최신 안정 버전이며 1.13의 베타 테스트와 1.14의 개발이 진행중이다.

2 특징

Rust는 현대적인 시스템 프로그래밍 언어로, C++와 동등한 수준의 속도를 달성하면서 메모리 오류를 완전히 없애는 것을 목표로 한다. 또한 함수형 프로그래밍 언어로부터 발전된 타입 시스템을 도입하였으며, 클래스 대신 트레이트(trait)를 기반으로 다형성을 달성한다. 매크로를 사용해 언어를 확장하는 것이 가능하며, 이 모든 것이 현대적인 모듈 시스템을 통해 쉽게 모듈화될 수 있다. 모듈들은 크레이트라고 하는 단위로 묶여서 실행 파일이나 라이브러리로 배포될 수 있으며, Cargo라는 패키지 관리 프로그램을 통해 빌드 및 패키지 배포를 자동화하고 필요한 라이브러리를 Cargo를 통해 자동으로 다운로드받을 수 있다.

2.1 안전한 메모리 관리

Rust는 "쓰레기 수집 없이 메모리 안전성을 제공하는 언어"다. 다시 말해, Java처럼 바이트코드 실행기를 돌리거나, 스크립트 언어들처럼 인터프리터 런타임을 돌리거나 않고 C/C++와 동등한 기계어 코드를 만들어내면서 메모리 오류는 컴파일 시간에 잡아낸다는 것이다.

기본적으로 쓰레기 수집기(garbage collector)가 없기 때문에 쓰레기 수집기가 동작하는 동안 프로그램 전체의 실행이 멈춘다던가 하는 현상은 Rust에선 일어나지 않으며, 필요한 것보다 더 넓은 메모리를 쓰레기 수집기가 미리 할당해 둔다던가 하는 일 없이 정확하게 필요한 양의 메모리만 사용하는 프로그램을 만들 수 있다.[2] 동시에, C++처럼 메모리 관리를 전적으로 프로그래머에게 맡겨서 메모리 누수, 이중 반환, 댕글링 포인터 등등 온갖 종류의 메모리 오류로 프로그램이 박살나도록 방치하지도 않는다.

이를 달성하기 위해, Rust는 크게 두 가지 규칙을 만들고 이 규칙들을 타입 시스템으로 만들어서 컴파일 시간에 강제한다.

  • 모든 값은 한 곳에서만 소유할 수 있으며, 다른 곳에서 그 값에 접근하려면 소유권을 넘겨받거나 빌려서 써야 한다.
  • 하나의 값을 여러 곳에서 동시에 수정해선 안 된다.

2.1.1 소유권

Rust에서 모든 값[3]은 그 값이 대입된 변수나 구조체 필드, 넘겨받은 함수 인자 등의 이름에 귀속된다. 이름은 자기에게 귀속된 값에 대한 소유권(ownership)을 가지며, 다른 이름으로 값을 대입하면 그 이름으로 소유권이 이전된다. 예를 들어, 다른 언어에서 `a = b`와 같은 대입 연산은 `b`의 값을 `a`로 복사하거나, `b`가 가리키던 객체를 `a`도 함께 가리키겠다는 의미를 갖는 데 반해, Rust에서는 `b`가 가지고 있던 값을 `a`로 이동시킨다는 의미가 된다. 다시 말해, `b`가 가지고 있던 값의 소유권이 `a`로 이전하며, `b`로는 `a`로 대입된 값에 접근할 수 없게 된다.[4] 이 규칙은 함수로 인자를 넘기거나 값을 리턴할 때도 동일하게 적용되는데, 이 때는 인자 전달이 함수 안으로 값을 이동하는 것이 되고, 함수 리턴값은 함수 밖으로 값을 이동하는 것이 된다.[5] 만약 함수의 리턴값을 받지 않는다던가 하는 일로 인해 소유권을 넘겨주지 못한 채로 이름이 사라지게 되면, 거기에 귀속되었던 값도 함께 사라지면서 그 값을 담았던 메모리도 해제되게 된다.

Rust 컴파일러는 컴파일 단계에서 소유권이 어떻게 이전되는지를 모두 추적할 수 있으며, 필요한 메모리 할당 및 해제 코드를 컴파일 중에 정확한 위치에 삽입한다. 이런 방법으로 Rust는 런타임 오버헤드가 없는 안전한 메모리 관리를 이루어낸다.

소유권 규칙에 따라 하나의 값은 언제나 하나의 이름으로만 접근할 수 있게 되는데, 실제로 이렇게만 프로그래밍을 하려면 제약이 너무 심하기 때문에 Rust에서는 다른 값을 가리킬 수 있도록 borrowed pointer(`&`)[6]를 제공하고 있다. borrowed pointer는 C나 C++에서의 포인터처럼 다른 값을 가리키고 참조할 수 있는데, 다른 변수의 값을 빌려 쓰는 것이기 때문에 원래 소유권이 변하지 않는 동안에만 살아 있을 수 있다. 쉽게 말해 "도서관에서 책을 빌렸으면 적어도 도서관이 망하기 전에는 책을 반납하시오" 같은 거다. 소유권 이전과 마찬가지로 이 규칙 또한 Rust 컴파일러에 의해 컴파일 단계에서 추적되고[7], 만약 이 규칙을 어기는 코드가 발견되면 당연히 컴파일 에러가 발생한다.

2.1.2 Mutability

동시성 프로그래밍에서 발생하는 대부분의 오류는 shared mutable state, 즉 변경 가능한 상태를 여러 곳에서 공유하는 데에서 온다. 이 문제에 대한 전통적인 방법은 락으로 임계 영역(critical section)을 만드는 것이지만, 어느 락이 어느 변수를 맡는지는 온전히 프로그래머의 머리에 맡겨야 했고, 실수로 규칙을 어겨도 딱히 알아차릴 방법이 없는 데다, 포인터를 만드는 게 밥먹는 것만큼 쉬운 C/C++와 같은 언어와 같은 객체에 대한 레퍼런스를 만드는 게 숨쉬는 것만큼 쉬운 Java나 Python 같은 언어에서 이런 실수는 굉장히 간단히 일어날 수 있다.

같은 문제에 대한 함수형 프로그래밍 패러다임의 접근법은 "변경(mutation)을 하지 말자"였다. 변경을 하지 않는다면 같은 메모리를 얼마든지 재사용해도 문제가 없고, 새로운 값이 필요하면 무조건 새 메모리를 할당하자는 방법이다. Erlang과 같은 언어가 그런 접근법으로 어느 정도 성공을 거뒀지만, 일단 기존 패러다임에 익숙한 프로그래머가 받아들이기 힘든 방법이었고, 상대적으로 낭비되는 메모리와 단일 쓰레드 성능 저하를 피하기 힘들었다. 최소한 시스템 프로그래밍 언어가 사용하기는 힘든 방법이다. 따라서 Rust는 다른 접근법을 사용한다. "공유(share)를 하지 말자".

다른 언어에서 변수가 기본적으로 변경 가능(mutable)하고, 변경 불가능(immutable)한 변수를 만들려면 `const`나 `final`과 같이 별도의 키워드를 써야 하는 것과 달리, Rust에서 선언한 변수는 기본적으로 변경 불가능하며, 변경 가능한 변수를 만들려면 `mut` 키워드를 별도로 붙여야 하도록 되어 있다.

마찬가지 논리로, `&`은 변경 불가능한 레퍼런스(immutable reference)이며, `&`으로 값을 빌리면 그 값을 읽기만 할 수 있고 수정할 수 없다. 수정하려고 하면? 컴파일 에러다. 변경 가능한 레퍼런스(mutable reference)를 만들려면 `mut`로 선언된 변수로부터 `&mut`을 얻어 와야 한다. 여기서 한 가지 재미있는 점이 있는데, `&mut`은 오로지 하나만 빌릴 수 있고, 다른 `&`과도 공존할 수 없다. 만약 한 변수에서 `&mut`를 두 개 빌리려고 하거나, `&`이 있는데 `&mut`를 빌리려고 하거나, `&mut`가 있는데 `&`을 빌리려고 하는 경우 모두 컴파일 단계에서 금지된다. 덧붙여, `&mut`이 존재하는 동안에는 원래의 변수에서도 쓰기가 막히며, `&mut`이 없는 동안에도 `&`이 있으면 마찬가지로 쓰기가 막힌다.

규칙이 조금 복잡해 보일지도 모르겠지만 이 규칙이 의도하는 바는 간단하다. Rust는 하나의 값이 여러 곳에서 동시에 수정되는 것을 막는다. 이 규칙 덕분에, 가령 락으로 보호되는 메모리 영역을 만들고 락을 걸 때 그 영역에 대한 `&mut`를 던지게 만들어도[8] 누가 그 포인터를 몰래 빼돌려서 어디 구조체 같은 데 저장해 뒀다가 써서 프로그램을 깨먹을 걱정을 하지 않아도 된다. 그런 코드를 쓰려고 하면 그냥 컴파일 에러가 날 테니까.

단일 쓰레드만 쓰는 상황에서도 이 규칙은 안전한 프로그램을 짜는 데에 충분히 도움이 된다. 일례로, Rust에서는 반복자 무효화(iterator invalidation)[9]가 일어나는 걸 언어적으로 막을 수 있는데, 반복자가 자기 컨테이너의 `&mut`를 들고 있기만 하면 컨테이너가 다른 곳에서 수정되는 걸 방지할 수 있기 때문이다.

2.2 제네릭(Generic)과 트레이트(Trait) 제공

C++, C\#, Java 등 대중적인 정적 타입 프로그래밍 언어들에서 흔히 제공하는 제네릭을 Rust 또한 가지고 있다. C#이나 Java처럼 타입 인자를 제공하는 정도의 기능을 갖고 있으며, 내부적으로는 C++의 템플릿처럼 타입별로 코드를 생성하는 방식으로 동작한다. 코드 생성에 필요한 정보를 라이브러리에 포함시키기 때문에 C++와는 달리 외부 라이브러리에 포함된 제네릭 타입이나 함수도 제약 없이 쓸 수 있다.

트레이트(trait)는 일종의 인터페이스로, 타입이 가져야 할 메서드의 목록을 제공하는 역할을 한다. 그리고 `impl` 블록을 작성해서 어떤 타입에 트레이트를 구현하게 되면, 그 타입은 트레이트가 선언한 메서드를 쓸 수 있게 된다. Java와 같은 일반적인 객체 지향 언어와는 달리, 타입 정의와 트레이트 구현은 완전히 독립되어 있다.[10] 예를 들어, `int`나 `&str` 같은 기본 타입에 자신이 정의한 메서드를 추가하는 것도 가능하고, 다른 라이브러리에 있는 트레이트를 내가 만든 타입에 구현하는 것도, 내가 만든 트레이트를 다른 라이브러리에서 제공하는 타입에 구현하는 것도 얼마든지 가능하다.

트레이트가 가지는 중요한 역할 중 하나는, 제네릭 인자에 트레이트를 써서 인자로 들어갈 타입에 필요한 조건을 붙이는 것이다.[11] 예를 들어 값 세 개를 오름차순으로 정렬하는 제네릭 함수는 이렇게 만들 수 있다.

2.3 매크로

수정바람
C/C++에서의 매크로는 단순한 문자열 치환이다.
예를들어 #define MUL5(x) (x*5) 라고 한다면 MUL5(3) 은 (3*5) 로 치환될 것이다.
Rust에서의 매크로는 단순한 문자열 치환이 아니라 문법의 일부로서 처리된다(즉, 전처리기가 처리하는 것이 아니고 컴파일러에 의하여 직접 처리된다).
C의 매크로에서는 MUL5(3) 에서 "3" 이 곱셈이 가능한지, 실수인지 이런 여부를 판단할 수 없다.
Rust의 매크로 시스템에서는

  • ident: an identifier. Examples: x; foo.
  • path: a qualified name. Example: T::SpecialA.
  • expr: an expression. Examples: 2 + 2; if true then { 1 } else { 2 }; f(42).
  • ty: a type. Examples: i32; Vec<(char, String)>; &T.
  • pat: a pattern. Examples: Some(t); (17, 'a'); _.
  • stmt: a single statement. Example: let x = 3.
  • block: a brace-delimited sequence of statements. Example: { log(error, "hi"); return 12; }.
  • item: an item. Examples: fn foo() { }; struct Bar;.
  • meta: a "meta item", as found in attributes. Example: cfg(target_os = "windows").
  • tt: a single token tree.

등의 다양한 제약 조건을 argument에 부여할 수 있다.

현재 이 시스템은 완벽하지 않다.
예를들어 C에서 익숙하게 쓰던 A ## B 등의 identifier concatenation 은 현재의 Rust 매크로 시스템에선 불가능하다 (플러그인을 따로 깔지 않으면).
하지만 이러한 기능이 점차 발전하면 다양한 형태의 매크로를 작성할 수 있게 될 것이다.

2.4 컴파일러 플러그인

수정바람
위에서 들었던 예시처럼 아직 부족한 요소가 많기 때문에 Rust에서는 "직접 만들어" 쓸 수 있는 길을 열어두었다.
컴파일러 플러그인을 작성하면 컴파일러에 의해서 동적으로 로드되어 컴파일 과정에 직접 간섭할 수 있다.
컴파일러 플러그인은 현재 nightly 버전에서만 지원된다.

컴파일러 플러그인은 크게 syntax extension, early lint pass, late lint pass 등으로 구성되어 있다.
세 가지 종류의 플러그인은 앞에서 부터 순서대로 호출되게 되며 같은 종류의 플러그인간의 순서는 보장되지 않는다.

  • Syntax Extension: 가장 먼저 불리게 되는 과정. 이 단계에서는 AST를 직접 변경할 수 있다. 다만 macro expansion 은 아직 수행되지 않은 상태이다. 즉, 매크로 안에 함수의 정의가 있으면 그 정의를 찾을 수 없다는 것 (수동으로 강제로 매크로 확장을 해야 한다).
  • Early Lint Pass: AST가 finalize 된 직후 이다. 이후 hir 이라는 중간 형태로 변하기 전의 AST를 최종적으로 검사할 수 있다.
  • Late Lint Pass: 이 단계에서의 가장 큰 변화는 바로 "Type 추론이 모두 끝나서 각 identifier 의 type을 알 수 있다" 는 점이다. 풍부한 type 정보를 이용하여 복잡한 컴파일러 확장 기능을 수행할 수 있다.

3 관련 링크

  1. 그레이던 호어는 이 뒤로도 한동안 수석 개발자로 개발에 참여하고 있다가, 현재는 수석 개발자 지위를 내려놓은 상태이다.
  2. 다만 이는 정확히 말하자면 틀린 부분이다. 커스텀 메모리 얼로케이터인 jemalloc을 사용하기 때문에 OS 입장에서는 항상 사용하는 만큼만 메모리를 할당받는 것은 아니다.
  3. 메모리 영역이라고 이해해도 무리는 없다.
  4. 단, `int`나 `char` 등 일부 기본 타입들은 예외인데, 어차피 이런 타입들은 복사가 그리 비싸지도 않고, 얘네까지 이동으로 처리하면 코딩하기 너무 불편해지기 때문이다
  5. 값이 이동할 때 새로 메모리를 할당하고 메모리를 복사할 지, 그냥 주어진 메모리 영역을 그대로 쓸지 결정하는 것은 Rust 컴파일러의 몫으로, 최적화하기에 따라서는 함수로 인자를 넘기고 리턴받는 동안 단 한 번의 메모리 복사도 일어나지 않을 수도 있다. C++ 중급자라면 아마 RVO라는 약어가 익숙할 것이다.
  6. borrowed reference라고도 부른다
  7. Rust 컴파일러의 borrowck라는 부분이 이 역할을 맡는다
  8. Rust 표준 라이브러리의 Mutex가 이렇게 만들어져 있다.
  9. C++를 예로 들면, `std::vector<T>`의 반복자를 만들어서 그걸로 루프를 도는 도중에 벡터의 크기를 바꾼다거나 하는 이유로 반복자의 상태가 깨지는 상황
  10. 함수형 언어에 익숙하다면 Haskell의 type class와 비슷하다고 보면 된다.
  11. C++ 차기 표준으로 도입하기 위해 작업중인 그러나 C++0x 시절부터 시작해서 언제 들어갈지 알 수가 없는 Concepts 기능과 유사한 역할을 한다.