Rust for Rubyists

Matthias Endler

Ruby 개발자를 위한 Rust

원문은 Matthias Endler님이 에 게재했습니다. 이 블로그 구독하기

최근에 관용적인 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은 조건자를 받지 않습니다. 그런 경우엔 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>라는 타입 힌트를 사용하는 이유는, 없으면 Rust가 collect를 호출할 때 어떤 컬렉션을 만들어야 할지 알 수 없기 때문입니다.
  • 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을 기대합니다. Resultok()를 호출하면 Option으로 변환할 수 있습니다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"

이는 다음과 같이 옮길 수 있습니다:

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에 대한 흥미가 생기셨나요? 이 GitHub 프로젝트를 한번 살펴보세요. 기여해 주시면 감사하겠습니다.

각주

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

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

댓글