소개

이 안내서는 Rust를 처음 접하는 C# 및 .NET 개발자를 대상으로 합니다. 모든 주제를 망라하지는 않습니다. C#/.NET과 Rust는 일부 개념과 구문이 비슷하지만 표현 방법은 다를 수 있습니다. 메모리 관리처럼 접근 방식 자체가 크게 다른 주제도 있습니다. 이 안내서는 두 언어의 주요 개념을 짧은 예제와 함께 비교하고 대응 관계를 설명합니다.

원저자1들도 Rust를 처음 접한 C#/.NET 개발자였습니다. 이 안내서는 원저자들이 몇 달간 Rust 코드를 작성하며 익힌 내용을 모았습니다. 원저자들은 Rust를 배우기 시작할 때 이와 같은 자료가 있었다면 도움이 되었을 것이라고 말합니다. 다만 C#과 .NET의 관점에만 기대어 Rust를 익히기보다는 Rust의 책과 웹 자료를 함께 읽으며 Rust 특유의 표현 방식도 접할 수 있습니다. 이 안내서는 상속, 스레드, 비동기 프로그래밍 등을 Rust에서 지원하는지 빠르게 알아보는 데 도움을 줍니다.

독자에 관한 전제

  • C# 및 .NET 개발 경험이 풍부한 독자
  • Rust를 처음 접하는 독자

이 안내서의 목표

  • C#/.NET의 여러 주제와 Rust의 대응 개념을 간결하게 비교
  • 추가 학습에 활용할 Rust 참조 문서, 책, 기술 문서 링크 제공

다루지 않는 내용

  • 디자인 패턴과 아키텍처에 관한 설명
  • Rust 언어를 처음부터 가르치는 튜토리얼
  • 이 안내서만으로 Rust에 능숙해지는 학습 과정
  • C#과 Rust의 코드 작성법을 폭넓게 모은 요리책 형식의 자료

1

원저자는 알파벳순으로 Atif Aziz, Bastian Burger, Daniele Antonio Maggio, Dariusz Parys, Patrick Schuler입니다.

라이선스

Copyright © Microsoft Corporation.
Portions Copyright © 2010 The Rust Project Developers

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE

기여하기

이 안내서에는 이슈와 풀 리퀘스트로 기여할 수 있습니다.

다음 작업에 특히 도움을 받을 수 있습니다.

  • 맞춤법과 문법 오류 수정
  • 기술적으로 부정확한 설명 수정
  • 코드 예제의 논리 오류와 컴파일 오류 수정
  • 영어 문장 개선
  • 주제나 개념을 더 쉽게 이해할 수 있는 설명 보강
  • C#, .NET, Rust의 변경 사항 반영. 두 언어의 기능이 가까워지면 설명과 예제도 수정할 수 있습니다.

맞춤법이나 예제의 구문 오류처럼 범위가 작은 수정은 바로 풀 리퀘스트를 제출할 수 있습니다. 작성자와 검토자 모두에게 큰 노력이 필요한 변경이라면 작업을 시작하기 전에 이슈에서 관리자나 편집자의 동의를 구하는 방식을 안내합니다. 그러면 작업을 마친 뒤 풀 리퀘스트가 거절될 위험을 줄일 수 있습니다.

웹에서 안내서를 읽다가 오류를 발견했다면 페이지 모서리의 편집 아이콘을 통해 해당 Markdown 원본을 수정하고 변경 사항을 제출할 수 있습니다.

기여 지침

  • 소개에서 밝힌 안내서의 목표를 따르고 다루지 않는 범위를 벗어나지 않습니다.
  • 설명은 짧게 쓰고 간결하면서 현실적인 코드 예제를 사용합니다.
  • 가능한 한 Rust와 C#의 예제를 모두 제시하고 비교합니다.
  • 예제가 간결해지거나 두 언어의 공통점을 잘 드러낸다면 최신 C# 및 Rust 기능을 사용할 수 있습니다.
  • C# 예제에서는 가능한 한 커뮤니티 패키지보다 .NET 기본 클래스 라이브러리를 사용합니다. Rust 표준 라이브러리의 API 범위는 비교적 작으므로 기능을 설명하는 데 crate가 필요할 수 있습니다. 이때는 rand처럼 널리 쓰이고 신뢰할 수 있는 패키지를 선택합니다.
  • 예제 코드는 가능한 한 독립적으로 실행할 수 있게 만듭니다. 컴파일 오류나 실행 오류를 보여 주려는 예제는 예외입니다.
  • 독자에게 직접 지시하는 2인칭 표현을 피하고 3인칭으로 설명하는 문체를 유지합니다. 예를 들어 "Rust에서 선택적 데이터를 나타내려면 Option<T>를 사용하세요"보다 "Rust의 Option<T> 타입은 선택적 데이터를 나타냅니다"처럼 씁니다.

시작하기

Rust Playground

로컬에 아무것도 설치하지 않고 Rust를 실행하려면 Rust Playground를 사용할 수 있습니다. 웹 브라우저에서 Rust 코드를 작성하고 실행하는 개발 환경입니다.

개발 컨테이너

Rust Playground에서는 컴파일과 실행에 걸리는 시간, 메모리, 네트워크 사용에 제약이 있습니다. Rust를 로컬에 설치하지 않고 사용하려면 https://github.com/microsoft/vscode-remote-try-rust 리포지터리에서 제공하는 개발 컨테이너도 사용할 수 있습니다. 개발 컨테이너는 GitHub Codespaces를 통해 웹 브라우저에서 실행하거나 Visual Studio Code에서 로컬로 실행할 수 있습니다.

로컬 설치

Rust 컴파일러와 개발 도구를 로컬에 모두 설치하는 방법은 The Rust Programming Language의 시작하기 장 중 설치 절이나 Rust 웹사이트의 설치 안내에서 확인할 수 있습니다.

언어

이 장에서는 C#과 Rust의 언어 기능을 비교합니다.

스칼라 타입

다음 표는 Rust의 기본 타입과 C# 및 .NET의 대응 타입을 보여 줍니다.

RustC#.NET비고
boolboolBoolean
charcharChar주 1 참조
i8sbyteSByte
i16shortInt16
i32intInt32
i64longInt64
i128Int128
isizenintIntPtr
u8byteByte
u16ushortUInt16
u32uintUInt32
u64ulongUInt64
u128UInt128
usizenuintUIntPtr
f32floatSingle
f64doubleDouble
decimalDecimal
()voidVoid 또는 ValueTuple주 2, 3 참조
objectObject주 3 참조

주석:

  1. Rust의 char와 .NET의 Char는 정의가 다릅니다. Rust의 char는 4바이트 너비의 유니코드 스칼라 값입니다. .NET의 Char는 2바이트 너비이며 문자를 UTF-16으로 저장합니다. 자세한 내용은 Rust char 문서에서 확인할 수 있습니다.

  2. Rust의 단위 타입 ()는 값으로 표현할 수 있는 빈 튜플입니다. C#에서 가장 가까운 개념은 값이 없음을 나타내는 void입니다. 다만 포인터와 안전하지 않은 코드를 사용하는 경우를 제외하면 void를 값으로 표현할 수 없습니다. .NET의 ValueTuple도 빈 튜플이지만 C#에는 이를 나타내는 () 같은 리터럴 구문이 없습니다. C#에서 ValueTuple을 사용할 수는 있으나 흔하지 않습니다. F#에는 Rust와 비슷한 단위 타입이 있습니다.

  3. void와 object는 스칼라 타입이 아닙니다. .NET 타입 계층에서 int와 같은 스칼라 타입이 object의 하위 타입이라는 점과는 별개입니다. 비교의 편의를 위해 표에 함께 넣었습니다.

관련 자료:

문자열

Rust에는 String과 &str이라는 두 가지 주요 문자열 타입이 있습니다. String은 힙에 할당되며, &str은 문자열의 슬라이스입니다.

다음 표는 .NET의 대응 타입을 보여 줍니다.

Rust.NET비고
&mut strSpan<char>
&strReadOnlySpan<char>
Box<str>String주 1 참조
StringString
가변 StringStringBuilder주 1 참조

Rust와 .NET에서 문자열을 다루는 방식에는 차이가 있지만 위 대응 관계는 출발점으로 활용할 수 있습니다. Rust 문자열은 UTF-8로 인코딩하고 .NET 문자열은 UTF-16으로 인코딩합니다. .NET 문자열은 불변입니다. Rust의 String은 let mut s = String::from("hello");처럼 선언하면 변경할 수 있습니다.

소유권 개념도 문자열 사용 방식에 영향을 줍니다. String의 소유권에 관해서는 Rust Book에서 확인할 수 있습니다.

주석:

  1. Rust의 Box<str>은 .NET의 String에 대응합니다. Rust에서 Box<str>은 포인터와 크기를 저장하지만 String은 포인터, 크기, 용량을 저장합니다. 따라서 String의 크기를 늘릴 수 있습니다. Rust의 String을 가변으로 선언했을 때는 .NET의 StringBuilder와 비슷한 면이 있습니다.

먼저 C# 예제입니다.

ReadOnlySpan<char> span = "Hello, World!";
string str = "Hello, World!";
StringBuilder sb = new StringBuilder("Hello, World!");

Rust에서는 다음과 같이 작성합니다.

let span: &str = "Hello, World!";
let str: Box<str> = Box::from("Hello World!");
let mut sb = String::from("Hello World!");

문자열 리터럴

.NET의 문자열 리터럴은 불변 String이며 힙에 할당됩니다. Rust의 문자열 리터럴은 &'static str 타입입니다. 불변이고 전역 수명을 가지며, 힙에 할당되지 않고 컴파일된 바이너리에 포함됩니다.

먼저 C# 예제입니다.

string str = "Hello, World!";

Rust 예제는 다음과 같습니다.

let str: &'static str = "Hello, World!";

C#의 축어 문자열 리터럴은 Rust의 원시 문자열 리터럴에 대응합니다. 먼저 C# 코드입니다.

string str = @"Hello, \World/!";

Rust 코드는 다음과 같습니다.

let str = r#"Hello, \World/!"#;

C#의 UTF-8 문자열 리터럴은 Rust의 바이트 문자열 리터럴에 대응합니다. 먼저 C# 코드입니다.

ReadOnlySpan<byte> str = "hello"u8;

Rust 코드는 다음과 같습니다.

let str = b"hello";

문자열 보간

C#에는 문자열 리터럴 안에 식을 넣을 수 있는 문자열 보간 기능이 있습니다. 다음 예제에서 사용법을 확인할 수 있습니다.

string name = "John";
int age = 42;
string str = $"Person {{ Name: {name}, Age: {age} }}";

Rust에는 언어 차원의 문자열 보간 기능이 없습니다. 대신 format! 매크로로 문자열을 포맷합니다. 다음 예제를 살펴보겠습니다.

let name = "John";
let age = 42;
let str = format!("Person {{ name: {name}, age: {age} }}");

format!은 문자열 안에 변수 이름을 바로 넣는 방식만 지원합니다. 더 복잡한 식은 format!("1 + 1 = {}", 1 + 1)처럼 별도 인수로 전달합니다.

C#의 사용자 정의 클래스와 구조체는 모두 object에서 ToString()을 상속하므로 문자열 보간에 사용할 수 있습니다.

class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    public override string ToString() =>
        $"Person {{ Name: {Name}, Age: {Age} }}";
}

var person = new Person { Name = "John", Age = 42 };
Console.Writeline(person);

Rust에서는 모든 타입이 기본 포맷 구현을 물려받지 않습니다. 문자열로 표현할 타입마다 std::fmt::Display 트레이트를 구현해야 합니다.

use std::fmt::*;

struct Person {
    name: String,
    age: i32,
}

impl Display for Person {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
        write!(f, "Person {{ name: {}, age: {} }}", self.name, self.age)
    }
}

let person = Person {
    name: "John".to_owned(),
    age: 42,
};

println!("{person}");

포맷 문자열 없이 Display로 값을 문자열로 바꿀 때는 std::string::ToString 트레이트를 사용할 수 있습니다. 이 트레이트의 to_string() 메서드는 .NET의 ToString()과 비슷하며 Display를 구현하면 자동으로 제공됩니다. 다음 두 표현은 같은 역할을 합니다.

// Display를 구현했으므로 to_string()을 자동으로 사용할 수 있습니다.
let s = person.to_string();
// s == "Person { name: John, age: 42 }"

std::fmt::Debug 트레이트를 사용하는 방법도 있습니다. 표준 타입은 모두 Debug를 구현하며 타입의 내부 표현을 출력할 때 사용할 수 있습니다. 다음 예제는 derive 특성으로 Person 구조체에 Debug를 자동 구현하고 내부 표현을 출력합니다.

#[derive(Debug)]
struct Person {
    name: String,
    age: i32,
}

let person = Person {
    name: "John".to_owned(),
    age: 42,
};

println!("{person:?}");

:? 포맷 지정자는 Debug 트레이트로 구조체를 출력합니다. 이를 생략하면 Display 트레이트를 사용합니다.

:#? 지정자를 사용하면 디버그 출력을 보기 좋게 정렬할 수 있습니다.

관련 자료:

구조화된 타입

다음 표는 .NET에서 자주 쓰는 객체 및 컬렉션 타입과 Rust의 대응 타입을 보여 줍니다.

C#Rust
ArrayArray
ListVec
TupleTuple
DictionaryHashMap

배열

Rust도 .NET처럼 길이가 고정된 배열을 지원합니다. 먼저 C# 예제입니다.

int[] someArray = new int[2] { 1, 2 };

Rust에서는 다음과 같이 작성합니다.

let someArray: [i32; 2] = [1,2];

목록

Rust의 Vec<T>는 C#의 List<T>에 대응합니다. 배열을 벡터로 변환하거나 벡터를 배열로 변환할 수 있습니다. 먼저 C# 예제입니다.

var something = new List<string>
{
    "a",
    "b"
};

something.Add("c");

Rust 예제는 다음과 같습니다.

let mut something = vec![
    "a".to_owned(),
    "b".to_owned()
];

something.push("c".to_owned());

튜플

먼저 C#의 튜플 예제입니다.

var something = (1, 2)
Console.WriteLine($"a = {something.Item1} b = {something.Item2}");

Rust의 튜플은 다음과 같습니다.

let something = (1, 2);
println!("a = {} b = {}", something.0, something.1);

// 분해를 지원합니다.
let (a, b) = something;
println!("a = {} b = {}", a, b);

Rust는 C#과 달리 튜플 요소에 이름을 붙일 수 없습니다. 인덱스로 접근하거나 튜플을 분해해서 요소를 사용할 수 있습니다.

딕셔너리

Rust의 HashMap<K, V>는 C#의 Dictionary<TKey, TValue>에 대응합니다. 먼저 C# 예제입니다.

var something = new Dictionary<string, string>
{
    { "Foo", "Bar" },
    { "Baz", "Qux" }
};

something.Add("hi", "there");

Rust 예제는 다음과 같습니다.

let mut something = HashMap::from([
    ("Foo".to_owned(), "Bar".to_owned()),
    ("Baz".to_owned(), "Qux".to_owned())
]);

something.insert("hi".to_owned(), "there".to_owned());

관련 자료:

사용자 정의 타입

다음 절에서는 사용자 정의 타입을 만드는 데 필요한 개념과 구문을 다룹니다.

클래스

Rust에는 클래스가 없습니다. 구조체인 struct를 사용합니다.

레코드

Rust에는 C#의 record struct나 record class에 해당하는 레코드 선언 구문이 없습니다.

구조체(struct)

Rust와 C#의 구조체에는 몇 가지 공통점이 있습니다.

  • 두 언어 모두 struct 키워드로 정의합니다. Rust의 struct는 데이터와 필드만 정의하며 함수와 메서드로 표현하는 동작은 별도의 구현 블록 (impl)에 작성합니다.
  • C# 구조체가 여러 인터페이스를 구현할 수 있듯 Rust 구조체도 여러 트레이트를 구현할 수 있습니다.
  • 구조체를 상속해 하위 클래스를 만들 수 없습니다.
  • 기본적으로 스택에 할당되지만 다음 경우에는 달라집니다.
    • .NET에서 박싱하거나 인터페이스로 변환한 경우
    • Rust에서 Box, Rc, Arc 같은 스마트 포인터로 감싼 경우

C#의 struct는 .NET의 _값 타입_을 모델링합니다. 도메인에 특화된 기본 값이나 값 동등성 의미를 가진 복합 값을 표현할 때 자주 사용합니다. Rust의 struct는 데이터 구조를 모델링하는 주요 구문이며 다른 하나는 enum입니다.

C#의 struct와 record struct에는 기본적으로 값 복사와 값 동등성 의미가 있습니다. Rust에서 같은 기능을 얻으려면 #[derive] 특성으로 구현할 트레이트를 지정합니다.

#[derive(Clone, Copy, PartialEq, Eq, Hash)]
struct Point {
    x: i32,
    y: i32,
}

C#/.NET에서는 대개 값 타입을 불변으로 설계합니다. 의미상 좋은 설계로 여겨지지만 언어 자체가 struct의 제자리 수정을 막지는 않습니다. Rust에서도 타입을 불변으로 설계하려면 개발자가 의도적으로 구현해야 합니다.

Rust에는 클래스와 하위 클래스에 기반한 타입 계층이 없습니다. 여러 타입이 동작을 공유할 때는 트레이트와 제네릭을 사용하며, 트레이트 객체의 가상 디스패치로 다형성을 구현합니다.

다음 C# struct는 직사각형을 나타냅니다.

struct Rectangle
{
    public Rectangle(int x1, int y1, int x2, int y2) =>
        (X1, Y1, X2, Y2) = (x1, y1, x2, y2);

    public int X1 { get; }
    public int Y1 { get; }
    public int X2 { get; }
    public int Y2 { get; }

    public int Length => Y2 - Y1;
    public int Width => X2 - X1;

    public (int, int) TopLeft => (X1, Y1);
    public (int, int) BottomRight => (X2, Y2);

    public int Area => Length * Width;
    public bool IsSquare => Width == Length;

    public override string ToString() => $"({X1}, {Y1}), ({X2}, {Y2})";
}

Rust에서 대응하는 코드는 다음과 같습니다.

#![allow(dead_code)]

struct Rectangle {
    x1: i32, y1: i32,
    x2: i32, y2: i32,
}

impl Rectangle {
    pub fn new(x1: i32, y1: i32, x2: i32, y2: i32) -> Self {
        Self { x1, y1, x2, y2 }
    }

    pub fn x1(&self) -> i32 { self.x1 }
    pub fn y1(&self) -> i32 { self.y1 }
    pub fn x2(&self) -> i32 { self.x2 }
    pub fn y2(&self) -> i32 { self.y2 }

    pub fn length(&self) -> i32 {
        self.y2 - self.y1
    }

    pub fn width(&self)  -> i32 {
        self.x2 - self.x1
    }

    pub fn top_left(&self) -> (i32, i32) {
        (self.x1, self.y1)
    }

    pub fn bottom_right(&self) -> (i32, i32) {
        (self.x2, self.y2)
    }

    pub fn area(&self)  -> i32 {
        self.length() * self.width()
    }

    pub fn is_square(&self)  -> bool {
        self.width() == self.length()
    }
}

use std::fmt::*;

impl Display for Rectangle {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
        write!(f, "({}, {}), ({}, {})", self.x1, self.y1, self.x2, self.y2)
    }
}

C#의 struct는 object에서 ToString 메서드를 상속하므로 사용자 정의 문자열 표현이 필요하면 기본 구현을 _재정의_합니다. Rust에는 상속이 없습니다. 타입이 Display 트레이트를 구현하면 포맷된 표현을 지원한다는 사실을 나타낼 수 있습니다. 그러면 다음 println! 호출처럼 구조체 인스턴스를 포맷할 수 있습니다.

fn main() {
    let rect = Rectangle::new(12, 34, 56, 78);
    println!("Rectangle = {rect}");
}

인터페이스

Rust에는 C#/.NET과 같은 인터페이스가 없습니다. 대신 _트레이트_를 사용합니다. 트레이트도 인터페이스처럼 추상화를 표현하며, 멤버를 통해 구현 타입이 충족할 계약을 정의합니다.

C#/.NET 인터페이스에 기본 메서드 구현을 넣을 수 있듯 Rust 트레이트에도 기본 구현을 제공할 수 있습니다. 구현 타입은 필요에 따라 더 적합하거나 최적화된 구현으로 이를 대체할 수 있습니다.

C#/.NET 인터페이스에는 속성, 인덱서, 이벤트, 정적 메서드와 인스턴스 메서드 등 여러 종류의 멤버를 넣을 수 있습니다. Rust 트레이트에는 인스턴스 메서드와 연관 함수, 상수를 정의할 수 있습니다. 연관 함수는 C#/.NET의 정적 메서드와 비슷합니다.

인터페이스는 클래스 계층 외에도 여러 타입에 걸친 추상화를 동적 디스패치로 다루는 주요 수단입니다. 구체적인 구현 타입을 몰라도 인터페이스를 기준으로 범용 코드를 작성할 수 있습니다. Rust의 _트레이트 객체_도 제한된 범위에서 같은 역할을 합니다. 트레이트 객체는 사실상 가상 메서드 테이블인 _v-table_을 사용하며, dyn Shape처럼 dyn 뒤에 트레이트 이름을 적습니다. 여기서 Shape는 트레이트 이름입니다. 트레이트 객체는 항상 &dyn Shape 같은 참조나 Box<dyn Shape> 같은 힙 할당 포인터 뒤에 놓입니다. .NET에서도 인터페이스는 참조 타입으로 다뤄지며, 값 타입을 인터페이스로 변환하면 관리 힙에 박싱됩니다.

트레이트 객체에는 원래 구현 타입을 다시 알아내기 어렵다는 제약이 있습니다. C#에서는 인터페이스 값을 다른 인터페이스나 하위 타입 또는 구체 타입으로 다운캐스트하거나 타입을 검사하는 일이 흔합니다. Rust에서는 별도의 지원이나 추가 작업 없이 같은 방식으로 처리할 수 없습니다.

열거형(enum)

C#의 enum은 기호 이름을 정수 값에 대응시키는 값 타입입니다.

enum DayOfWeek
{
    Sunday = 0,
    Monday = 1,
    Tuesday = 2,
    Wednesday = 3,
    Thursday = 4,
    Friday = 5,
    Saturday = 6,
}

Rust에서도 거의 같은 구문으로 열거형을 정의합니다.

enum DayOfWeek
{
    Sunday = 0,
    Monday = 1,
    Tuesday = 2,
    Wednesday = 3,
    Thursday = 4,
    Friday = 5,
    Saturday = 6,
}

.NET과 달리 Rust의 enum 인스턴스에는 상속받는 기본 동작이 없습니다. 처음에는 dow == DayOfWeek::Friday 같은 동등성 비교도 할 수 없습니다. C#의 enum에서 흔히 쓰는 기능을 갖추려면 #[derive] 특성으로 필요한 구현을 자동 생성합니다.

#[derive(Debug,     // "{:?}" 형식 출력을 지원합니다.
         Clone,     // Copy에 필요합니다.
         Copy,      // 값 복사 의미론을 지원합니다.
         Hash,      // 맵 타입에서 사용할 해시 계산을 지원합니다.
         PartialEq  // 값 동등성 비교(==)를 지원합니다.
)]
enum DayOfWeek
{
    Sunday = 0,
    Monday = 1,
    Tuesday = 2,
    Wednesday = 3,
    Thursday = 4,
    Friday = 5,
    Saturday = 6,
}

fn main() {
    let dow = DayOfWeek::Wednesday;
    println!("Day of week = {dow:?}");

    if dow == DayOfWeek::Friday {
        println!("Yay! It's the weekend!");
    }

    // 정수로 변환합니다.
    let dow = dow as i32;
    println!("Day of week = {dow:?}");

    let dow = dow as DayOfWeek;
    println!("Day of week = {dow:?}");
}

위 예제처럼 열거형을 지정된 정수 값으로 변환할 수 있습니다. 반대 방향의 변환은 C#처럼 바로 할 수 없습니다. C#/.NET에서는 표현하지 않은 정수 값이 enum 인스턴스에 들어갈 수 있다는 단점도 있습니다. Rust에서는 변환을 위한 보조 함수를 직접 만들 수 있습니다.

impl DayOfWeek {
    fn try_from_i32(n: i32) -> Result<DayOfWeek, i32> {
        use DayOfWeek::*;
        match n {
            0 => Ok(Sunday),
            1 => Ok(Monday),
            2 => Ok(Tuesday),
            3 => Ok(Wednesday),
            4 => Ok(Thursday),
            5 => Ok(Friday),
            6 => Ok(Saturday),
            _ => Err(n)
        }
    }
}

try_from_i32 함수는 n이 올바르면 성공을 나타내는 Ok와 DayOfWeek를 Result로 반환합니다. 그렇지 않으면 실패를 나타내는 Err에 원래 n을 담아 반환합니다.

let dow = DayOfWeek::try_from_i32(5);
println!("{dow:?}"); // 출력: Ok(Friday)

let dow = DayOfWeek::try_from_i32(50);
println!("{dow:?}"); // 출력: Err(50)

정수 타입과 열거형 사이의 변환을 직접 구현하지 않아도 되도록 돕는 Rust crate도 있습니다.

Rust의 enum으로는 각 변형이 서로 다른 데이터를 담는 구분된 유니언 타입도 설계할 수 있습니다. 다음 예제를 살펴보겠습니다.

enum IpAddr {
    V4(u8, u8, u8, u8),
    V6(String),
}

let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));

C#에는 같은 형태의 enum 선언이 없지만 클래스 레코드로 비슷하게 표현할 수 있습니다.

var home = new IpAddr.V4(127, 0, 0, 1);
var loopback = new IpAddr.V6("::1");

abstract record IpAddr
{
    public sealed record V4(byte A, byte B, byte C, byte D): IpAddr;
    public sealed record V6(string Address): IpAddr;
}

Rust의 정의는 변형의 집합이 고정된 _닫힌 타입_을 만듭니다. 컴파일러는 IpAddr에 IpAddr::V4와 IpAddr::V6 이외의 변형이 없다는 사실을 압니다. 따라서 C#의 switch 식과 비슷한 Rust의 match 식에서 모든 변형을 다루지 않으면 컴파일 오류를 냅니다. 반면 C# 레코드로 흉내 낸 형태는 간결해 보이더라도 클래스 계층을 만듭니다. IpAddr가 _추상 기본 클래스_이므로 컴파일러는 이 타입이 표현할 수 있는 모든 하위 타입을 알지 못합니다.

멤버

생성자

Rust에는 생성자라는 구문이 없습니다. 대신 타입 인스턴스를 반환하는 팩터리 함수를 작성합니다. 팩터리 함수는 독립 함수일 수도 있고 타입의 _연관 함수_일 수도 있습니다. C#으로 비유하면 연관 함수는 타입의 정적 메서드와 비슷합니다. 구조체에 팩터리 함수가 하나만 있다면 관례상 new라고 이름을 붙입니다.

struct Rectangle {
    x1: i32, y1: i32,
    x2: i32, y2: i32,
}

impl Rectangle {
    pub fn new(x1: i32, y1: i32, x2: i32, y2: i32) -> Self {
        Self { x1, y1, x2, y2 }
    }
}

Rust 함수는 연관 함수 여부와 관계없이 오버로딩을 지원하지 않습니다. 따라서 여러 팩터리 함수에는 서로 다른 이름을 붙입니다. String에서 제공하는 생성 함수의 예는 다음과 같습니다.

  • String::new: 빈 문자열 생성
  • String::with_capacity: 초기 버퍼 용량을 지정한 문자열 생성
  • String::from_utf8: UTF-8 텍스트 바이트에서 문자열 생성
  • String::from_utf16: UTF-16 텍스트 바이트에서 문자열 생성

Rust의 enum에서는 변형이 생성자 역할을 합니다. 자세한 내용은 열거형 절에서 확인할 수 있습니다.

관련 자료:

정적 메서드와 인스턴스 메서드

C#처럼 Rust의 enum과 struct에도 정적 메서드와 인스턴스 메서드에 대응하는 함수를 둘 수 있습니다. Rust에서 _메서드_는 항상 인스턴스에 속하며 첫 매개변수의 이름이 self입니다. self는 메서드가 속한 타입을 뜻하므로 별도의 타입 표기를 하지 않습니다. 정적 메서드에 해당하는 함수는 _연관 함수_라고 부릅니다. 다음 예제에서 new는 연관 함수이고 length, width, area는 메서드입니다.

struct Rectangle {
    x1: i32, y1: i32,
    x2: i32, y2: i32,
}

impl Rectangle {
    pub fn new(x1: i32, y1: i32, x2: i32, y2: i32) -> Self {
        Self { x1, y1, x2, y2 }
    }

    pub fn length(&self) -> i32 {
        self.y2 - self.y1
    }

    pub fn width(&self)  -> i32 {
        self.x2 - self.x1
    }

    pub fn area(&self)  -> i32 {
        self.length() * self.width()
    }
}

상수

C#과 마찬가지로 Rust의 타입에도 상수를 둘 수 있습니다. Rust에서는 타입 인스턴스 자체를 상수로 정의할 수도 있습니다.

struct Point {
    x: i32,
    y: i32,
}

impl Point {
    const ZERO: Point = Point { x: 0, y: 0 };
}

C#에서 같은 역할을 하려면 정적 읽기 전용 필드가 필요합니다.

readonly record struct Point(int X, int Y)
{
    public static readonly Point Zero = new(0, 0);
}

이벤트

C#의 event 키워드처럼 타입 멤버가 이벤트를 알리고 발생시키는 기능은 Rust 언어에 내장되어 있지 않습니다.

속성

C#에서는 대체로 타입의 필드를 비공개로 두고 get, set 접근자를 가진 속성으로 읽기와 쓰기를 캡슐화합니다. 접근자에는 값을 설정할 때 검증하거나 읽을 때 계산하는 로직을 넣을 수 있습니다. Rust에는 메서드가 있으며 getter는 필드와 같은 이름을 쓰고 setter에는 set_ 접두사를 붙이는 관례가 있습니다. Rust에서는 메서드와 필드가 같은 이름을 가질 수 있습니다.

다음 예제는 Rust 타입에서 속성과 비슷한 접근자 메서드를 작성하는 방식을 보여 줍니다.

struct Rectangle {
    x1: i32, y1: i32,
    x2: i32, y2: i32,
}

impl Rectangle {
    pub fn new(x1: i32, y1: i32, x2: i32, y2: i32) -> Self {
        Self { x1, y1, x2, y2 }
    }

    // 속성 getter와 비슷하며 각각 필드와 이름이 같습니다.

    pub fn x1(&self) -> i32 { self.x1 }
    pub fn y1(&self) -> i32 { self.y1 }
    pub fn x2(&self) -> i32 { self.x2 }
    pub fn y2(&self) -> i32 { self.y2 }

    // 속성 setter와 비슷합니다.

    pub fn set_x1(&mut self, val: i32) { self.x1 = val }
    pub fn set_y1(&mut self, val: i32) { self.y1 = val }
    pub fn set_x2(&mut self, val: i32) { self.x2 = val }
    pub fn set_y2(&mut self, val: i32) { self.y2 = val }

    // 계산 속성과 비슷합니다.

    pub fn length(&self) -> i32 {
        self.y2 - self.y1
    }

    pub fn width(&self)  -> i32 {
        self.x2 - self.x1
    }

    pub fn area(&self)  -> i32 {
        self.length() * self.width()
    }
}

C#에서는 필드마다 속성을 공개하고 필드는 비공개로 유지하는 방식이 일반적입니다. Rust에서는 가능하면 필드를 직접 공개하는 경우가 더 많습니다. 접근자 메서드를 위한 전용 구문이 없고 빌림 검사기와 관련한 복잡성이 생길 수 있기 때문입니다.

확장 메서드

C#의 확장 메서드는 기존 타입의 정의를 수정하지 않고 정적으로 바인딩되는 새 메서드를 붙입니다. 다음 C# 예제에서는 StringBuilder 클래스에 Wrap 메서드를 확장 방식으로 추가합니다.

using System;
using System.Text;
using Extensions; // (1)

var sb = new StringBuilder("Hello, World!");
sb.Wrap(">>> ", " <<<"); // (2)
Console.WriteLine(sb.ToString()); // 출력: >>> Hello, World! <<<

namespace Extensions
{
    static class StringBuilderExtensions
    {
        public static void Wrap(this StringBuilder sb,
                                string left, string right) =>
            sb.Insert(0, left).Append(right);
    }
}

확장 메서드를 사용하려면 (1) 해당 메서드가 들어 있는 타입의 네임스페이스를 가져와야 (2) 메서드를 호출할 수 있습니다. Rust에서는 _확장 트레이트_로 비슷한 기능을 구현합니다. 다음 Rust 예제는 String에 wrap 메서드를 추가합니다.

#![allow(dead_code)]

mod exts {
    pub trait StrWrapExt {
        fn wrap(&mut self, left: &str, right: &str);
    }

    impl StrWrapExt for String {
        fn wrap(&mut self, left: &str, right: &str) {
            self.insert_str(0, left);
            self.push_str(right);
        }
    }
}

fn main() {
    use exts::StrWrapExt as _; // (1)

    let mut s = String::from("Hello, World!");
    s.wrap(">>> ", " <<<"); // (2)
    println!("{s}"); // 출력: >>> Hello, World! <<<
}

C#과 마찬가지로 확장 트레이트의 메서드를 사용하려면 (1) 트레이트를 가져와야 (2) 메서드를 호출할 수 있습니다. 가져올 때 트레이트 이름 StrWrapExt를 _로 버려도 String의 wrap 메서드는 계속 사용할 수 있습니다.

가시성과 접근 한정자

C#에는 다음과 같은 접근 한정자가 있습니다.

  • private
  • protected
  • internal
  • protected internal(패밀리)
  • public

Rust 프로그램은 모듈로 이루어진 트리로 구성됩니다. 모듈에는 타입, 트레이트, 열거형, 상수, 함수 같은 항목을 정의합니다. 거의 모든 항목은 기본적으로 비공개입니다. 예외적으로 공개 트레이트의 연관 항목은 기본적으로 공개됩니다. C# 인터페이스에서 public을 명시하지 않은 멤버가 기본적으로 공개되는 것과 비슷합니다. Rust에서는 pub 한정자로 모듈 트리에 대한 가시성을 변경합니다. pub의 변형을 사용하면 공개 범위를 더 좁게 지정할 수 있습니다.

  • pub(self)
  • pub(super)
  • pub(crate)
  • pub(in PATH)

자세한 내용은 The Rust Reference의 가시성과 비공개 범위 절에서 확인할 수 있습니다.

다음 표는 C#과 Rust 한정자의 대략적인 대응 관계를 보여 줍니다.

C#Rust비고
private기본값주 1 참조
protected해당 없음주 2 참조
internalpub(crate)
protected internal(패밀리)해당 없음주 2 참조
publicpub
  1. Rust에서는 비공개 가시성을 나타내는 별도 키워드가 없습니다. 기본값이 비공개입니다.
  2. Rust에는 클래스 기반 타입 계층이 없으므로 protected에 대응하는 한정자가 없습니다.

가변성

C#에서 타입이 변경 가능한지와 파괴적 또는 비파괴적 변경을 지원하는지는 개발자가 설계합니다. C#의 위치 기반 레코드 선언(record class 또는 readonly record struct)은 불변 설계를 지원합니다. Rust에서는 다음 예제처럼 self 매개변수의 타입으로 메서드의 가변성을 표현합니다.

struct Point { x: i32, y: i32 }

impl Point {
    pub fn new(x: i32, y: i32) -> Self {
        Self { x, y }
    }

    // self는 가변이 아닙니다.

    pub fn x(&self) -> i32 { self.x }
    pub fn y(&self) -> i32 { self.y }

    // self는 가변입니다.

    pub fn set_x(&mut self, val: i32) { self.x = val }
    pub fn set_y(&mut self, val: i32) { self.y = val }
}

C#에서는 with로 비파괴적 변경을 할 수 있습니다.

var pt = new Point(123, 456);
pt = pt with { X = 789 };
Console.WriteLine(pt.ToString()); // 출력: Point { X = 789, Y = 456 }

readonly record struct Point(int X, int Y);

Rust에는 with가 없습니다. 비슷한 동작이 필요하면 타입을 설계할 때 이를 구현해야 합니다.

struct Point { x: i32, y: i32 }

impl Point {
    pub fn new(x: i32, y: i32) -> Self {
        Self { x, y }
    }

    pub fn x(&self) -> i32 { self.x }
    pub fn y(&self) -> i32 { self.y }

    // 다음 메서드는 self를 소비하고 새 인스턴스를 반환합니다.

    pub fn set_x(self, val: i32) -> Self { Self::new(val, self.y) }
    pub fn set_y(self, val: i32) -> Self { Self::new(self.x, val) }
}

C#의 with는 읽고 쓸 수 있는 필드를 공개한 일반 struct에도 사용할 수 있습니다. 레코드가 아니어도 됩니다.

struct Point
{
    public int X;
    public int Y;

    public override string ToString() => $"({X}, {Y})";
}

var pt = new Point { X = 123, Y = 456 };
Console.WriteLine(pt.ToString()); // 출력: (123, 456)
pt = pt with { X = 789 };
Console.WriteLine(pt.ToString()); // 출력: (789, 456)

Rust의 _구조체 갱신 구문_은 이와 비슷해 보일 수 있습니다.

mod points {
    #[derive(Debug)]
    pub struct Point { pub x: i32, pub y: i32 }
}

fn main() {
    use points::Point;
    let pt = Point { x: 123, y: 456 };
    println!("{pt:?}"); // 출력: Point { x: 123, y: 456 }
    let pt = Point { x: 789, ..pt };
    println!("{pt:?}"); // 출력: Point { x: 789, y: 456 }
}

C#의 with는 복사한 뒤 값을 바꾸는 비파괴적 변경을 수행합니다. 반면 구조체 갱신 구문은 필드에 대해서만 작동하며 값의 일부를 _이동_합니다. 이 구문을 사용하려면 타입의 필드에 접근할 수 있어야 하므로 비공개 세부 사항에 접근할 수 있는 Rust 모듈 안에서 더 자주 사용합니다.

로컬 함수

C#과 Rust는 모두 로컬 함수를 지원합니다. Rust의 로컬 함수는 C#의 정적 로컬 함수에 해당합니다. 따라서 둘러싼 어휘 범위의 변수를 사용할 수 없습니다. _클로저_는 해당 변수를 사용할 수 있습니다.

람다와 클로저

C#과 Rust는 함수를 일급 값으로 다루며 _고차 함수_를 작성할 수 있습니다. 고차 함수는 다른 함수를 인수로 받아 호출자가 함수 동작의 일부를 제공하도록 합니다. C#에서는 타입 안전한 함수 참조를 대리자로 표현하며, Func와 Action을 자주 사용합니다. _람다 식_으로 대리자 인스턴스를 즉석에서 만들 수 있습니다.

Rust에도 함수 포인터가 있습니다. 가장 간단한 형태는 fn 타입입니다.

fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
    f(arg) + f(arg)
}

fn main() {
    let answer = do_twice(|x| x + 1, 5);
    println!("The answer is: {}", answer); // 출력: The answer is: 12
}

Rust는 _함수 포인터_와 _클로저_를 구분합니다. 함수 포인터의 타입은 fn으로 정의합니다. 클로저는 주변 어휘 범위의 변수를 참조할 수 있지만 함수 포인터는 그럴 수 없습니다. C#에도 delegate* 형태의 함수 포인터가 있습니다. 관리 코드에서 타입 안전하게 사용하는 정적 람다 식은 Rust의 함수 포인터와 더 가깝습니다.

클로저를 받는 함수와 메서드는 Fn, FnMut, FnOnce 트레이트 중 하나를 제약 조건으로 둔 제네릭 타입을 사용합니다. 함수 포인터나 클로저 값을 전달할 때는 앞 예제의 |x| x + 1 같은 _클로저 식_을 작성합니다. C#의 람다 식과 역할이 같습니다. 클로저 식이 함수 포인터가 될지 클로저가 될지는 주변 변수를 참조하는지에 따라 달라집니다.

클로저가 주변 변수를 캡처하면 소유권 규칙도 적용됩니다. 캡처한 값의 소유권이 클로저로 이동할 수 있기 때문입니다. 자세한 내용은 The Rust Programming Language의 클로저에서 캡처한 값 이동과 Fn 트레이트 절에서 확인할 수 있습니다.

변수

다음 C# 예제는 변수에 값을 할당합니다.

int x = 5;

Rust에서는 다음과 같이 작성합니다.

let x: i32 = 5;

여기까지 두 언어의 눈에 띄는 차이는 타입 선언의 위치뿐입니다. C#과 Rust는 모두 타입 안전성을 보장합니다. 컴파일러는 변수에 지정된 타입의 값만 저장하도록 검사합니다. 컴파일러의 타입 추론을 활용하면 예제를 더 짧게 작성할 수 있습니다. 먼저 C# 코드입니다.

var x = 5;

Rust 코드는 다음과 같습니다.

let x = 5;

첫 예제에서 변수에 값을 다시 할당하면 두 언어의 동작이 달라집니다.

var x = 5;
x = 6;
Console.WriteLine(x); // 6

Rust에서 같은 문장을 작성하면 컴파일되지 않습니다.

let x = 5;
x = 6; // 오류: 불변 변수 'x'에 다시 값을 대입할 수 없습니다.
println!("{}", x);

Rust의 변수는 기본적으로 _불변_입니다. 이름에 값을 바인딩한 뒤에는 그 값을 변경할 수 없습니다. 변수 이름 앞에 mut를 붙이면 가변 변수를 만들 수 있습니다.

let mut x = 5;
x = 6;
println!("{}", x); // 6

또는 변수 _섀도잉_을 사용하면 가변성 없이도 예제를 수정할 수 있습니다.

let x = 5;
let x = 6;
println!("{}", x); // 6

C#도 섀도잉을 지원합니다. 예를 들어 로컬 변수가 필드를 가리거나 파생 타입의 멤버가 기본 타입의 멤버를 가릴 수 있습니다. Rust에서는 위 예제처럼 같은 이름을 유지하면서 변수 타입도 바꿀 수 있습니다. 데이터를 여러 타입과 형태로 변환할 때마다 새 이름을 정하지 않아도 된다는 장점이 있습니다.

관련 자료:

네임스페이스

.NET은 네임스페이스로 타입을 구성하고 프로젝트에서 타입과 메서드가 보이는 범위를 제어합니다.

Rust에서 네임스페이스라는 말은 다른 개념을 가리킵니다. .NET 네임스페이스에 대응하는 Rust의 기능은 모듈입니다. C#과 Rust는 각각 접근 한정자와 가시성 한정자로 항목의 공개 범위를 제한합니다. Rust에서는 몇 가지 예외를 제외하면 기본 가시성이 _비공개_입니다. C#의 public은 Rust의 pub에, internal은 pub(crate)에 대응합니다. 세부 접근 범위는 가시성 한정자 문서에서 확인할 수 있습니다.

동등성

C#에서 두 값의 동등성을 비교할 때는 _값 동등성_을 검사하는 경우도 있고, 두 변수가 메모리의 같은 객체를 가리키는지 확인하는 _참조 동등성_을 검사하는 경우도 있습니다. 모든 사용자 정의 타입은 System.Object를 상속하므로 동등성을 비교할 수 있습니다. 값 타입은 System.Object를 상속하는 System.ValueType을 통해 이 기능을 얻습니다. 비교할 때는 타입에 적용된 동등성 의미에 따릅니다.

다음 C# 예제는 값 동등성과 참조 동등성을 비교합니다.

var a = new Point(1, 2);
var b = new Point(1, 2);
var c = a;
Console.WriteLine(a == b); // (1) True
Console.WriteLine(a.Equals(b)); // (1) True
Console.WriteLine(a.Equals(new Point(2, 2))); // (1) False
Console.WriteLine(ReferenceEquals(a, b)); // (2) False
Console.WriteLine(ReferenceEquals(a, c)); // (2) True

record Point(int X, int Y);
  1. record Point의 == 연산자와 Equals 메서드는 값 동등성을 비교합니다. 레코드는 기본적으로 값 기반 동등성을 지원합니다.
  2. 참조 동등성 비교는 두 변수가 메모리의 같은 객체를 가리키는지 검사합니다.

Rust에서는 같은 예제를 다음과 같이 작성할 수 있습니다.

#[derive(Copy, Clone)]
struct Point(i32, i32);

fn main() {
    let a = Point(1, 2);
    let b = Point(1, 2);
    let c = a;
    println!("{}", a == b); // 오류: "an implementation of `PartialEq<_>` might be missing for `Point`"
    println!("{}", a.eq(&b));
    println!("{}", a.eq(&Point(2, 2)));
}

위 컴파일 오류는 Rust의 동등성 비교가 언제나 트레이트 구현에 달려 있음을 보여 줍니다. == 비교를 지원하려면 타입이 PartialEq를 구현해야 합니다.

예제를 고치려면 Point에 PartialEq를 파생 구현합니다. 기본 파생 구현은 모든 필드를 비교하므로 각 필드도 PartialEq를 구현해야 합니다. C# 레코드의 동등성 비교와 비슷합니다.

#[derive(Copy, Clone, PartialEq)]
struct Point(i32, i32);

fn main() {
    let a = Point(1, 2);
    let b = Point(1, 2);
    let c = a;
    println!("{}", a == b); // true
    println!("{}", a.eq(&b)); // true
    println!("{}", a.eq(&Point(2, 2))); // false
    println!("{}", a.eq(&c)); // true
}

Rust의 모든 값이 참조인 것은 아니므로 참조 동등성과 일대일로 대응하는 개념은 없습니다. 다만 참조가 둘 있다면 std::ptr::eq()로 같은 대상을 가리키는지 확인할 수 있습니다.

fn main() {
    let a = 1;
    let b = 1;
    println!("{}", a == b); // true
    println!("{}", std::ptr::eq(&a, &b)); // false
    println!("{}", std::ptr::eq(&a, &a)); // true
}

참조를 원시 포인터로 변환하고 ==로 비교하는 방법도 있습니다.

관련 자료:

  • PartialEq보다 엄격한 동등성 조건을 다루는 Eq

제네릭

C#의 제네릭을 사용하면 다른 타입을 매개변수로 받는 타입과 메서드를 정의할 수 있습니다. 코드 재사용과 타입 안전성을 높이고 실행 시간의 캐스팅을 줄여 성능에도 도움이 됩니다. 다음 예제는 임의의 값에 시간 정보를 덧붙이는 제네릭 타입입니다.

using System;

sealed record Timestamped<T>(DateTime Timestamp, T Value)
{
    public Timestamped(T value) : this(DateTime.UtcNow, value) { }
}

Rust에도 제네릭이 있습니다. 위 타입에 대응하는 코드는 다음과 같습니다.

use std::time::*;

struct Timestamped<T> { value: T, timestamp: SystemTime }

impl<T> Timestamped<T> {
    fn new(value: T) -> Self {
        Self { value, timestamp: SystemTime::now() }
    }
}

관련 자료:

제네릭 타입 제약 조건

C#에서는 where 절로 제네릭 타입에 제약 조건을 지정할 수 있습니다. 다음 예제에서 제약 조건을 확인할 수 있습니다.

using System;

// 참고: 레코드는 `IEquatable`을 자동으로 구현합니다. 다음
// 구현은 Rust와 비교하기 위해 이를 명시적으로 보여 줍니다.
sealed record Timestamped<T>(DateTime Timestamp, T Value) :
    IEquatable<Timestamped<T>>
    where T : IEquatable<T>
{
    public Timestamped(T value) : this(DateTime.UtcNow, value) { }

    public bool Equals(Timestamped<T>? other) =>
        other is { } someOther
        && Timestamp == someOther.Timestamp
        && Value.Equals(someOther.Value);

    public override int GetHashCode() => HashCode.Combine(Timestamp, Value);
}

Rust에서도 같은 목적의 제약 조건을 작성할 수 있습니다.

use std::time::*;

struct Timestamped<T> { value: T, timestamp: SystemTime }

impl<T> Timestamped<T> {
    fn new(value: T) -> Self {
        Self { value, timestamp: SystemTime::now() }
    }
}

impl<T> PartialEq for Timestamped<T>
where
    T: PartialEq,
{
    fn eq(&self, other: &Self) -> bool {
        self.value == other.value && self.timestamp == other.timestamp
    }
}

Rust에서는 매개변수를 선언할 때 바로 제약 조건을 붙이는 짧은 형식도 지원합니다.

impl<T: PartialEq> PartialEq for Timestamped<T> {
    fn eq(&self, other: &Self) -> bool {
        self.value == other.value && self.timestamp == other.timestamp
    }
}

다만 where 절은 i32: PartialEq<T>처럼 임의의 타입에도 제약 조건을 지정할 수 있어 표현 범위가 더 넓습니다.

Rust에서는 제네릭 타입 제약 조건을 바운드라고 부릅니다.

C# 버전은 T 자체가 IEquatable<T>를 구현할 때만 Timestamped<T> 인스턴스를 만들 수 있습니다. Rust 버전에서는 Timestamped<T>가 조건부로 PartialEq를 구현하므로 적용 범위가 더 넓습니다. 동등성 비교를 지원하지 않는 T로도 Timestamped<T>를 만들 수 있지만, 그 인스턴스에는 PartialEq를 통한 동등성 비교가 없습니다.

관련 자료:

다형성

Rust에는 클래스와 하위 클래스가 없으므로 C#과 동일한 방식으로 다형성을 구현할 수 없습니다.

관련 내용은 다음 절에서 확인할 수 있습니다.

상속

구조체 절에서 설명하듯 Rust에는 C#과 같은 클래스 상속이 없습니다. 구조체 간에 동작을 공유하려면 트레이트를 사용할 수 있습니다. 또한 C#의 _인터페이스 상속_과 비슷하게 Rust에서도 슈퍼트레이트로 트레이트 사이의 관계를 정의할 수 있습니다.

예외 처리

.NET에서 예외는 System.Exception 클래스를 상속한 타입입니다. 코드 실행 중 문제가 생기면 예외를 던집니다. 프로그램이 예외를 처리하거나 종료할 때까지 예외는 호출 스택을 따라 전파됩니다.

Rust에는 예외가 없습니다. 대신 복구 가능한 오류와 복구할 수 없는 오류를 구분합니다. 복구 가능한 오류는 문제를 보고한 뒤 프로그램을 계속 실행할 수 있는 경우를 뜻합니다. 이런 오류가 발생할 수 있는 연산은 Result<T, E> 타입을 반환합니다. 여기서 E는 오류 변형의 타입입니다. 복구할 수 없는 오류를 만나면 panic! 매크로가 실행을 중단합니다. 복구할 수 없는 오류는 항상 버그의 징후입니다.

사용자 정의 오류 타입

.NET의 사용자 정의 예외는 Exception 클래스를 상속합니다. 사용자 정의 예외 작성 방법 문서에는 다음 예제가 있습니다.

public class EmployeeListNotFoundException : Exception
{
    public EmployeeListNotFoundException() { }

    public EmployeeListNotFoundException(string message)
        : base(message) { }

    public EmployeeListNotFoundException(string message, Exception inner)
        : base(message, inner) { }
}

Rust에서는 Error 트레이트를 구현해 오류 값에 일반적으로 기대하는 기능을 제공할 수 있습니다. 최소한의 사용자 정의 오류 구현은 다음과 같습니다.

#[derive(Debug)]
pub struct EmployeeListNotFound;

impl std::fmt::Display for EmployeeListNotFound {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("Could not find employee list.")
    }
}

impl std::error::Error for EmployeeListNotFound {}

.NET의 Exception.InnerException 속성에 대응하는 기능은 Rust의 Error::source() 메서드입니다. 다만 이 메서드를 반드시 구현할 필요는 없습니다. 기본 일괄 구현은 None을 반환합니다.

C#과 달리 Rust의 오류 타입이 반드시 std::error::Error를 구현해야 하는 것은 아닙니다. 이 트레이트 없이도 Result에서 사용할 수 있습니다. 공개 API의 오류 타입이라면 구현하는 관례가 있습니다.

오류 발생시키기

C#에서는 예외 인스턴스를 던져 예외를 발생시킵니다.

void ThrowIfNegative(int value)
{
    if (value < 0)
    {
        throw new ArgumentOutOfRangeException(nameof(value));
    }
}

Rust에서는 복구 가능한 오류가 발생하면 메서드에서 Ok 또는 Err 변형을 반환합니다.

fn error_if_negative(value: i32) -> Result<(), &'static str> {
    if value < 0 {
        Err("Specified argument was out of the range of valid values. (Parameter 'value')")
    } else {
        Ok(())
    }
}

panic! 매크로는 복구할 수 없는 오류를 발생시킵니다.

fn panic_if_negative(value: i32) {
    if value < 0 {
        panic!("Specified argument was out of the range of valid values. (Parameter 'value')")
    }
}

오류 전파

.NET에서는 예외를 처리하거나 프로그램이 종료될 때까지 예외가 호출 스택을 따라 전파됩니다. Rust에서 복구할 수 없는 오류도 비슷하게 동작하지만 이를 처리하는 일은 드뭅니다.

복구 가능한 오류는 명시적으로 전파하고 처리합니다. Rust의 함수나 메서드 시그니처는 이런 오류가 발생할 수 있음을 나타냅니다. C#에서는 예외를 잡아 오류 발생 여부에 따라 다음 동작을 선택할 수 있습니다.

void Write()
{
    try
    {
        File.WriteAllText("file.txt", "content");
    }
    catch (IOException)
    {
        Console.WriteLine("Writing to file failed.");
    }
}

Rust에서는 대략 다음 코드에 대응합니다.

fn write() {
    match std::fs::write("file.txt", b"content")
    {
        Ok(_) => {}
        Err(_) => println!("Writing to file failed."),
    };
}

복구 가능한 오류를 직접 처리하지 않고 전파하기만 하는 경우도 많습니다. 이때 메서드 시그니처의 오류 타입이 전파할 오류와 호환되어야 합니다. ? 연산자를 사용하면 간결하게 전파할 수 있습니다.

fn write() -> Result<(), std::io::Error> {
    std::fs::write("file.txt", b"content")?;
    Ok(())
}

? 연산자로 오류를 전파하려면 오류 전파의 간단한 방법 절에서 설명하는 것처럼 오류 구현이 서로 _호환_되어야 합니다. 가장 범용적인 호환 오류 타입은 Box<dyn Error> 트레이트 객체입니다.

스택 추적

.NET에서 처리되지 않은 예외를 던지면 런타임이 스택 추적을 출력해 문제의 맥락을 파악하도록 돕습니다.

Rust에서 복구할 수 없는 오류는 panic!의 역추적으로 비슷한 정보를 얻을 수 있습니다.

원문은 안정화된 Rust의 복구 가능한 오류에서 역추적을 지원하지 않는다고 설명합니다. 실험 기능의 provide 메서드를 사용하는 경우를 별도로 언급합니다.

null 가능성과 선택성

C#에서는 값이 없거나 아직 초기화되지 않았음을 나타낼 때 null을 자주 사용합니다. 다음 예제를 살펴보겠습니다.

int? some = 1;
int? none = null;

Rust에는 null이 없으므로 활성화할 nullable 문맥도 없습니다. 값이 없거나 선택 사항인 경우에는 Option<T>로 표현합니다. 위 C# 코드에 대응하는 Rust 코드는 다음과 같습니다.

let some: Option<i32> = Some(1);
let none: Option<i32> = None;

Rust의 Option<T>는 F#의 'T option과 거의 같습니다.

선택적 값에 따른 제어 흐름

C#에서는 null 가능 값을 처리할 때 if/else 문으로 흐름을 제어할 수 있습니다.

uint? max = 10;
if (max is { } someMax)
{
    Console.WriteLine($"The maximum is {someMax}."); // The maximum is 10.
}

Rust에서는 패턴 일치로 같은 동작을 구현할 수 있습니다.

let max = Some(10u32);
match max {
    Some(max) => println!("The maximum is {}.", max), // The maximum is 10.
    None => ()
}

if let을 사용하면 코드를 더 간결하게 작성할 수 있습니다.

let max = Some(10u32);
if let Some(max) = max {
    println!("The maximum is {}.", max); // The maximum is 10.
}

null 조건부 연산자

C#의 null 조건부 연산자 ?.와 ?[]는 null을 다루기 편하게 해 줍니다. Rust에서는 Option의 중첩 여부에 따라 map 또는 and_then 메서드를 사용할 수 있습니다. 다음 예제에서 대응 관계를 살펴보겠습니다.

string? some = "Hello, World!";
string? none = null;
Console.WriteLine(some?.Length); // 13
Console.WriteLine(none?.Length); // (빈 줄)

record Name(string FirstName, string LastName);
record Person(Name? Name);

{
    Person? person = new Person(new Name("John", "Doe"));
    Console.WriteLine(person1?.Name?.FirstName); // John
}

{
    Person? person = new Person(null);
    Console.WriteLine(person1?.Name?.FirstName); // (빈 줄)
}

{
    Person? person = null;
    Console.WriteLine(person1?.Name?.FirstName); // (빈 줄)
}

다음 예제에서는 다른 중첩 형태를 비교합니다.

let some: Option<String> = Some(String::from("Hello, World!"));
let none: Option<String> = None;
println!("{:?}", some.map(|s| s.len())); // Some(13)
println!("{:?}", none.map(|s| s.len())); // None

struct Name { first_name: String, last_name: String }
struct Person { name: Option<Name> }

let person: Option<Person> = Some(Person {
    name: Some(Name {
        first_name: "John".into(),
        last_name: "Doe".into(),
    }),
});
println!("{:?}", person.and_then(|p| p.name.map(|name| name.first_name))); // Some("John")

let person: Option<Person> = Some(Person { name: None });
println!("{:?}", person.and_then(|p| p.name.map(|name| name.first_name))); // None

let person: Option<Person> = None;
println!("{:?}", person.and_then(|p| p.name.map(|name| name.first_name))); // None

앞 장의 오류 전파 절에서 살펴본 ? 연산자는 Option에도 사용할 수 있습니다. None을 만나면 함수에서 None을 반환하고, Some이면 안의 값으로 계산을 계속합니다.

fn foo(optional: Option<i32>) -> Option<String> {
    let value = optional?;
    Some(value.to_string())
}

null 병합 연산자

C#의 null 병합 연산자 ??는 null 가능 값이 null일 때 기본값을 선택하는 데 사용합니다.

int? some = 1;
int? none = null;
Console.WriteLine(some ?? 0); // 1
Console.WriteLine(none ?? 0); // 0

Rust에서는 unwrap_or로 같은 동작을 구현할 수 있습니다.

let some: Option<i32> = Some(1);
let none: Option<i32> = None;
println!("{:?}", some.unwrap_or(0)); // 1
println!("{:?}", none.unwrap_or(0)); // 0

기본값을 계산하는 비용이 크다면 unwrap_or_else를 사용할 수 있습니다. 이 메서드는 클로저를 받아 기본값을 지연 계산합니다.

null 허용 연산자

C#의 null 허용 연산자 !는 컴파일러의 정적 흐름 분석에만 영향을 주므로 Rust에 직접 대응하는 구문이 없습니다. Rust에서는 같은 역할의 구문이 필요하지 않습니다. 다만 unwrap은 값이 None이면 패닉을 일으킨다는 점에서 비슷한 결과를 낼 수 있습니다. expect도 비슷하지만 사용자 정의 오류 메시지를 넣을 수 있습니다. 앞서 설명했듯 패닉은 복구할 수 없는 상황에 사용합니다.

버림

C#의 버림은 식의 결과 또는 결과의 일부를 사용하지 않겠다는 의도를 컴파일러와 다른 개발자에게 나타냅니다.

적용할 수 있는 위치는 여러 곳입니다. 가장 간단한 예로 식의 결과를 무시하는 C# 코드를 살펴보겠습니다.

_ = city.GetCityInformation(cityName);

Rust에서 식의 결과를 무시할 때도 같은 형태로 작성합니다.

_ = city.get_city_information(city_name);

C#에서 튜플을 분해할 때도 버림을 사용할 수 있습니다.

var (_, second) = ("first", "second");

Rust에서도 같은 방식으로 작성합니다.

let (_, second) = ("first", "second");

Rust는 튜플뿐 아니라 구조체와 열거형의 분해도 지원합니다. 이때 ..는 타입에서 나머지 부분을 뜻합니다.

struct Point {
    x: i32,
    y: i32,
    z: i32,
}

let origin = Point { x: 0, y: 0, z: 0 };

match origin {
    Point { x, .. } => println!("x is {}", x), // x is 0
}

패턴을 일치시킬 때 결과의 일부를 버리거나 무시하면 편리한 경우가 있습니다. 다음은 C# 예제입니다.

_ = ("first", "second") switch
{
    ("first", _) => "first element matched",
    (_, _) => "first element did not match"
};

Rust에서도 거의 같은 형태로 작성합니다.

_ = match ("first", "second")
{
    ("first", _) => "first element matched",
    (_, _) => "first element did not match"
};

변환과 캐스팅

C#과 Rust는 모두 컴파일할 때 타입을 정적으로 검사합니다. 변수를 선언한 뒤에는 해당 타입으로 암시적으로 변환할 수 없는 다른 타입의 값을 할당할 수 없습니다. C#의 여러 타입 변환 방식에는 Rust에서 대응하는 방법이 있습니다.

암시적 변환

C#과 Rust 모두 암시적 변환을 지원합니다. Rust에서는 이를 타입 강제 변환이라고 부릅니다. 다음 예제를 살펴보겠습니다.

int intNumber = 1;
long longNumber = intNumber;

Rust는 허용하는 타입 강제 변환의 범위를 훨씬 좁게 제한합니다.

let int_number: i32 = 1;
let long_number: i64 = int_number; // 오류: `i64`가 필요하지만 `i32`를 받았습니다.

하위 타입 관계를 이용한 올바른 암시적 변환 예제는 다음과 같습니다.

fn bar<'a>() {
    let s: &'static str = "hi";
    let t: &'a str = s;
}

관련 자료:

명시적 변환

정보를 잃을 수 있는 변환에는 C#에서 캐스팅 식을 사용한 명시적 변환이 필요합니다.

double a = 1.2;
int b = (int)a;

명시적 변환은 다운캐스팅 과정에서 OverflowException이나 InvalidCastException 같은 예외를 발생시키며 실행 중 실패할 수 있습니다.

Rust는 기본 타입 간 강제 변환 대신 as 키워드를 사용한 명시적 변환, 즉 캐스팅을 제공합니다. Rust의 캐스팅은 패닉을 일으키지 않습니다.

let int_number: i32 = 1;
let long_number: i64 = int_number as _;

사용자 정의 변환

.NET 타입에는 한 타입을 다른 타입으로 바꾸는 사용자 정의 변환 연산자를 둘 수 있습니다. System.IConvertible도 타입 간 변환에 사용합니다.

Rust 표준 라이브러리는 From 트레이트와 그 역방향인 Into 트레이트로 값 변환을 추상화합니다. 어떤 타입에 From을 구현하면 Into의 기본 구현도 자동으로 제공됩니다. Rust에서는 이를 _일괄 구현_이라고 부릅니다. 다음 예제는 두 가지 타입 변환을 보여 줍니다.

fn main() {
    let my_id = MyId("id".into()); // `String`의 `From<&str>` 구현 덕분에 `into()`가 자동으로 제공됩니다.
    println!("{}", String::from(my_id)); // `String`의 `From<MyId>` 구현을 사용합니다.
}

struct MyId(String);

impl From<MyId> for String {
    fn from(MyId(value): MyId) -> Self {
        value
    }
}

관련 자료:

연산자 오버로딩

C#에서는 사용자 정의 타입이 _오버로드 가능한 연산자_를 오버로드할 수 있습니다. 다음 C# 예제를 살펴보겠습니다.

Console.WriteLine(new Fraction(5, 4) + new Fraction(1, 2));  // 14/8

public readonly record struct Fraction(int Numerator, int Denominator)
{
    public static Fraction operator +(Fraction a, Fraction b) =>
        new(a.Numerator * b.Denominator + b.Numerator * a.Denominator, a.Denominator * b.Denominator);

    public override string ToString() => $"{Numerator}/{Denominator}";
}

Rust의 여러 연산자는 트레이트를 통해 오버로드할 수 있습니다. 연산자가 메서드 호출을 간단하게 표현하는 구문이기 때문입니다. 예를 들어 a + b의 + 연산자는 add 메서드를 호출합니다. 다음 예제와 연산자 오버로딩 문서를 참고할 수 있습니다.

use std::{fmt::{Display, Formatter, Result}, ops::Add};

struct Fraction {
    numerator: i32,
    denominator: i32,
}

impl Display for Fraction {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
        f.write_fmt(format_args!("{}/{}", self.numerator, self.denominator))
    }
}

impl Add<Fraction> for Fraction {
    type Output = Fraction;

    fn add(self, rhs: Fraction) -> Fraction {
        Fraction {
            numerator: self.numerator * rhs.denominator + rhs.numerator * self.denominator,
            denominator: self.denominator * rhs.denominator,
        }
    }
}

fn main() {
    println!(
        "{}",
        Fraction { numerator: 5, denominator: 4 } + Fraction { numerator: 1, denominator: 2 }
    ); // 14/8
}

문서 주석

C#은 XML 텍스트를 담는 주석 구문으로 타입의 API를 문서화합니다. C# 컴파일러는 주석과 API 시그니처를 구조화한 XML 파일을 만듭니다. 다른 도구는 이 파일을 처리해 사람이 읽기 좋은 문서를 생성할 수 있습니다. 다음은 간단한 C# 예제입니다.

/// <summary>
/// <c>MyClass</c>에 대한 문서 주석입니다.
/// </summary>
public class MyClass {}

Rust의 문서 주석은 C# 문서 주석에 대응합니다. Rust 문서 주석에는 Markdown 구문을 사용합니다. Rust 문서 컴파일러인 rustdoc은 보통 cargo doc을 통해 실행하며 주석을 문서로 컴파일합니다. 다음 예제를 살펴보겠습니다.

/// `MyStruct`에 대한 문서 주석입니다.
struct MyStruct;

.NET SDK에는 cargo doc에 대응하는 dotnet doc 같은 명령이 없습니다.

관련 자료:

메모리 관리

Rust는 C#과 .NET처럼 _메모리 안전성_을 제공해 메모리 접근 오류를 예방합니다. 이런 오류는 소프트웨어 보안 취약점의 흔한 원인입니다. Rust는 CLR 같은 런타임의 검사 없이 컴파일할 때 메모리 안전성을 보장합니다. 예외적으로 배열 범위 검사는 컴파일된 코드가 실행 중에 수행합니다. .NET의 JIT 컴파일 코드도 같은 검사를 수행합니다. C#처럼 Rust에서도 안전하지 않은 코드를 작성할 수 있습니다. 두 언어는 메모리 안전성을 보장하지 않는 함수와 코드 블록에 모두 unsafe 키워드를 사용합니다.

Rust에는 가비지 수집기(GC)가 없습니다. 메모리 관리는 개발자의 책임입니다. 다만 _안전한 Rust_의 소유권 규칙은 메모리를 더 이상 사용하지 않을 때 해제하도록 합니다. 예를 들어 블록이나 함수를 벗어나면 해당 메모리가 해제될 수 있습니다. 컴파일러는 정적 분석으로 소유권 규칙을 검사하고 위반한 코드를 컴파일 오류로 거부합니다.

.NET에는 정적 필드, 스레드 스택의 로컬 변수, CPU 레지스터, 핸들 등의 GC 루트를 제외하면 Rust와 같은 메모리 소유권 개념이 없습니다. GC는 수집할 때 루트에서 참조를 따라 사용 중인 메모리를 찾아내고 나머지를 정리합니다. 대부분의 .NET 코드는 소유권이나 GC의 동작을 의식하지 않고 작성할 수 있습니다. 다만 성능에 민감한 코드에서는 힙에 할당하는 객체의 양과 속도를 고려할 수 있습니다.

반면 Rust의 소유권 규칙은 함수, 타입, 자료구조의 설계부터 코드 작성까지 영향을 줍니다. 개발자는 소유권을 명시적으로 고려해야 합니다. Rust는 데이터 사용에 엄격한 규칙을 적용해 실행 중 발생할 수 있는 데이터 경쟁 상태와 손상 문제도 컴파일할 때 찾아냅니다. 이 장에서는 스레드 안전성보다 메모리 관리와 소유권에 집중합니다.

Rust에서는 어느 시점에든 스택 또는 힙에 있는 구조체의 메모리를 소유하는 주체가 하나뿐입니다. 컴파일러는 수명과 소유권을 추적합니다. 소유권을 다른 곳으로 넘기는 동작을 _이동_이라고 부릅니다. 다음 Rust 예제에서 이를 확인할 수 있습니다.

#![allow(dead_code, unused_variables)]

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let a = Point { x: 12, y: 34 }; // a가 Point를 소유합니다.
    let b = a;                      // 이제 b가 Point를 소유합니다.
    println!("{}, {}", a.x, a.y);   // 컴파일 오류입니다.
}

main의 첫 문장은 Point를 만들고 a에 소유권을 부여합니다. 두 번째 문장에서 소유권이 a에서 b로 이동하므로 a는 더 이상 유효한 메모리를 나타내지 않습니다. 마지막 문장에서 a를 통해 좌표를 출력하려 하면 컴파일에 실패합니다. 다음처럼 main을 수정할 수 있습니다.

fn main() {
    let a = Point { x: 12, y: 34 }; // a가 Point를 소유합니다.
    let b = a;                      // 이제 b가 Point를 소유합니다.
    println!("{}, {}", b.x, b.y);   // 정상적으로 b를 사용합니다.
}   // b가 소유한 Point가 해제됩니다.

main이 끝나면 a와 b가 범위를 벗어납니다. 스택이 main 호출 전 상태로 돌아가면서 b가 가리키던 메모리를 해제합니다. Rust에서는 b가 소유한 점이 _드롭_되었다고 말합니다. a는 이미 소유권을 넘겼으므로 범위를 벗어나도 드롭할 점이 없습니다.

Rust 구조체는 Drop 트레이트를 구현해 인스턴스가 드롭될 때 실행할 코드를 정의할 수 있습니다.

C#에서 드롭과 대략 비슷한 기능으로 클래스 종료자가 있습니다. GC는 나중에 종료자를 자동 호출하지만 Rust의 드롭은 컴파일러가 범위와 수명을 근거로 소유자가 없어졌다고 판단한 위치에서 즉시, 결정적으로 실행합니다. .NET에서 Drop과 가까운 인터페이스는 IDisposable입니다. 타입은 이를 구현해 보유한 비관리 리소스나 메모리를 해제합니다. .NET이 결정적 해제를 강제하는 것은 아니지만 C#의 using 문을 사용하면 블록이 끝날 때 인스턴스를 해제할 수 있습니다.

Rust의 'static은 전역 수명을 나타내는 예약된 수명 지정자입니다. C#의 타입에 선언한 정적 읽기 전용 필드가 아주 거친 비유에 해당합니다.

C#과 .NET에서는 참조를 자유롭게 공유하므로 단일 소유자와 소유권 이동이 제한적으로 보일 수 있습니다. Rust에서도 Rc 스마트 포인터로 _공유 소유권_을 구현할 수 있습니다. Rc는 참조 횟수를 셉니다. 스마트 포인터를 복제할 때 횟수가 증가하고 복제본이 드롭되면 감소합니다. 참조 횟수가 0이 되면 스마트 포인터 뒤의 인스턴스가 드롭됩니다. 다음 예제는 앞 예제를 확장해 이 동작을 보여 줍니다.

#![allow(dead_code, unused_variables)]

use std::rc::Rc;

struct Point {
    x: i32,
    y: i32,
}

impl Drop for Point {
    fn drop(&mut self) {
        println!("Point dropped!");
    }
}

fn main() {
    let a = Rc::new(Point { x: 12, y: 34 });
    let b = Rc::clone(&a); // b와 공유합니다.
    println!("a = {}, {}", a.x, a.y); // a를 사용해도 됩니다.
    println!("b = {}, {}", b.x, b.y);
}

// 출력:
// a = 12, 34
// b = 12, 34
// Point dropped!

예제에서 확인할 점은 다음과 같습니다.

  • Point는 Drop의 drop 메서드를 구현해 인스턴스가 드롭될 때 메시지를 출력합니다.
  • main에서 만든 점을 Rc가 감싸므로 점의 소유자는 a가 아닌 스마트 포인터입니다.
  • b는 스마트 포인터의 복제본을 받아 참조 횟수가 2가 됩니다. 앞 예제와 달리 a와 b가 서로 다른 스마트 포인터 복제본을 소유하므로 둘 다 계속 사용할 수 있습니다.
  • main 끝에서 a와 b가 범위를 벗어나면 각각 드롭됩니다. Rc의 Drop 구현은 참조 횟수를 줄이고 0이 되면 소유한 점도 드롭합니다. 이때 Point의 Drop 구현이 메시지를 한 번 출력합니다. 점 하나를 만들어 공유한 뒤 한 번 드롭했다는 뜻입니다.

Rc는 스레드 안전하지 않습니다. 여러 스레드에서 소유권을 공유할 때는 Rust 표준 라이브러리의 Arc를 사용합니다. Rust는 스레드 사이에서 Rc를 사용하지 못하도록 검사합니다.

.NET에서는 C#의 enum, struct 같은 값 타입이 스택에 놓이고 interface, record class, class 같은 참조 타입은 힙에 할당된다고 설명하는 경우가 많습니다. Rust에서는 enum과 struct 중 무엇을 쓰는지가 메모리 위치를 결정하지 않습니다. 기본적으로 스택에 놓이지만 .NET에서 값 타입을 박싱해 힙에 복사하듯 Rust에서는 Box로 감싸 힙에 할당할 수 있습니다.

let stack_point = Point { x: 12, y: 34 };
let heap_point = Box::new(Point { x: 12, y: 34 });

Box도 Rc, Arc처럼 스마트 포인터이지만 뒤에 있는 인스턴스를 독점적으로 소유합니다. 이 스마트 포인터들은 타입 인수 T의 인스턴스를 힙에 할당합니다.

C#의 new 키워드는 타입의 인스턴스를 만듭니다. 앞 예제의 Box::new, Rc::new도 비슷한 목적처럼 보일 수 있지만 Rust에서 new는 특별한 언어 구문이 아닙니다. 팩터리 함수에 관례적으로 붙이는 이름입니다. 이런 함수는 Rust에서 정적 메서드에 해당하는 타입의 _연관 함수_라고 부릅니다.

리소스 관리

앞의 메모리 관리 장에서는 GC, 소유권, 종료자를 중심으로 .NET과 Rust의 차이를 설명했습니다. 여기서는 리소스 해제 예제를 살펴보겠습니다.

다음 예제는 가상의 SQL _데이터베이스 연결_을 만들고 올바르게 닫거나 해제하거나 드롭하는 방법을 보여 줍니다. 먼저 .NET 코드입니다.

{
    using var db1 = new DatabaseConnection("Server=A;Database=DB1");
    using var db2 = new DatabaseConnection("Server=A;Database=DB2");

    // ..."db1"과 "db2"를 사용하는 코드...
}   // "db1"과 "db2"가 범위를 벗어나 여기서 Dispose를 호출합니다.

public class DatabaseConnection : IDisposable
{
    readonly string connectionString;
    SqlConnection connection; // IDisposable을 구현합니다.

    public DatabaseConnection(string connectionString) =>
        this.connectionString = connectionString;

    public void Dispose()
    {
        // SqlConnection을 반드시 해제합니다.
        this.connection.Dispose();
        Console.WriteLine("Closing connection: {this.connectionString}");
    }
}

Rust에서는 같은 리소스를 다음과 같이 다룹니다.

struct DatabaseConnection(&'static str);

impl DatabaseConnection {
    // ...데이터베이스 연결을 사용하는 함수...
}

impl Drop for DatabaseConnection {
    fn drop(&mut self) {
        // ...연결을 닫는 코드...
        self.close_connection();
        // ...메시지를 출력하는 코드...
        println!("Closing connection: {}", self.0)
    }
}

fn main() {
    let _db1 = DatabaseConnection("Server=A;Database=DB1");
    let _db2 = DatabaseConnection("Server=A;Database=DB2");
    // ...데이터베이스 연결을 사용하는 코드...
} // "db1"과 "db2"가 범위를 벗어나 여기서 Dispose를 호출합니다.

.NET에서 Dispose를 호출한 뒤 객체를 다시 사용하면 보통 실행 중에 ObjectDisposedException이 발생합니다. Rust는 같은 종류의 잘못된 사용을 컴파일할 때 차단합니다.

스레딩

Rust 표준 라이브러리는 스레딩, 동기화, 동시성을 지원합니다. 언어와 표준 라이브러리에 기본 기능이 있으며 crate는 더 많은 기능을 제공합니다. 이 안내서에서는 추가 crate를 다루지 않습니다.

다음 표는 .NET의 스레딩 타입과 메서드가 Rust에서 대략 무엇에 대응하는지 보여 줍니다.

.NETRust
Threadstd::thread::thread
Thread.Startstd::thread::spawn
Thread.Joinstd::thread::JoinHandle
Thread.Sleepstd::thread::sleep
ThreadPool해당 없음
Mutexstd::sync::Mutex
Semaphore해당 없음
Monitorstd::sync::Mutex
ReaderWriterLockstd::sync::RwLock
AutoResetEventstd::sync::Condvar
ManualResetEventstd::sync::Condvar
Barrierstd::sync::Barrier
CountdownEventstd::sync::Barrier
Interlockedstd::sync::atomic
Volatilestd::sync::atomic
ThreadLocalstd::thread_local

C#/.NET과 Rust에서는 비슷한 방식으로 스레드를 시작하고 완료될 때까지 기다립니다. 다음 C# 프로그램은 스레드를 만들고 그 스레드가 표준 출력에 문장을 출력한 뒤 종료되기를 기다립니다.

using System;
using System.Threading;

var thread = new Thread(() => Console.WriteLine("Hello from a thread!"));
thread.Start();
thread.Join(); // 스레드가 끝날 때까지 기다립니다.

Rust의 대응 코드는 다음과 같습니다.

use std::thread;

fn main() {
    let thread = thread::spawn(|| println!("Hello from a thread!"));
    thread.join().unwrap(); // 스레드가 끝날 때까지 기다립니다.
}

.NET에서는 스레드 객체의 생성과 초기화, 스레드 시작이 서로 다른 동작입니다. Rust의 thread::spawn은 두 동작을 함께 수행합니다.

.NET에서는 스레드에 인수로 데이터를 전달할 수 있습니다.

#nullable enable

using System;
using System.Text;
using System.Threading;

var t = new Thread(obj =>
{
    var data = (StringBuilder)obj!;
    data.Append(" World!");
});

var data = new StringBuilder("Hello");
t.Start(data);
t.Join();

Console.WriteLine($"Phrase: {data}");

클로저를 사용하면 더 간결하고 현대적인 C# 코드로 작성할 수 있습니다.

using System;
using System.Text;
using System.Threading;

var data = new StringBuilder("Hello");

var t = new Thread(obj => data.Append(" World!"));

t.Start();
t.Join();

Console.WriteLine($"Phrase: {data}");

Rust의 thread::spawn에는 같은 인수 전달 방식이 없습니다. 대신 클로저를 통해 스레드에 데이터를 전달합니다.

use std::thread;

fn main() {
    let data = String::from("Hello");
    let handle = thread::spawn(move || {
        let mut data = data;
        data.push_str(" World!");
        data
    });
    println!("Phrase: {}", handle.join().unwrap());
}

예제에서 확인할 점은 다음과 같습니다.

  • move 키워드로 data의 소유권을 스레드의 클로저로 _이동_해야 합니다. 그 뒤에는 main에서 원래 data 변수를 사용할 수 없습니다. 계속 사용하려면 값 타입의 지원 범위에 따라 복사하거나 복제합니다.

    Rust 1.63.0부터는 범위 지정 스레드로 정적 수명이 아닌 데이터와 이동하지 않은 값의 참조도 스레드에서 사용할 수 있습니다. 데이터가 스레드 종료까지 살아 있어야 하므로 범위가 끝나기 전에 스레드를 강제로 합류시킵니다.

  • Rust 스레드는 C#의 태스크처럼 값을 반환할 수 있으며 이 값은 join 메서드의 반환값이 됩니다.

  • C#에서도 Rust 예제처럼 클로저로 스레드에 데이터를 전달할 수 있습니다. C#은 소유권을 직접 관리하지 않습니다. 참조가 모두 사라지면 GC가 데이터의 메모리를 회수합니다.

동기화

여러 스레드가 데이터를 공유한다면 손상을 막기 위해 읽기와 쓰기 접근을 동기화합니다. C#에서는 lock 키워드를 동기화 기본 구문으로 제공합니다. .NET의 Monitor를 예외 상황에도 안전하게 사용하도록 펼쳐지는 구문입니다.

using System;
using System.Threading;

var dataLock = new object();
var data = 0;
var threads = new List<Thread>();

for (var i = 0; i < 10; i++)
{
    var thread = new Thread(() =>
    {
        for (var j = 0; j < 1000; j++)
        {
            lock (dataLock)
                data++;
        }
    });
    threads.Add(thread);
    thread.Start();
}

foreach (var thread in threads)
    thread.Join();

Console.WriteLine(data);

Rust에서는 Mutex 같은 동시성 자료구조를 명시적으로 사용합니다.

use std::thread;
use std::sync::{Arc, Mutex};

fn main() {
    let data = Arc::new(Mutex::new(0)); // (1)

    let mut threads = vec![];
    for _ in 0..10 {
        let data = Arc::clone(&data); // (2)
        let thread = thread::spawn(move || { // (3)
            for _ in 0..1000 {
                let mut data = data.lock().unwrap();
                *data += 1; // (4)
            }
        });
        threads.push(thread);
    }

    for thread in threads {
        thread.join().unwrap();
    }

    println!("{}", data.lock().unwrap());
}

예제에서 확인할 점은 다음과 같습니다.

  • 여러 스레드가 Mutex 인스턴스와 그 안의 데이터를 함께 소유하므로 Arc로 감쌉니다(1). Arc는 원자적 참조 횟수를 제공하며 복제할 때 증가하고(2) 드롭할 때 감소합니다. 횟수가 0이 되면 뮤텍스와 그 안의 데이터가 드롭됩니다. 메모리 관리 장에서 자세히 설명합니다.
  • 각 스레드의 클로저는 복제된 참조(2)의 소유권을 받습니다(3).
  • *data += 1(4)은 포인터 접근처럼 보이지만 안전하지 않은 포인터 조작이 아닙니다. 뮤텍스 가드가 감싼 데이터를 갱신합니다.

C# 예제는 lock 문을 제거하면 스레드 안전하지 않은 코드가 될 수 있습니다. Rust 예제는 일부 코드를 제거하는 등 스레드 안전성을 해치는 변경을 하면 컴파일을 거부합니다. C#/.NET에서는 개발자가 동기화 자료구조를 주의해서 사용해야 하며 Rust에서는 컴파일러의 검사를 받을 수 있다는 차이를 보여 줍니다.

Rust 컴파일러가 이를 검사할 수 있는 이유는 자료구조에 Sync, Send라는 특별한 트레이트가 적용되기 때문입니다. Sync는 타입 인스턴스의 참조를 스레드 사이에서 공유해도 안전함을 뜻합니다. Send는 인스턴스를 다른 스레드로 보내거나 이동해도 안전함을 뜻합니다. 자세한 내용은 Rust 책의 두려움 없는 동시성 장에서 확인할 수 있습니다.

생산자와 소비자

생산자와 소비자 패턴은 스레드 사이에서 작업을 분배할 때 자주 사용합니다. 생산자 스레드가 소비자 스레드에 데이터를 전달하므로 데이터를 공유하거나 잠글 필요가 없습니다. .NET은 이 패턴을 폭넓게 지원합니다. 기본적인 예로 System.Collections.Concurrent의 BlockingCollection을 사용하는 C# 코드를 살펴보겠습니다.

using System;
using System.Threading;
using System.Collections.Concurrent;

var messages = new BlockingCollection<string>();
var producer = new Thread(() =>
{
    for (var n = 1; i < 10; i++)
        messages.Add($"Message #{n}");
    messages.CompleteAdding();
});

producer.Start();

// 여기서는 메인 스레드가 소비자입니다.
foreach (var message in messages.GetConsumingEnumerable())
    Console.WriteLine(message);

producer.Join();

Rust에서는 _채널_로 같은 동작을 구현할 수 있습니다. 표준 라이브러리의 mpsc::channel은 생산자 여럿과 소비자 하나를 지원합니다. 위 C# 예제를 Rust로 옮기면 대략 다음과 같습니다.

use std::thread;
use std::sync::mpsc;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    let producer = thread::spawn(move || {
        for n in 1..10 {
            tx.send(format!("Message #{}", n)).unwrap();
        }
    });

    // 여기서는 메인 스레드가 소비자입니다.
    for received in rx {
        println!("{}", received);
    }

    producer.join().unwrap();
}

.NET에도 System.Threading.Channels 네임스페이스에 채널이 있습니다. 주로 async와 await를 사용하는 태스크 및 비동기 프로그래밍을 위해 설계되었습니다. Rust에서 비동기 코드에 적합한 채널은 Tokio 런타임이 제공합니다.

테스트

테스트 구성

.NET 솔루션은 xUnit, NUnit, MSTest 등 어떤 프레임워크를 쓰는지와 단위 테스트인지 통합 테스트인지에 관계없이 보통 테스트 코드를 별도 프로젝트에 둡니다. 따라서 테스트할 애플리케이션이나 라이브러리와 테스트 코드는 서로 다른 어셈블리에 들어갑니다.

Rust에서는 _단위 테스트_를 관례적으로 tests라는 하위 모듈에 작성합니다. 이 모듈은 테스트 대상 애플리케이션 또는 라이브러리 모듈과 같은 _소스 파일_에 놓입니다. 이 구성에는 두 가지 장점이 있습니다.

  • 구현 코드와 단위 테스트를 나란히 배치
  • 하위 모듈이 내부 항목에 접근할 수 있으므로 .NET의 [InternalsVisibleTo] 같은 우회 수단이 불필요

테스트 하위 모듈에는 #[cfg(test)] 특성을 붙입니다. 그러면 cargo test를 실행할 때만 전체 모듈을 조건부로 컴파일하고 실행합니다. 하위 모듈의 테스트 함수에는 #[test] 특성을 붙입니다.

통합 테스트는 보통 소스가 있는 src 디렉터리와 나란한 tests 디렉터리에 둡니다. cargo test는 그 디렉터리의 파일을 각각 별도 crate로 컴파일하고 #[test]가 붙은 메서드를 실행합니다. tests 디렉터리의 파일은 통합 테스트로 인식하므로 모듈에 #[cfg(test)]를 붙이지 않아도 됩니다.

관련 자료:

테스트 실행

Rust에서 dotnet test에 대응하는 명령은 cargo test입니다.

cargo test는 기본적으로 모든 테스트를 병렬로 실행합니다. 스레드 하나로 순차 실행하려면 다음 명령을 사용합니다.

cargo test -- --test-threads=1

자세한 내용은 테스트 병렬 또는 순차 실행 절에서 확인할 수 있습니다.

테스트 출력

복잡한 통합 테스트나 종단 간 테스트에서는 실행 중 일어난 일을 기록하기도 합니다. .NET에서는 프레임워크마다 방법이 다릅니다. 예를 들어 NUnit은 Console.WriteLine을 사용하고 xUnit은 ITestOutputHelper를 사용합니다. Rust에서는 NUnit과 비슷하게 println!으로 표준 출력에 기록합니다. 테스트 중 캡처한 출력은 기본적으로 보이지 않습니다. 테스트 프로그램에 --show-output을 전달하면 성공한 테스트의 출력도 볼 수 있습니다.

cargo test -- --show-output

자세한 내용은 함수 출력 표시 절에서 확인할 수 있습니다.

단언

.NET에서는 테스트 프레임워크에 따라 다양한 단언 방법을 사용합니다. 다음은 xUnit.net의 단언 예제입니다.

[Fact]
public void Something_Is_The_Right_Length()
{
    var value = "something";
    Assert.Equal(9, value.Length);
}

Rust에서는 별도의 테스트 프레임워크나 crate가 없어도 됩니다. 표준 라이브러리가 대부분의 테스트에 쓸 수 있는 단언 _매크로_를 제공합니다.

다음 예제에서는 assert_eq를 사용합니다.

#[test]
fn something_is_the_right_length() {
    let value = "something";
    assert_eq!(9, value.len());
}

Rust 표준 라이브러리에는 xUnit.net의 [Theory] 같은 데이터 기반 테스트 기능이 없습니다.

모의 객체

.NET 애플리케이션이나 라이브러리를 테스트할 때 Moq, NSubstitute 같은 프레임워크로 타입의 종속성을 대체할 수 있습니다. Rust에도 mockall 같은 crate가 있습니다. 외부 crate 없이 cfg 특성에 기반한 조건부 컴파일로 간단히 대체할 수도 있습니다. cfg는 test 같은 구성 기호에 따라 표시된 코드를 조건부로 포함합니다. DEBUG에 따라 디버그 빌드용 코드를 컴파일하는 것과 비슷합니다. 이 방식은 모듈의 모든 테스트가 하나의 구현만 사용할 수 있다는 제약이 있습니다.

#[cfg(test)]를 붙인 코드는 cargo test를 실행할 때만 컴파일하고 실행합니다. 내부적으로는 rustc --test로 컴파일합니다. 반대로 #[cfg(not(test))]가 붙은 코드는 cargo test로 테스트하지 않을 때만 포함합니다.

다음 예제는 환경 변수 값을 읽어 반환하는 표준 라이브러리의 var_os 함수를 대체합니다. get_env가 사용하는 var_os의 모의 버전을 조건부로 가져옵니다. cargo build나 cargo run 에서는 std::env::var_os를 사용합니다. cargo test에서는 tests::var_os_mock을 var_os라는 이름으로 가져와 테스트 중에 모의 구현을 사용합니다.

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.

/// 환경 변수를 읽어 설정되어 있으면 값을 반환하는 유틸리티 함수입니다.
/// 값이 유효한 유니코드가 아니면 패닉이 발생합니다.
pub fn get_env(key: &str) -> Option<String> {
    #[cfg(not(test))]                 // 일반 빌드에서는...
    use std::env::var_os;             // ...표준 라이브러리에서 가져옵니다.
    #[cfg(test)]                      // 테스트 빌드에서는...
    use tests::var_os_mock as var_os; // ...테스트 하위 모듈에서 모의 구현을 가져옵니다.

    let val = var_os(key);
    val.map(|s| s.to_str()     // 문자열 슬라이스를 가져옵니다.
                 .unwrap()     // 유효한 유니코드가 아니면 패닉이 발생합니다.
                 .to_owned())  // "String"으로 변환합니다.
}

#[cfg(test)]
mod tests {
    use std::ffi::*;
    use super::*;

    pub(crate) fn var_os_mock(key: &str) -> Option<OsString> {
        match key {
            "FOO" => Some("BAR".into()),
            _ => None
        }
    }

    #[test]
    fn get_env_when_var_undefined_returns_none() {
        assert_eq!(None, get_env("???"));
    }

    #[test]
    fn get_env_when_var_defined_returns_some_value() {
        assert_eq!(Some("BAR".to_owned()), get_env("FOO"));
    }
}

코드 적용 범위

.NET에는 테스트 코드 적용 범위를 분석하는 도구가 다양합니다. Visual Studio는 도구를 내장하고 Visual Studio Code에는 확장이 있습니다. .NET 개발자는 coverlet에도 익숙할 수 있습니다.

Rust는 테스트 코드 적용 범위를 수집할 내장 기능을 제공합니다. 추가 분석 도구도 있지만 통합 방식이 완전히 자동화되어 있지는 않습니다. 몇 가지 수동 단계를 거치면 결과를 시각적으로 확인할 수 있습니다.

Visual Studio Code의 Coverage Gutters 확장과 Tarpaulin을 함께 사용하면 코드 적용 범위를 편집기에서 볼 수 있습니다. Coverage Gutters에는 LCOV 파일이 필요합니다. 이 파일은 Tarpaulin 이외의 도구로도 만들 수 있습니다.

설정을 마친 뒤 다음 명령을 실행합니다.

cargo tarpaulin --ignore-tests --out Lcov

이 명령은 LCOV 코드 적용 범위 파일을 만듭니다. Coverage Gutters: Watch를 켜면 확장이 파일을 읽고 소스 편집기에 줄별 적용 범위를 표시합니다.

LCOV 파일의 위치가 중요합니다. 여러 패키지가 있는 프로젝트 구조에서 --workspace로 루트에 LCOV 파일을 만들면 패키지 루트에 별도 파일이 있더라도 워크스페이스 루트의 파일을 사용합니다. 루트 전체의 파일을 만드는 것보다 테스트 대상 패키지만 분리해 실행하는 편이 더 빠릅니다.

벤치마킹

Rust에서는 cargo bench로 벤치마크를 실행합니다. 이 Cargo 명령은 #[bench] 특성이 붙은 메서드를 실행합니다. 원문 작성 당시 이 특성은 불안정 기능이며 nightly 채널에서만 사용할 수 있었습니다.

.NET에서는 BenchmarkDotNet 라이브러리로 메서드 성능을 측정하고 추적할 수 있습니다. Rust에서 비슷한 역할을 하는 crate는 Criterion입니다.

Criterion 문서에 따르면 실행 간 통계 정보를 수집하고 저장합니다. 성능 저하를 자동으로 감지하고 최적화 결과를 측정할 수 있습니다.

Criterion은 #[bench] 특성 대신 criterion_group!과 criterion_main! 매크로를 사용합니다. Criterion 시작하기 문서에 나온 설정으로 안정 채널에서도 벤치마크를 실행할 수 있습니다.

BenchmarkDotNet처럼 벤치마크 결과를 지속적 벤치마킹용 GitHub Action과 통합할 수도 있습니다. Criterion은 여러 출력 형식을 지원합니다. 그중 bencher 형식은 nightly의 libtest 벤치마크 형식을 모방하며 해당 Action과 호환됩니다.

로깅과 추적

.NET은 여러 로깅 API를 제공합니다. ILogger는 내장 로깅 공급자와 타사 공급자를 폭넓게 지원하므로 대부분의 경우 기본 선택으로 사용할 수 있습니다. 다음은 C#의 구조화된 로깅 예제입니다.

using Microsoft.Extensions.Logging;

using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
var logger = loggerFactory.CreateLogger<Program>();
logger.LogInformation("Hello {Day}.", "Thursday"); // Hello Thursday.

Rust의 log crate는 가벼운 로깅 파사드를 제공합니다. ILogger보다 기능이 적고 원문 작성 당시 안정적인 구조화 로깅이나 로깅 범위를 제공하지 않았습니다.

.NET과 더 비슷한 기능이 필요하다면 Tokio의 tracing을 사용할 수 있습니다. tracing은 Rust 애플리케이션을 계측해 구조화된 이벤트 기반 진단 정보를 수집하는 프레임워크입니다. tracing_subscriber로 tracing 구독자를 구현하고 조합할 수 있습니다. 앞의 구조화 로깅을 이 두 crate로 작성하면 다음과 같습니다.

fn main() {
    // 전역 기본("console") 수집기를 설치합니다.
    tracing_subscriber::fmt().init();
    tracing::info!("Hello {Day}.", Day = "Thursday"); // Hello Thursday.
}

OpenTelemetry는 규격에 따라 원격 측정 데이터를 계측, 생성, 수집, 내보내기 위한 도구와 API, SDK를 제공합니다. 원문 작성 당시 OpenTelemetry Logging API는 안정화되지 않았고 Rust 구현은 로깅을 지원하지 않았지만 추적 API는 지원했습니다.

조건부 컴파일

.NET과 Rust는 모두 외부 조건에 따라 특정 코드를 컴파일할 수 있습니다.

.NET에서는 전처리기 지시문으로 조건부 컴파일을 제어합니다.

#if debug
    Console.WriteLine("Debug");
#else
    Console.WriteLine("Not debug");
#endif

미리 정의된 기호 외에도 컴파일러 옵션인 _DefineConstants_로 기호를 정의할 수 있습니다. 이 기호를 #if, #else, #elif, #endif와 함께 사용하여 소스 파일을 조건에 따라 컴파일합니다.

Rust에서는 cfg 특성, cfg_attr 특성, cfg! 매크로로 조건부 컴파일을 제어합니다. .NET과 마찬가지로 컴파일러 플래그 --cfg를 사용하여 구성 옵션을 직접 설정할 수도 있습니다.

cfg 특성은 구성 조건식(ConfigurationPredicate)을 평가하여 코드의 포함 여부를 결정합니다.

use std::fmt::{Display, Formatter};

struct MyStruct;

// 운영체제가 Unix이고 foo가 bar가 아닐 때만 이 Display 구현을 포함합니다.
// Linux에서는 'rustc main.rs --cfg foo=\"baz\"'로 이 버전을 컴파일할 수 있습니다.
#[cfg(all(unix, not(foo = "bar")))]
impl Display for MyStruct {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        f.write_str("Running without foo=bar configuration")
    }
}

// unix와 foo=bar가 모두 정의되었을 때만 이 함수를 포함합니다.
// Linux에서는 'rustc main.rs --cfg foo=\"bar\"'로 이 버전을 컴파일할 수 있습니다.
#[cfg(all(unix, foo = "bar"))]
impl Display for MyStruct {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        f.write_str("Running with foo=bar configuration")
    }
}

// Unix용으로 컴파일하지 않으면 이 함수는 패닉을 일으킵니다.
// Windows에서는 'rustc main.rs'로 이 버전을 컴파일할 수 있습니다.
#[cfg(not(unix))]
impl Display for MyStruct {
    fn fmt(&self, _f: &mut Formatter<'_>) -> std::fmt::Result {
        panic!()
    }
}

fn main() {
    println!("{}", MyStruct);
}

cfg_attr 특성은 구성 조건식에 따라 다른 특성을 적용합니다.

#[cfg_attr(feature = "serialization_support", derive(Serialize, Deserialize))]
pub struct MaybeSerializableStruct;

// `serialization_support` 기능을 활성화하면 위 코드는 다음과 같이 확장됩니다.
// #[derive(Serialize, Deserialize)]
// pub struct MaybeSerializableStruct;

내장 cfg! 매크로는 하나의 구성 조건식을 받아 참이면 true, 거짓이면 false로 평가합니다.

if cfg!(unix) {
  println!("I'm running on a unix machine!");
}

관련 내용은 조건부 컴파일 문서에서 확인할 수 있습니다.

기능 플래그

조건부 컴파일은 선택적 의존성을 제공할 때도 유용합니다. Cargo에서는 패키지의 Cargo.toml 파일에 있는 [features] 테이블에 기능 이름을 정의합니다. 각 기능은 활성화하거나 비활성화할 수 있습니다. 빌드할 패키지의 기능은 --features 같은 명령줄 옵션으로 활성화합니다. 의존 패키지의 기능은 Cargo.toml의 의존성 선언에서 활성화합니다.

자세한 내용은 Cargo의 기능 플래그 문서를 참고할 수 있습니다.

환경 변수와 구성

환경 변수에 접근하기

.NET에서는 System.Environment.GetEnvironmentVariable 메서드로 실행 중인 프로세스의 환경 변수 값을 읽습니다.

using System;

const string name = "EXAMPLE_VARIABLE";

var value = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrEmpty(value))
    Console.WriteLine($"Variable '{name}' not set.");
else
    Console.WriteLine($"Variable '{name}' set to '{value}'.");

Rust에서는 std::env 모듈의 var와 var_os 함수로 실행 중에 환경 변수에 접근합니다.

var는 Result<String, VarError>를 반환합니다. 환경 변수가 설정되어 있으면 값을 반환하고, 설정되지 않았거나 유효한 유니코드가 아니면 오류를 반환합니다.

var_os는 Option<OsString>을 반환합니다. 변수가 설정되어 있으면 Some 값을, 설정되지 않았으면 None을 반환합니다. OsString은 유효한 유니코드일 필요가 없습니다.

use std::env;


fn main() {
    let key = "ExampleVariable";
    match env::var(key) {
        Ok(val) => println!("{key}: {val:?}"),
        Err(e) => println!("couldn't interpret {key}: {e}"),
    }
}
use std::env;

fn main() {
    let key = "ExampleVariable";
    match env::var_os(key) {
        Some(val) => println!("{key}: {val:?}"),
        None => println!("{key} not defined in the environment"),
    }
}

Rust에서는 컴파일 시점에도 환경 변수에 접근할 수 있습니다. env! 매크로는 컴파일할 때 환경 변수 값을 &'static str로 확장합니다. 해당 변수가 설정되지 않았다면 컴파일 오류가 발생합니다.

use std::env;

fn main() {
    let example = env!("ExampleVariable");
    println!("{example}");
}

.NET에서도 소스 생성기를 활용하면 컴파일 시점에 환경 변수에 접근할 수 있습니다. 다만 Rust의 env!처럼 직접 사용하는 방식은 아닙니다.

구성

.NET에서는 구성 공급자로 설정 값을 읽습니다. 프레임워크는 Microsoft.Extensions.Configuration 네임스페이스와 NuGet 패키지를 통해 여러 공급자 구현을 제공합니다.

구성 공급자는 다양한 원천의 키와 값 쌍을 읽고 IConfiguration을 통해 통합된 설정을 제공합니다.

using Microsoft.Extensions.Configuration;

class Example {
    static void Main()
    {
        IConfiguration configuration = new ConfigurationBuilder()
            .AddEnvironmentVariables()
            .Build();

        var example = configuration.GetValue<string>("ExampleVar");

        Console.WriteLine(example);
    }
}

다른 공급자는 공식 .NET 구성 공급자 문서에서 살펴볼 수 있습니다.

Rust에서는 figment나 config 같은 외부 크레이트를 사용해 비슷한 방식으로 구성을 관리할 수 있습니다. 다음 예제는 config 크레이트를 사용합니다.

use config::{Config, Environment};

fn main() {
    let builder = Config::builder().add_source(Environment::default());

    match builder.build() {
        Ok(config) => {
            match config.get_string("examplevar") {
                Ok(v) => println!("{v}"),
                Err(e) => println!("{e}")
            }
        },
        Err(_) => {
            // 오류가 발생했습니다.
        }
    }
}

LINQ

이 장에서는 시퀀스(IEnumerable/IEnumerable<T>)를 조회하거나 변환할 때 사용하는 LINQ를 다룹니다. 리스트, 집합, 딕셔너리 같은 컬렉션도 포함합니다.

IEnumerable<T>

Rust에서 IEnumerable<T>에 대응하는 트레이트는 IntoIterator입니다. .NET에서 IEnumerable<T>.GetEnumerator()가 IEnumerator<T>를 반환하듯, Rust의 IntoIterator::into_iter는 Iterator를 반환합니다. 두 언어 모두 반복 가능한 컨테이너의 항목을 순회하는 간단한 구문을 제공합니다. C#에서는 foreach를 사용합니다.

using System;
using System.Text;

var values = new[] { 1, 2, 3, 4, 5 };
var output = new StringBuilder();

foreach (var value in values)
{
    if (output.Length > 0)
        output.Append(", ");
    output.Append(value);
}

Console.Write(output); // 출력: 1, 2, 3, 4, 5

Rust에서는 for를 사용합니다.

use std::fmt::Write;

fn main() {
    let values = [1, 2, 3, 4, 5];
    let mut output = String::new();

    for value in values {
        if output.len() > 0 {
            output.push_str(", ");
        }
        // ! 쓰기 오류를 무시합니다.
        _ = write!(output, "{value}");
    }

    println!("{output}");  // 출력: 1, 2, 3, 4, 5
}

Rust의 for 반복문은 대략 다음 코드로 변환됩니다.

use std::fmt::Write;

fn main() {
    let values = [1, 2, 3, 4, 5];
    let mut output = String::new();

    let mut iter = values.into_iter();      // 반복자를 가져옵니다.
    while let Some(value) = iter.next() {   // 항목이 남아 있는 동안 반복합니다.
        if output.len() > 0 {
            output.push_str(", ");
        }
        _ = write!(output, "{value}");
    }

    println!("{output}");
}

Rust에서는 순회할 때도 소유권과 데이터 경합 방지 규칙이 적용됩니다. 배열을 순회하는 모습은 C#과 비슷해 보이지만 같은 컬렉션을 두 번 이상 순회하려면 소유권을 고려해야 합니다. 다음 예제는 정수 벡터를 두 번 순회하여 합계와 최댓값을 각각 출력하려고 합니다.

fn main() {
    let values = vec![1, 2, 3, 4, 5];

    // 모든 값을 더합니다.

    let mut sum = 0;
    for value in values {
        sum += value;
    }
    println!("sum = {sum}");

    // 최댓값을 구합니다.

    let mut max = None;
    for value in values {
        if let Some(some_max) = max { // max가 정의되어 있으면
            if value > some_max {     // value가 더 크면
                max = Some(value)     // 새 최댓값을 기록합니다.
            }
        } else {                      // 반복 시작 시 max는 정의되지 않았으므로
            max = Some(value)         // 첫 번째 값으로 설정합니다.
        }
    }
    println!("max = {max:?}");
}

하지만 이 코드는 컴파일되지 않습니다. values가 배열 대신 Vec<i32>로 선언되어 있기 때문입니다. 벡터는 .NET의 List<T>처럼 크기를 늘릴 수 있는 Rust의 컬렉션입니다. 첫 번째 반복문에서 벡터의 항목을 순회 변수 value로 옮기면서 벡터를 소비합니다. 각 value는 반복이 끝날 때 범위를 벗어나 해제됩니다. 항목이 힙 메모리를 소유한다면 해당 메모리도 함께 해제됩니다. 이를 해결하려면 for 반복문에서 &values를 사용해 항목에 대한 공유 참조를 순회합니다. 그러면 value가 항목의 소유권을 가져오지 않습니다.

다음은 각 for 반복문의 values를 &values로 바꾼 코드입니다. 이제 컴파일할 수 있습니다.

fn main() {
    let values = vec![1, 2, 3, 4, 5];

    // 모든 값을 더합니다.

    let mut sum = 0;
    for value in &values {
        sum += value;
    }
    println!("sum = {sum}");

    // 최댓값을 구합니다.

    let mut max = None;
    for value in &values {
        if let Some(some_max) = max { // max가 정의되어 있으면
            if value > some_max {     // value가 더 크면
                max = Some(value)     // 새 최댓값을 기록합니다.
            }
        } else {                      // 반복 시작 시 max는 정의되지 않았으므로
            max = Some(value)         // 첫 번째 값으로 설정합니다.
        }
    }
    println!("max = {max:?}");
}

벡터 대신 배열을 사용해도 소유권 이전과 값의 해제를 관찰할 수 있습니다. 다음 예제는 정수를 감싼 Int 구조체의 배열을 순회하며 합계를 계산합니다.

#[derive(Debug)]
struct Int(i32);

impl Drop for Int {
    fn drop(&mut self) {
        println!("{:?} dropped", self)
    }
}

fn main() {
    let values = [Int(1), Int(2), Int(3), Int(4), Int(5)];
    let mut sum = 0;

    for value in values {
        println!("value = {:?}", value);
        sum += value.0;
    }

    println!("sum = {sum}");
}

Int는 Drop을 구현하여 인스턴스가 해제될 때 메시지를 출력합니다. 위 코드를 실행하면 다음 결과를 얻습니다.

value = Int(1)
Int(1) dropped
value = Int(2)
Int(2) dropped
value = Int(3)
Int(3) dropped
value = Int(4)
Int(4) dropped
value = Int(5)
Int(5) dropped
sum = 15

반복문이 각 값을 가져오고 해제한 뒤 합계를 출력합니다. 반복문에서 values를 &values로 바꾸면 다음과 같습니다.

for value in &values {
    // ...
}

출력 순서도 달라집니다.

value = Int(1)
value = Int(2)
value = Int(3)
value = Int(4)
value = Int(5)
sum = 15
Int(1) dropped
Int(2) dropped
Int(3) dropped
Int(4) dropped
Int(5) dropped

이번에는 반복 변수가 항목의 소유권을 가져오지 않으므로 순회 중에는 값이 해제되지 않습니다. 반복이 끝나고 합계를 출력한 뒤 main의 끝에서 values 배열이 범위를 벗어나면 모든 Int 인스턴스가 해제됩니다.

이 예제처럼 Rust와 C#의 반복 구문과 추상화는 비슷하지만 소유권에 따른 차이가 있습니다. 이 차이로 인해 Rust 컴파일러가 일부 반복 코드를 거부할 수 있습니다.

관련 내용은 Iterator와 참조를 통한 순회 문서를 참고할 수 있습니다.

연산자

LINQ의 _연산자_는 연결해서 사용할 수 있는 C# 확장 메서드입니다. 일반적으로 데이터 원본에 대한 조회를 구성합니다. C#은 from, where, select, join 같은 절을 사용하는 SQL 형태의 _쿼리 구문_도 제공합니다. 쿼리 구문은 메서드 연결을 대신하거나 함께 사용할 수 있습니다. 많은 명령형 반복문을 표현력과 조합성이 높은 LINQ 쿼리로 바꿀 수 있습니다.

Rust에는 C#의 쿼리 구문에 대응하는 구문이 없습니다. 대신 반복 가능한 타입에 적용하는 메서드, 즉 _어댑터_가 있으며 C#의 메서드 연결과 비슷하게 사용할 수 있습니다. C#에서는 명령형 반복문을 LINQ로 바꿀 때 표현력과 조합성이 좋아지는 대신 성능을 고려할 수 있습니다. 계산량이 많은 명령형 반복문은 JIT 최적화를 활용하고 간접 호출 횟수를 줄일 수 있어 보통 더 빠릅니다. Rust에서는 반복자 메서드를 연결하는 코드와 직접 작성한 명령형 반복문 사이에 이러한 성능 차이가 일반적으로 발생하지 않습니다. 따라서 반복자 메서드를 연결하는 코드를 자주 볼 수 있습니다.

다음 표는 자주 쓰는 LINQ 메서드와 Rust의 대략적인 대응 메서드를 보여 줍니다.

.NETRust비고
Aggregatereduce주 1
Aggregatefold주 1
Allall
Anyany
Concatchain
Countcount
ElementAtnth
GroupBy-
Lastlast
Maxmax
Maxmax_by
MaxBymax_by_key
Minmin
Minmin_by
MinBymin_by_key
Reverserev
Selectmap
Selectenumerate
SelectManyflat_map
SelectManyflatten
SequenceEqualeq
Single-주 3
SingleOrDefault-주 3
Skipskip
SkipWhileskip_while
Sumsum
Taketake
TakeWhiletake_while
ToArraycollect주 2
ToDictionarycollect주 2
ToListcollect주 2
Wherefilter
Zipzip
  1. 초기값을 받지 않는 Aggregate 오버로드는 reduce에, 초기값을 받는 오버로드는 fold에 대응합니다.
  2. Rust의 collect는 일반적으로 반복자로부터 값을 생성할 수 있는 모든 타입, 즉 FromIterator를 구현한 타입에 사용할 수 있습니다. collect는 대상 타입이 필요합니다. 컴파일러가 이를 추론하지 못하면 collect::<Vec<_>>()처럼 터보피시(::<>) 구문으로 타입을 지정합니다. 이 때문에 표에서는 열거 가능한 값을 컬렉션으로 변환하는 여러 LINQ 메서드에 collect를 대응시켰습니다.
  3. Single과 SingleOrDefault는 결과가 둘 이상인지 검사합니다. Rust의 find는 첫 번째 일치 항목만 반환하며 유일성은 검사하지 않습니다. 원문 표의 find와 try_find 대응 관계는 이 차이를 반영하지 못하므로 직접 대응 메서드를 표시하지 않았습니다.

다음은 C#에서 시퀀스를 변환하는 예제입니다.

var result =
    Enumerable.Range(0, 10)
              .Where(x => x % 2 == 0)
              .SelectMany(x => Enumerable.Range(0, x))
              .Aggregate(0, (acc, x) => acc + x);

Console.WriteLine(result); // 50

Rust에서도 비슷하게 작성할 수 있습니다.

let result = (0..10)
    .filter(|x| x % 2 == 0)
    .flat_map(|x| (0..x))
    .fold(0, |acc, x| acc + x);

println!("{result}"); // 50

지연 실행

LINQ의 많은 연산자는 결과가 실제로 필요할 때 작업을 수행하도록 설계되었습니다. 이를 통해 여러 작업을 연결할 때 불필요한 계산을 피할 수 있습니다. 예를 들어 LINQ 연산자가 IEnumerable<T>를 반환해도 순회를 시작하기 전에는 T 항목을 생성하거나 계산하지 않을 수 있습니다. 이를 _지연 실행_이라고 합니다. 순회가 시작될 때 모든 항목을 만드는 대신 각 항목에 도달할 때마다 계산하면 결과를 _스트리밍_한다고 합니다.

Rust 반복자도 같은 지연 평가와 스트리밍 개념을 지원합니다.

두 언어 모두 이러한 특성으로 _무한 시퀀스_를 표현할 수 있습니다. 원본 시퀀스는 끝이 없지만 개발자가 필요한 만큼만 소비합니다. 다음은 C# 예제입니다.

foreach (var x in InfiniteRange().Take(5))
    Console.Write($"{x} "); // "0 1 2 3 4"를 출력합니다.

IEnumerable<int> InfiniteRange()
{
    for (var i = 0; ; ++i)
        yield return i;
}

Rust에서는 무한 범위로 같은 개념을 표현합니다.

// 원문 작성 당시 Rust의 제너레이터와 yield는 불안정 기능이므로
// 이 예제에서는 대신 Range를 사용합니다.
// https://doc.rust-lang.org/std/ops/struct.Range.html

for value in (0..).take(5) {
    print!("{value} "); // "0 1 2 3 4"를 출력합니다.
}

반복자 메서드(yield)

C#의 yield 키워드를 사용하면 _반복자 메서드_를 간단히 작성할 수 있습니다. 반복자 메서드는 IEnumerable<T> 또는 IEnumerator<T>를 반환합니다. 컴파일러가 메서드 본문을 반환 타입의 구체적인 구현으로 변환하므로 개발자가 매번 클래스를 직접 작성하지 않아도 됩니다. 원문 작성 당시 Rust의 대응 기능인 코루틴은 불안정 기능으로 분류되었습니다.

메타프로그래밍

메타프로그래밍은 다른 코드를 작성하거나 생성하는 코드를 만드는 기법입니다.

C#에서는 .NET 5부터 Roslyn의 소스 생성기를 메타프로그래밍에 사용할 수 있습니다. 소스 생성기는 빌드 중 C# 소스 파일을 만들고 이를 사용자 코드의 컴파일에 추가합니다. 소스 생성기가 도입되기 전에도 Visual Studio는 T4 텍스트 템플릿을 통한 코드 생성을 지원했습니다. 템플릿 예제와 생성 결과에서 작동 방식을 살펴볼 수 있습니다.

Rust는 매크로로 메타프로그래밍을 지원합니다. 매크로는 선언적 매크로와 절차적 매크로로 나뉩니다.

선언적 매크로는 입력 코드를 패턴과 비교하고 일치하는 패턴에 대응하는 코드를 생성합니다. 다음은 println!("Some text")처럼 호출할 수 있는 println! 매크로의 정의입니다.

macro_rules! println {
    () => {
        $crate::print!("\n")
    };
    ($($arg:tt)*) => {{
        $crate::io::_print($crate::format_args_nl!($($arg)*));
    }};
}

선언적 매크로 작성 방법은 Rust 레퍼런스의 예제 기반 매크로와 The Little Book of Rust Macros에서 자세히 다룹니다.

절차적 매크로는 코드를 입력받아 처리한 뒤 새로운 코드를 출력합니다.

C#에서 메타프로그래밍에 사용하는 또 다른 기법으로 리플렉션이 있습니다. Rust는 리플렉션을 지원하지 않습니다.

함수형 매크로

함수형 매크로는 function!(...) 형태로 호출합니다. 다음 예제의 print_something 매크로는 "Something"을 출력하는 print_it 함수를 생성합니다.

lib.rs에 다음 코드를 작성합니다.

extern crate proc_macro;
use proc_macro::TokenStream;

#[proc_macro]
pub fn print_something(_item: TokenStream) -> TokenStream {
    "fn print_it() { println!(\"Something\") }".parse().unwrap()
}

main.rs에서는 다음처럼 매크로를 사용합니다.

use replace_crate_name_here::print_something;
print_something!();

fn main() {
    print_it();
}

파생 매크로

파생 매크로는 구조체, 열거형, 공용체의 토큰 스트림을 받아 새 항목을 생성할 수 있습니다. #[derive(Clone)]은 입력 타입이 Clone 트레이트를 구현하는 데 필요한 코드를 생성하는 파생 매크로의 예입니다.

사용자 정의 파생 매크로의 작성 방법은 Rust 레퍼런스의 파생 매크로 절을 참고할 수 있습니다.

특성 매크로

특성 매크로는 Rust 항목에 붙일 수 있는 새로운 특성을 정의합니다. Tokio로 비동기 코드를 작성할 때는 다음처럼 #[tokio::main] 특성 매크로를 비동기 진입점에 붙일 수 있습니다.

#[tokio::main]
async fn main() {
    println!("Hello world");
}

사용자 정의 특성 매크로의 작성 방법은 Rust 레퍼런스의 특성 매크로 절을 참고할 수 있습니다.

비동기 프로그래밍

.NET과 Rust는 사용 방식이 비슷한 비동기 프로그래밍 모델을 지원합니다. 먼저 C# 비동기 코드의 기본 형태를 살펴보겠습니다.

async Task<string> PrintDelayed(string message, CancellationToken cancellationToken)
{
    await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
    return $"Message: {message}";
}

Rust 코드도 비슷하게 구성합니다. 다음 예제는 sleep을 구현하기 위해 async-std를 사용합니다.

use std::time::Duration;
use async_std::task::sleep;

async fn format_delayed(message: &str) -> String {
    sleep(Duration::from_secs(1)).await;
    format!("Message: {}", message)
}
  1. Rust의 async 키워드는 코드 블록을 Future 트레이트를 구현하는 상태 기계로 바꿉니다. C# 컴파일러가 async 코드를 상태 기계로 바꾸는 방식과 비슷합니다. 두 언어 모두 이 방식으로 비동기 코드를 순차적인 형태로 작성할 수 있습니다.
  2. Rust와 C# 모두 비동기 함수나 메서드 앞에 async를 붙이지만 반환 타입 표기는 다릅니다. C# 비동기 메서드는 Task<T>나 ValueTask<T>처럼 실제 반환 타입을 명시합니다. Rust 비동기 함수는 String 같은 내부 결과 타입을 명시합니다. 함수가 반환하는 값은 항상 Future를 구현하는 타입이기 때문입니다.
  3. await의 위치도 다릅니다. C#에서는 표현식 앞에 await를 붙이고 Rust에서는 표현식 뒤에 .await를 붙입니다. Rust의 .await는 메서드가 아니지만 메서드 호출을 이어 쓰는 구문과 잘 어울립니다.

관련 내용은 Rust 비동기 프로그래밍 문서에서 확인할 수 있습니다.

작업 실행

다음 C# 예제에서는 PrintDelayed의 반환 작업을 await하지 않아도 메서드가 실행됩니다.

var cancellationToken = CancellationToken.None;
PrintDelayed("message", cancellationToken); // 1초 뒤 "message"를 출력합니다.
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);

async Task PrintDelayed(string message, CancellationToken cancellationToken)
{
    await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
    Console.WriteLine(message);
}

Rust에서 같은 방식으로 함수를 호출하면 아무것도 출력하지 않습니다.

use async_std::task::sleep;
use std::time::Duration;

#[tokio::main] // 비동기 main 함수를 실행하기 위해 사용합니다.
async fn main() {
    print_delayed("message"); // 아무것도 출력하지 않습니다.
    sleep(Duration::from_secs(2)).await;
}

async fn print_delayed(message: &str) {
    sleep(Duration::from_secs(1)).await;
    println!("{}", message);
}

Rust의 퓨처는 지연 실행되기 때문입니다. 실행하기 전까지 아무 작업도 수행하지 않습니다. Future를 실행하는 일반적인 방법은 .await를 사용하는 것입니다. .await는 퓨처를 완료할 때까지 실행하려고 시도합니다. 퓨처가 더 진행할 수 없으면 현재 스레드의 제어권을 양보합니다. 다시 진행할 수 있을 때 실행기가 퓨처를 선택해 실행을 재개하고 .await가 완료됩니다. 자세한 내용은 async/.await 문서를 참고할 수 있습니다.

다른 async 함수 안에서는 퓨처를 기다릴 수 있지만 Rust의 표준 main 함수는 그대로 async로 선언할 수 없습니다. Rust 자체가 비동기 코드를 실행하는 런타임을 제공하지 않기 때문입니다. 비동기 코드를 실행하려면 비동기 런타임을 사용합니다. Tokio는 널리 쓰이는 비동기 런타임 가운데 하나입니다. 위 예제의 tokio::main 특성 매크로는 비동기 main 함수를 진입점으로 지정하고 런타임을 구성합니다.

작업 취소

앞선 C# 예제에서는 .NET의 일반적인 방식에 따라 비동기 메서드에 CancellationToken을 전달했습니다. CancellationToken으로 비동기 작업의 취소를 알릴 수 있습니다.

Rust의 퓨처는 폴링할 때만 진행하므로 취소 방식이 다릅니다. Future를 해제하면 더 이상 진행하지 않습니다. 대기 중인 비동기 작업에서 중단된 지점까지 생성한 값도 함께 해제됩니다. 따라서 대부분의 Rust 비동기 함수는 취소 신호를 받는 매개변수를 사용하지 않습니다. 퓨처를 해제하는 동작을 취소라고 부르기도 합니다.

퓨처의 소유권을 해제하는 방식이 적합하지 않은 경우에는 tokio_util::sync::CancellationToken으로 .NET의 CancellationToken과 비슷하게 취소를 알리고 처리할 수 있습니다.

여러 작업 실행

.NET에서는 Task.WhenAny와 Task.WhenAll을 사용해 여러 작업을 처리합니다.

Task.WhenAny는 작업 하나가 완료되면 완료됩니다. Tokio의 tokio::select! 매크로도 동시에 진행되는 여러 분기 중 하나가 완료되기를 기다릴 때 사용할 수 있습니다. 먼저 C# 예제를 살펴보겠습니다.

var cancellationToken = CancellationToken.None;

var result =
    await Task.WhenAny(Delay(TimeSpan.FromSeconds(2), cancellationToken),
                       Delay(TimeSpan.FromSeconds(1), cancellationToken));

Console.WriteLine(result.Result); // Waited 1 second(s).

async Task<string> Delay(TimeSpan delay, CancellationToken cancellationToken)
{
    await Task.Delay(delay, cancellationToken);
    return $"Waited {delay.TotalSeconds} second(s).";
}

Rust에서는 다음처럼 작성합니다.

use std::time::Duration;
use tokio::{select, time::sleep};

#[tokio::main]
async fn main() {
    let result = select! {
        result = delay(Duration::from_secs(2)) => result,
        result = delay(Duration::from_secs(1)) => result,
    };

    println!("{}", result); // Waited 1 second(s).
}

async fn delay(delay: Duration) -> String {
    sleep(delay).await;
    format!("Waited {} second(s).", delay.as_secs())
}

두 예제는 취소 동작이 다릅니다. tokio::select!는 선택되지 않은 나머지 분기를 취소합니다. Task.WhenAny를 사용할 때는 아직 실행 중인 작업을 취소할지 개발자가 결정합니다.

Task.WhenAll에 대응하는 기능으로는 tokio::join!을 사용할 수 있습니다.

여러 소비자

.NET의 Task는 여러 소비자가 기다릴 수 있습니다. 작업이 완료되거나 실패하면 모두 결과를 전달받습니다. Rust의 Future는 일반적으로 복제하거나 복사할 수 없으며 .await하면 소유권이 이동합니다. futures::FutureExt::shared 확장 메서드는 여러 소비자에게 전달할 수 있는 복제 가능한 퓨처 핸들을 만듭니다.

use futures::FutureExt;
use std::time::Duration;
use tokio::{select, time::sleep, signal};
use tokio_util::sync::CancellationToken;

#[tokio::main]
async fn main() {
    let token = CancellationToken::new();
    let child_token = token.child_token();

    let bg_operation = background_operation(child_token);

    let bg_operation_done = bg_operation.shared();
    let bg_operation_final = bg_operation_done.clone();

    select! {
        _ = bg_operation_done => {},
        _ = signal::ctrl_c() => {
            token.cancel();
        },
    }

    bg_operation_final.await;
}

async fn background_operation(cancellation_token: CancellationToken) {
    select! {
        _ = sleep(Duration::from_secs(2)) => println!("Background operation completed."),
        _ = cancellation_token.cancelled() => println!("Background operation cancelled."),
    }
}

비동기 반복

.NET에는 IAsyncEnumerable<T>와 IAsyncEnumerator<T>가 있습니다. 원문 작성 당시 Rust 표준 라이브러리에는 이에 대응하는 비동기 반복 API가 없었습니다. 대신 futures의 Stream 트레이트가 비슷한 기능을 제공합니다. 개념과 사용법은 Rust 비동기 책의 스트림 장에서도 다룹니다.

C#에서는 동기 반복자를 작성할 때와 비슷한 구문으로 비동기 반복자를 작성합니다.

await foreach (int item in RangeAsync(10, 3).WithCancellation(CancellationToken.None))
    Console.Write(item + " "); // "10 11 12"를 출력합니다.

async IAsyncEnumerable<int> RangeAsync(int start, int count)
{
    for (int i = 0; i < count; i++)
    {
        await Task.Delay(TimeSpan.FromSeconds(i));
        yield return start + i;
    }
}

Rust에는 futures::channel::mpsc처럼 Stream을 구현하는 여러 타입이 있습니다. C#에 가까운 구문을 원한다면 async-stream이 제공하는 매크로로 스트림을 간결하게 생성할 수 있습니다.

use async_stream::stream;
use futures_core::stream::Stream;
use futures_util::{pin_mut, stream::StreamExt};
use std::{
    io::{stdout, Write},
    time::Duration,
};
use tokio::time::sleep;

#[tokio::main]
async fn main() {
    let stream = range(10, 3);
    pin_mut!(stream); // 반복에 필요합니다.
    while let Some(result) = stream.next().await {
        print!("{} ", result); // "10 11 12"를 출력합니다.
        stdout().flush().unwrap();
    }
}

fn range(start: i32, count: i32) -> impl Stream<Item = i32> {
    stream! {
        for i in 0..count {
            sleep(Duration::from_secs(i as _)).await;
            yield start + i;
        }
    }
}

프로젝트 구조

.NET 프로젝트에도 일반적인 디렉터리 구성이 있지만 Rust 프로젝트보다 형식이 자유로운 편입니다. Visual Studio 2022에서 클래스 라이브러리와 xUnit 테스트 프로젝트로 구성된 솔루션을 만들면 다음과 같은 구조가 됩니다.

.
|   SampleClassLibrary.sln
+---SampleClassLibrary
|       Class1.cs
|       SampleClassLibrary.csproj
+---SampleTestProject
        SampleTestProject.csproj
        UnitTest1.cs
        Usings.cs

각 프로젝트는 자체 .csproj 파일과 별도 디렉터리를 사용합니다. 저장소 루트에는 .sln 파일이 있습니다.

Cargo는 새 패키지의 구성을 쉽게 파악할 수 있도록 다음 패키지 레이아웃을 따릅니다.

.
+-- Cargo.lock
+-- Cargo.toml
+-- src/
|   +-- lib.rs
|   +-- main.rs
+-- benches/
|   +-- some-bench.rs
+-- examples/
|   +-- some-example.rs
+-- tests/
    +-- some-integration-test.rs
  • Cargo.toml과 Cargo.lock은 패키지 루트에 둡니다.
  • src/lib.rs는 기본 라이브러리 파일이며 src/main.rs는 기본 실행 파일입니다. 자세한 규칙은 타깃 자동 검색을 참고할 수 있습니다.
  • 벤치마크는 benches에, 통합 테스트는 tests에 둡니다. 테스트와 벤치마킹 장에서 자세히 다룹니다.
  • 예제는 examples에 둡니다.
  • 단위 테스트용 크레이트를 별도로 만들지 않습니다. 단위 테스트는 대상 코드와 같은 파일에 둡니다. 테스트 장에서 예제를 확인할 수 있습니다.

대규모 프로젝트 관리

Rust의 대규모 프로젝트에서는 Cargo 워크스페이스로 관련 패키지를 함께 관리할 수 있습니다. 기본 패키지가 없는 프로젝트에서는 가상 매니페스트를 사용하기도 합니다.

의존성 버전 관리

대규모 .NET 프로젝트에서는 중앙 패키지 관리로 의존성 버전을 한곳에서 관리할 수 있습니다. Cargo는 워크스페이스 상속으로 의존성을 중앙에서 관리합니다.

컴파일과 빌드

.NET CLI와 Cargo

Rust의 Cargo (cargo)는 .NET CLI(dotnet)에 대응합니다. 두 도구 모두 하위 도구를 쉽게 사용할 수 있는 진입점을 제공합니다. C# 컴파일러(csc)나 MSBuild(dotnet msbuild)를 직접 실행할 수도 있지만 보통 dotnet build로 솔루션을 빌드합니다. Rust에서도 컴파일러(rustc)를 직접 호출할 수 있지만 일반적으로 cargo build가 간편합니다.

빌드 결과물

.NET에서 dotnet build로 실행 프로그램을 빌드하면 패키지를 복원하고 소스 코드를 컴파일하여 어셈블리를 만듭니다. 어셈블리에는 중간 언어(IL) 코드가 들어 있습니다. 호스트에 .NET 런타임이 설치되어 있다면 일반적으로 .NET이 지원하는 여러 플랫폼에서 실행할 수 있습니다. 의존 패키지의 어셈블리는 대개 프로젝트 결과물과 함께 배치합니다. Rust의 cargo build도 빌드 결과물을 만들지만 Rust 컴파일러는 일반적으로 모든 코드를 정적으로 링크하여 플랫폼별 바이너리 하나로 만듭니다. 다른 링크 방식도 사용할 수 있습니다.

.NET에서는 dotnet publish로 프레임워크 종속 배포(FDD)나 자체 포함 배포(SCD)에 필요한 실행 파일을 준비합니다. Rust에는 이에 직접 대응하는 dotnet publish 명령이 없습니다. 빌드 결과물이 대상 플랫폼용 바이너리를 이미 포함하기 때문입니다.

.NET 라이브러리를 dotnet build로 빌드해도 IL을 포함한 어셈블리가 생성됩니다. Rust 라이브러리를 빌드하면 각 라이브러리 타깃에 해당하는 플랫폼별 컴파일 결과물이 생성됩니다.

관련 개념은 크레이트 문서에서 확인할 수 있습니다.

의존성

.NET에서는 프로젝트 파일에 빌드 옵션과 의존성을 정의합니다. Cargo를 사용하는 Rust 프로젝트는 Cargo.toml에 패키지 의존성을 선언합니다. 다음은 일반적인 .NET 프로젝트 파일의 예입니다.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="morelinq" Version="3.3.2" />
  </ItemGroup>

</Project>

이에 대응하는 Rust의 Cargo.toml은 다음과 같이 작성합니다.

[package]
name = "hello_world"
version = "0.1.0"

[dependencies]
tokio = "1.0.0"

Cargo는 src/main.rs를 패키지와 이름이 같은 바이너리 크레이트의 루트로 취급합니다. 패키지에 src/lib.rs가 있으면 패키지와 이름이 같은 라이브러리 크레이트도 포함된 것으로 취급합니다.

패키지

.NET에서는 주로 NuGet으로 패키지를 설치합니다. 예를 들어 .NET CLI에서 NuGet 패키지 참조를 추가하면 프로젝트 파일에 의존성이 기록됩니다.

dotnet add package morelinq

Rust에서도 Cargo로 비슷하게 패키지를 추가할 수 있습니다.

cargo add tokio

.NET 패키지는 주로 nuget.org에, Rust 패키지는 주로 crates.io에 게시합니다.

정적 코드 분석

.NET 5부터 .NET SDK는 코드 품질과 코드 스타일을 분석하는 Roslyn 분석기를 포함합니다. Rust에서 이에 대응하는 린트 도구는 Clippy입니다.

.NET 프로젝트에서 TreatWarningsAsErrors를 true로 설정해 경고가 있을 때 빌드를 실패시킬 수 있습니다. Clippy도 cargo clippy -- -D warnings로 컴파일러나 Clippy의 경고를 오류로 처리할 수 있습니다.

Rust CI 파이프라인에는 다음 정적 검사를 추가할 수 있습니다.