Rust for Rubyists

Matthias Endler

루비스트를 위한 Rust

최근에 관용적인 Ruby에 대한 멋진 글을 읽었습니다. 저는 결코 뛰어난 Ruby 개발자는 아니지만, 그 글에 나온 많은 패턴이 Rust에서도 꽤 흔하게 쓰인다는 것을 깨달았습니다. 아래에서는 두 언어의 관용적인 코드를 나란히 비교해 보겠습니다.

Ruby 코드 예제는 원문에서 가져왔습니다.

Map과 고차 함수

첫 번째 예제는 map을 이용해 컨테이너의 요소를 순회하는 아주 기본적인 경우입니다.

user_ids = users.map { |user| user.id }

map 개념은 Rust에서도 매우 표준적입니다. Ruby와 비교하면 조금 더 명시적으로 작성해야 합니다. usersUser 객체의 벡터라면, 먼저 이터레이터를 만들어야 합니다.

let user_ids = users.iter().map(|user| user.id);

꽤 장황하다고 느낄 수도 있지만, 이 추가적인 추상화 덕분에 중요한 개념을 표현할 수 있습니다. 바로 이터레이터가 벡터의 소유권을 가져갈지 그렇지 않을지를 구분하는 것입니다.

  • iter()를 사용하면 벡터에 대한 “읽기 전용 뷰”를 얻습니다. 순회가 끝난 뒤에도 벡터는 그대로 유지됩니다.
  • into_iter()를 사용하면 벡터의 소유권을 가져옵니다. 순회가 끝나면 벡터는 사라집니다. Rust 용어로는 moved된 것입니다.
  • iter()into_iter()의 차이에 대해 여기에서 더 읽어보세요.

위 Ruby 코드는 다음과 같이 더 간단하게 쓸 수도 있습니다.

user_ids = users.map(&:id)

Ruby에서 map 같은 고차 함수는 인자로 블록이나 proc을 받고, 언어 차원에서 메서드 호출을 위한 편리한 단축 문법을 제공합니다. &:id{|o| o.id()}와 같습니다.

Rust에서도 비슷한 방식을 쓸 수 있습니다.

let id = |u: &User| u.id;
let user_ids = users.iter().map(id);

다만 이 방법이 가장 관용적인 방식은 아닐 것입니다. 이런 경우에는 Universal Function Call Syntax를 사용하는 경우가 더 흔합니다.1

let user_ids = users.iter().map(User::id);

Rust에서 고차 함수는 인자로 함수를 받습니다. 따라서 users.iter().map(Users::id)는 대체로 users.iter().map(|u| u.id())와 동일합니다.2

또한 Rust의 map()은 컬렉션이 아니라 또 다른 이터레이터를 반환합니다. 컬렉션을 얻고 싶다면 뒤에서 보게 될 것처럼 collect()를 호출해야 합니다.

Each를 이용한 순회

반복 얘기가 나온 김에, Ruby 코드에서 자주 보이는 패턴이 있습니다.

["Ruby", "Rust", "Python", "Cobol"].each do |lang|
  puts "Hello #{lang}!"
end

Rust 1.21부터는 Rust에서도 이렇게 쓸 수 있습니다.

["Ruby", "Rust", "Python", "Cobol"]
    .iter()
    .for_each(|lang| println!("Hello {lang}!", lang = lang));

다만 Rust에서는 보통 일반적인 for 루프로 더 많이 작성합니다.

for lang in ["Ruby", "Rust", "Python", "Cobol"].iter() {
    println!("Hello {lang}!", lang = lang);
}

Select와 filter

Ruby에서 컬렉션에서 짝수만 추출하고 싶다고 해 보겠습니다.

even_numbers = [1, 2, 3, 4, 5].map { |element| element if element.even? } # [ni, 2, nil, 4, nil]
even_numbers = even_numbers.compact # [2, 4]

이 예제에서는 compact를 호출하기 전까지 even_numbers 배열에 nil 항목이 있습니다. 반면 Rust에는 nil이나 Null이라는 개념이 없습니다. 그래서 compact가 필요하지 않습니다. 또한 map은 서술어(predicate)를 받지 않습니다. 그럴 때는 filter를 사용합니다.

let even_numbers = vec![1, 2, 3, 4, 5]
    .iter()
    .filter(|&element| element % 2 == 0);

혹은 결과를 벡터로 만들고 싶다면

// Result: [2, 4]
let even_numbers: Vec<i64> = vec![1, 2, 3, 4, 5]
    .into_iter()
    .filter(|element| element % 2 == 0).collect();

몇 가지 참고 사항입니다.

  • 여기서는 Vec<i64>라는 타입 힌트를 사용했습니다. 이 힌트가 없으면 collect를 호출할 때 어떤 컬렉션을 만들려는 것인지 Rust가 알 수 없기 때문입니다.
  • vec!는 벡터를 생성하는 매크로입니다.
  • iter 대신 into_iter를 사용했습니다. 이렇게 하면 벡터 안 요소들의 소유권을 가져옵니다. iter()를 사용하면 대신 Vec<&i64>를 얻게 됩니다.

Rust에서 숫자에는 even 메서드가 없지만, 그렇다고 직접 정의하지 못할 이유는 없습니다!

let even = |x: &i64| x % 2 == 0;
let even_numbers = vec![1, 2, 3, 4, 5].into_iter().filter(even);

실제 상황이라면 수치 연산을 위해 num 같은 서드파티 패키지(crate)를 사용하는 것이 일반적일 것입니다.

extern crate num;
use num::Integer;

fn main() {
    let even_numbers: Vec<i64> = vec![1, 2, 3, 4, 5]
        .into_iter()
        .filter(|x| x.is_even()).collect();
}

일반적으로 Rust에서는 표준 라이브러리에 없는 기능은 crate을 사용해 해결하는 것이 아주 흔합니다. 이렇게 널리 받아들여진 이유 중 하나는 cargo가 정말 훌륭한 패키지 매니저이기 때문입니다. (아무래도 Ruby로 유명한 Yehuda Katz가 만든 덕분일지도 모릅니다. 😉)

앞서 언급했듯이 Rust에는 nil이 없습니다. 하지만 실패할 수 있는 연산이라는 개념은 여전히 존재합니다. 이를 표현하는 대표적인 타입이 Result입니다.

문자열 벡터를 정수로 변환하고 싶다고 해 보겠습니다.

let maybe_numbers = vec!["1", "2", "nah", "nope", "3"];
let numbers: Vec<_> = maybe_numbers
    .into_iter()
    .map(|i| i.parse::<u64>())
    .collect();

겉보기엔 깔끔해 보이지만, 출력 결과는 조금 예상과 다를 수 있습니다. numbers에는 파싱 에러까지 함께 들어갑니다.

[Ok(1), Ok(2), Err(ParseIntError { kind: InvalidDigit }), Err(ParseIntError { kind: InvalidDigit }), Ok(3)]

때로는 성공한 연산에만 관심이 있을 수 있습니다. 에러를 쉽게 걸러내는 방법 중 하나는 filter_map을 사용하는 것입니다.

let maybe_numbers = vec!["1", "2", "nah", "nope", "3"];
let numbers: Vec<_> = maybe_numbers
    .into_iter()
    .filter_map(|i| i.parse::<u64>().ok())
    .collect();

여기서는 두 가지를 바꿨습니다.

  • map 대신 이제 filter_map을 사용합니다.
  • parseResult를 반환하지만, filter_mapOption을 기대합니다. ok()를 호출하면 ResultOption으로 변환할 수 있습니다3.

반환값에는 성공적으로 변환된 문자열만 들어갑니다.

[1, 2, 3]

filter_map은 Ruby의 select 메서드와 유사합니다.

[1, 2, 3, 4, 5].select { |element| element.even? }

난수

Ruby에서 배열에서 무작위 숫자를 하나 가져오는 방법은 다음과 같습니다.

[1, 2, 3].sample

꽤 멋지고 관용적인 방법입니다! Rust와 비교해 보겠습니다.

let mut rng = thread_rng();
rng.choose(&[1, 2, 3, 4, 5])

이 코드가 동작하려면 rand crate가 필요합니다. 실행 예제를 보려면 스니펫을 클릭하세요.

Ruby와 몇 가지 차이가 있습니다. 즉, 어떤 난수 생성기를 정확히 사용할지 더 명시적으로 정해야 합니다. 여기서는 시스템 시드로 지연 초기화되는 스레드 로컬 난수 생성기를 사용하기로 했습니다. 이 경우 벡터 대신 슬라이스를 사용했습니다. 가장 큰 차이점은 슬라이스는 크기가 고정되어 있지만 벡터는 그렇지 않다는 점입니다.

표준 라이브러리 내에서 Rust는 슬라이스 자체에 sample이나 choose 메서드를 제공하지 않습니다. 이는 설계상의 결정으로, 언어의 핵심을 작게 유지해 향후 언어 발전을 가능하게 하려는 의도입니다.

그렇다고 더 깔끔한 구현을 할 수 없다는 뜻은 아닙니다. 예를 들어 Choose 트레이트를 정의하고 [T]에 대해 구현할 수 있습니다.

extern crate rand;
use rand::{thread_rng, Rng};

trait Choose<T> {
    fn choose(&self) -> Option<&T>;
}

impl<T> Choose<T> for [T] {
    fn choose(&self) -> Option<&T> {
        let mut rng = thread_rng();
        rng.choose(&self)
    }
}

이 보일러플레이트 코드를 crate로 만들어 다른 사람들도 재사용할 수 있게 할 수 있습니다. 그렇게 하면 Ruby의 우아함에 견줄 만한 해법을 얻을 수 있습니다.

[1, 2, 4, 8, 16, 32].choose()

암시적 반환과 표현식

Ruby 메서드는 마지막 문장의 결과를 자동으로 반환합니다.

def get_user_ids(users)
  users.map(&:id)
end

Rust도 마찬가지입니다. 세미콜론이 없는 것에 주목하세요.

fn get_user_ids(users: &[User]) -> Vec<u64> {
    users.iter().map(|user| user.id).collect()
}

하지만 Rust에서는 이것이 시작에 불과합니다. 모든 것이 표현식이기 때문입니다. 다음 블록은 문자열을 문자 단위로 나누고, h를 제거한 뒤, 결과를 HashSet으로 반환합니다. 이 HashSetx에 할당됩니다.

let x: HashSet<_> = {
    // Get unique chars of a word {'h', 'e', 'l', 'o'}
    let unique = "hello".chars();
    // filter out the 'h'
    unique.filter(|&char| char != 'h').collect()
};

조건문도 마찬가지입니다.

let x = if 1 > 0 { "absolutely!" } else { "no seriously" };

match 문도 표현식이므로, 그 결과를 변수에 할당할 수도 있습니다!

enum Unit {
    Meter,
    Yard,
    Angstroem,
    Lightyear,
}

let length_in_meters = match unit {
    Unit::Meter => 1.0,
    Unit::Yard => 0.91,
    Unit::Angstroem => 0.0000000001,
    Unit::Lightyear => 9.461e+15,
};

다중 할당

Ruby에서는 한 번에 여러 값을 변수에 할당할 수 있습니다.

def values
  [1, 2, 3]
end

one, two, three = values

Rust에서는 튜플을 튜플로 분해할 수는 있지만, 예를 들어 벡터를 튜플로 분해할 수는 없습니다. 그래서 다음 코드는 동작합니다.

let (one, two, three) = (1, 2, 3);

하지만 다음 코드는 동작하지 않습니다.

let (one, two, three) = [1, 2, 3];
//    ^^^^^^^^^^^^^^^^^ expected array of 3 elements, found tuple

다음 코드도 마찬가지입니다.

let (one, two, three) = [1, 2, 3].iter().collect();
// a collection of type `(_, _, _)` cannot be built from an iterator over elements of type `&{integer}`

하지만 nightly Rust에서는 이제 이렇게 할 수 있습니다.

let [one, two, three] = [1, 2, 3];

반면에 다중 할당 외에도 구조 분해로 할 수 있는 일이 훨씬 더 많습니다. 패턴 문법을 이용해 아름답고 사용하기 편한 코드를 작성할 수 있습니다.

let x = 4;
let y = false;

match x {
    4 | 5 | 6 if y => println!("yes"),
    _ => println!("no"),
}

이는 if 조건이 마지막 값인 6에만 적용되는 것이 아니라 전체 패턴인 4 | 5 | 6에 적용되므로 no가 출력됩니다.

문자열 보간

Ruby는 강력한 문자열 보간 기능을 제공합니다.

programming_language = "Ruby"
"#{programming_language} is a beautiful programming language"

이를 Rust에서는 다음과 같이 번역할 수 있습니다.

let programming_language = "Rust";
format!("{} is also a beautiful programming language", programming_language);

이름이 있는 인자도 가능하지만, 훨씬 덜 쓰입니다.

println!("{language} is also a beautiful programming language", language="Rust");

Rust의 println!() 문법은 Ruby보다도 훨씬 더 다양합니다. 그 외에 무엇을 할 수 있는지 궁금하다면 문서를 확인해 보세요.

마치며!

Ruby는 자주 쓰이는 많은 패턴에 대해 문법적 편의를 제공하므로 매우 우아한 코드를 작성할 수 있습니다. 로우레벨 프로그래밍이나 순수한 성능은 이 언어의 주된 목표가 아닙니다.

그런 것이 필요하다면 Rust가 좋은 선택이 될 수 있습니다. Rust는 비슷한 수준의 사용성을 유지하면서도 하드웨어를 세밀하게 제어할 수 있게 해주기 때문입니다. 다만 Rust는 의심스러울 때는 명시성을 선호하며, 마법을 지양합니다.

관용적인 Rust에 대한 흥미가 생기셨나요? 이 Github 프로젝트를 한번 살펴보세요. 기여해 주시면 감사하겠습니다.

각주

1. 힌트를 주신 Florian Gilcher님께 감사드립니다.
2. 여러 부정확한 부분을 지적해 주신 masklin님께 감사드립니다.
3. 첫 번째 버전에서는 ok()Resultboolean으로 변환한다고 잘못 썼었습니다. 정정해 주신 isaacg님께 감사드립니다.

원문은 Matthias Endler님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.