Cursed Rust: Printing Things The Wrong Way

Matthias Endler

저주받은 Rust: 엉뚱한 방법으로 출력하기

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

코펜하겐 대학교 시험 중에 있었던 물리학자에 관한 유명한 이야기가 있다. 수험생은 기압계를 이용해 초고층 건물의 높이를 측정하는 방법을 설명하라는 질문을 받았다. 학생은 건물 옥상에서 끈에 기압계를 매달아 늘어뜨린 뒤, 끈의 길이와 기압계의 높이를 합쳐 측정하겠다고 답했다. 기술적으로는 틀린 답이 아니었지만, 시험관들은 그 답을 달가워하지 않았다.

항의와 재평가가 이어진 뒤, 학생은 다양한 물리학 기반 해법들을 내놓았다. 기압계를 떨어뜨려 낙하 시간으로 건물 높이를 계산하는 방법부터, 건물 그림자와 기압계 그림자의 길이 비례를 이용해 기압계의 높이로부터 건물 높이를 구하는 방법까지 다양했다. 심지어 기압계를 주는 대가로 관리인에게 그냥 물어보면 된다는 유머러스한 제안까지 했다.

전설에 따르면 그 물리학자는 1922년 노벨상을 수상한 닐스 보어였다. 이 이야기는 기압계 문제로도 알려져 있다.

이 이야기는 왜 흥미로운가?

이 질문과 가능한 답들에는 중요한 교육적 효과가 있다. 학습자에게 정석적인 방법이 아니더라도 해결책에 도달할 수 있다는 것, 그리고 그런 방법들이 종종 정석 해법보다 더 흥미롭다는 것을 알려준다. 그 이유는 문제 자체에 대해 무언가를 드러내 보여주기 때문이다.

관습적인 질문에 대한 비관습적인 답에서 배우는 것에는 가치가 있다. 어느 정도는 이는 사고와 문제 해결의 새로운 방식을 길러주며, 이는 혁신의 본질적인 부분이기도 하다.

같은 원리를 Rust 학습에 적용하기

Rust를 배우는 책이라면 어디에서나 가장 먼저 나오는 예제가 바로 “Hello, world!” 프로그램이다.

fn main() {
    println!("Hello, world!");
}

Rust 설치가 제대로 되었는지 확인하는 쉬운 방법이기도 하다.

하지만 발상을 뒤집어 재미를 더해볼 수도 있다. println!을 쓰지 않고 “Hello, world!”를 출력하는 방법을 찾아보자.

가능한 한 많은 엉뚱한 해법을 생각해 보자. 더 기괴할수록 좋다! 아래의 각 해법을 살펴보면서 왜 동작하는지, 그리고 그로부터 무엇을 배울 수 있는지 이해해 보자.

이건 밈으로 시작됐다가, 게시물이 큰 관심을 받자 본격적인 글로 확장하게 됐다.

당연히 이런 해법들을 실제 프로덕션 코드에서 써서는 안 된다. 대신 이 엔터프라이즈급 hello world 버전을 참고하자.

해법 1: println! 디슈가링하기

use std::io::Write;

write!(std::io::stdout().lock(), "Hello, world!");

이 해법이 흥미로운 이유는 println!이 사실 문자열 끝에 개행 문자를 붙여 write!를 호출하는 매크로에 불과하다는 것을 보여주기 때문이다.

실제 코드는 훨씬 더 기괴하다. 놀라움을 느끼고 싶다면 이 파일에서 print를 검색해 보라. write! 자체는 write_fmt 호출로 디슈가링된다는 점에 주목하자. write_fmtWrite 트레이트의 메서드다.

실제로 유용한 경우도 있다. 무언가를 정말 빠르게 출력하고 싶다면 stdout을 한 번만 lock한 뒤 write!를 사용할 수 있다. 이렇게 하면 println!을 호출할 때마다 stdout을 lock하는 오버헤드를 피할 수 있다. 이 트릭으로 아주 빠른 yes를 만드는 방법은 이 글에서 확인해 보라.

해법 2: 문자 단위로 순회하기

"Hello, world!".chars().for_each(|c| print!("{}", c));

이 방법은 Rust의 강력한 이터레이터를 이용해 println!을 구현할 수 있음을 보여준다. 여기서는 문자열의 문자들을 순회하며 하나씩 출력한다.

chars()유니코드 스칼라 값에 대한 이터레이터를 반환한다.

이터레이터에 대해 더 알아보려면 여기를 참고하자.

해법 3: Display 구현하기

struct HelloWorld;

impl std::fmt::Display for HelloWorld {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Hello, world!")
    }
}

println!("{HelloWorld}");

이를 통해 Rust에서 트레이트가 어떻게 동작하는지 조금 배울 수 있다. Display 트레이트를 구현한 구조체를 정의하면 print!로 출력할 수 있게 된다. 일반적으로 Display는 더 복잡한 타입을 출력 가능하게 만들기 위한 것이지만, 하드코딩된 문자열에 대해 구현하는 것도 가능하다!

해법 4: Display가 왜 필요하지?

Display를 쓰는 대신 우리만의 트레이트를 만들어 보면 어떨까?

trait Println {
    fn println(&self);
}

impl Println for &str {
    fn println(&self) {
        print!("{}", self);
    }
}

"Hello, world!".println();

트레이트 메서드의 이름은 원하는 대로 정할 수 있다는 점을 이용할 수 있다. 이 예제에서는 표준 라이브러리에 있는 것처럼 보이도록 println이라는 이름을 선택했다.

이렇게 하면 println! 매크로를 완전히 뒤집게 된다. 문자열을 인자로 전달하는 대신, 문자열 자체에 메서드를 호출하는 것이다!

해법 5: println!이 있는데 panic!이 왜 필요해?

panic!("Hello, world!");

Rust에서 무언가를 출력하는 방법이 println!만 있는 것은 아니다. 여기서는 panic!을 사용하는데, 이는 문자열을 출력하고(부수 효과로) 즉시 프로그램을 종료한다. 단 하나의 문자열만 출력하면 된다면 문제없이 동작한다…

해법 6: 난 ♥︎️ 클로저

(|s: &str| print!("{}", s))("hello");

Rust에서는 클로저를 정의한 직후 바로 호출할 수 있다. 이 클로저는 문자열 슬라이스를 인자로 받아 출력하는 익명 함수로 정의된다. 문자열 슬라이스는 클로저의 인자로 전달된다.

실제로는 한 번만 사용하고 이름을 붙이고 싶지 않은 클로저를 정의할 때 유용할 수 있다.

해법 7: C 스타일

extern crate libc;
use libc::{c_char, c_int};
use core::ffi::CStr;

extern "C" {
    fn printf(fmt: *const c_char, ...) -> c_int;
}

fn main() {
    const HI: &CStr = match CStr::from_bytes_until_nul(b"hello\n\0") {
        Ok(x) => x,
        Err(_) => panic!(),
    };

    unsafe {
        printf(HI.as_ptr());
    }
}

출력하기 위해 굳이 Rust 표준 라이브러리를 쓸 필요조차 없다! 이 예제는 Rust에서 C 표준 라이브러리의 printf 함수를 호출하는 방법을 보여준다. 문자열을 전달하기 위해 로우 포인터를 사용하므로 unsafe하다. 이를 통해 Rust에서 FFI가 어떻게 동작하는지 조금 배울 수 있다.

크레딧: Reddit의 /u/pinespear@[email protected]

해법 8: C++ 스타일

이제 완전히 사이코패스 영역에 들어왔다… 그러니 여기서 멈추지 말자. 정말 열심히 노력하면 Rust를 원하는 대로 구부려 C++처럼 보이게 만들 수도 있다.

use std::fmt::Display;
use std::ops::Shl;

#[allow(non_camel_case_types)]
struct cout;
#[allow(non_camel_case_types)]
struct endl;

impl<T: Display> Shl<T> for cout {
    type Output = cout;
    fn shl(self, data: T) -> Self::Output {
        print!("{}", data);
        cout
    }
}
impl Shl<endl> for cout {
    type Output = ();
    fn shl(self, _: endl) -> Self::Output {
        println!();
    }
}

cout << "Hello World" << endl;

Shl 트레이트는 << 연산자를 구현하는 데 사용된다. cout 구조체는 Display를 구현한 모든 타입에 대해 Shl을 구현하므로, 출력 가능한 모든 타입을 출력할 수 있다. endl 구조체는 cout에 대해 Shl을 구현하며, 마지막에 개행 문자를 출력한다.

크레딧: 이 해법은 Wisha Wanichwecharungruang에게 있다.

해법 9: 어셈블리로 완전한 제어하기

이런 고수준 추상화들은 효율적으로 출력하는 데 방해만 된다. 우리는 CPU에 대한 제어권을 되찾아야 한다. 어셈블리가 정답이다. 더 이상 낭비되는 사이클도, 숨겨진 명령어도 없다. 순수하고 오염되지 않은 성능만이 있을 뿐이다.

use std::arch::asm;

const SYS_WRITE: usize = 1;
const STDOUT: usize = 1;

fn main() {
    #[cfg(not(target_arch = "x86_64"))]
    panic!("This only works on x86_64 machines!");

    let phrase = "Hello, world!";
    let bytes_written: usize;
    unsafe {
        asm! {
            "syscall",
            inout("rax") SYS_WRITE => bytes_written,
            inout("rdi") STDOUT => _,
            in("rsi") phrase.as_ptr(),
            in("rdx") phrase.len(),
            // syscall clobbers these
            out("rcx") _,
            out("r11") _,
        }
    }

    assert_eq!(bytes_written, phrase.len());
}

(Rust Playground)

우리가 하는 일이라고는 어셈블리 코드를 호출하는 것뿐인데 왜 굳이 Rust를 쓰는지 궁금하다면, 요점을 놓치고 있는 것이다! 이건 단순히 출력하는 것 이상의 문제다. 자유에 관한 이야기다! 내 CPU를 어떻게 써야 하는지 참견하지 마라.

좋아, x86_64 머신에서만 동작하지만, 자유를 위한 작은 희생이다.

isaacthefallenapple이 제출함.

해법 10: “Blazing Fast”

모든 CPU 코어에 프리미엄을 지불했는데 제대로 활용하지 않는다면 무슨 소용인가? 두려움 없는 동시성이 Rust의 약속 중 하나가 아니었던가? 그 코어들을 제대로 활용해 보자!

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

fn main() {
    let phrase = "hello world";
    let phrase = Arc::new(Mutex::new(phrase.chars().collect::<Vec<_>>()));

    let mut handles = vec![];

    for i in 0..phrase.lock().unwrap().len() {
        let phrase = Arc::clone(&phrase);
        let handle = thread::spawn(move || {
            thread::sleep(Duration::from_millis(((i + 1) * 100) as u64));
            print!("{}", phrase.lock().unwrap()[i]);
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
    println!();
}

여기서는 각 문자를 별도의 스레드에서 출력한다. 스레드들은 루프 안에서 생성되며, 각 스레드는 문자를 출력하기 전에 일정 시간(밀리초) 동안 잠든다. 이를 통해 CPU의 모든 힘을 동원해 문자열을 출력한다! 문자가 항상 올바른 순서대로 출력되지는 않을 수도 있지만(스케줄링은 어렵다니까!), 그건 엄청난 성능 향상을 위한 충분히 가치 있는 트레이드오프다.

이제 여러분 차례!

더 많은 해법이 있다면 메시지로 알려 달라.

이 글이 마음에 들었다면 매년 열리는 난독화 C 코드 콘테스트도 즐길 수 있을 것이다. 이전 수상작들은 여기에서 확인해 보라.

기압계 이야기에 더 흥미를 느꼈다면, 또 다른 유명한 물리학자이자 노벨상 수상자로서 비관습적인 사고방식으로 알려진 리처드 파인만이 쓴 책 Surely You’re Joking, Mr. Feynman!을 읽어 보라.

우리 모두 틀에 박힌 사고에서 벗어나 문제에 대한 엉뚱한 해법을 고민해 보자. 어쩌면 그것이 문제 자체를 더 깊이 이해하는 열쇠가 될지도 모른다.

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

댓글