Cursed Rust: Printing Things The Wrong Way

Matthias Endler

被詛咒的 Rust:用錯誤的方式印出東西

有一個關於哥本哈根大學考試期間一位物理學家的著名故事。考生被要求描述如何使用氣壓計測定摩天大樓的高度。該學生建議用一條繩子將氣壓計從大樓屋頂垂吊下來,然後測量繩長加上氣壓計本身的高度。雖然在技術上正確,考官們並不覺得有趣。

經過申訴與重新評估後,該學生提出了各種以物理學為基礎的解法,從丟下氣壓計並利用墜落時間計算大樓高度,到利用大樓影子與氣壓計影子長度的比例,從氣壓計的高度推算大樓的高度。他甚至幽默地建議,只要把氣壓計送給管理員,就可以直接問到答案。

如傳說所言,這位物理學家是Niels Bohr(尼爾斯·波耳),他後來於 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!的呼叫,並在字串後加上換行字元。

實際的程式碼詭異得多。如果你想大開眼界,可以在這個檔案中搜尋printwrite!本身會解糖為對write_fmt的呼叫,而這是Write trait(特徵)的一個方法。

這其實有實際的應用場景:如果你想非常快速地印出東西,可以先鎖定stdout一次,然後再使用write!。這樣可以避免每次呼叫println!時都要鎖定stdout的額外開銷。請參閱這篇關於如何用這個技巧寫出超快速yes版本的文章

解法 2:逐字元迭代

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

這顯示了你可以使用 Rust 強大的 iterators(迭代器)來實作println!。在這裡我們遍歷字串中的字元,並逐一印出。

chars()會回傳一個遍歷Unicode 純量值的 iterator。

請在這裡深入了解 iterators。

解法 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}");

這讓我們稍微了解 trait 在 Rust 中的運作方式:我們定義一個實作了Display trait 的 struct(結構),讓我們可以用print!來印出它。一般來說,Display是用來讓更複雜的型別變得可列印,但也可以為一個寫死的字串實作它!

解法 4:誰還需要Display

如果我們不用Display,而是建立自己的 trait 呢?

trait Println {
    fn println(&self);
}

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

"Hello, world!".println();

我們可以利用我們可以隨意命名 trait 方法這一點。在這個範例中,我們選擇了println,讓它看起來像是標準程式庫的一部分。

這完全顛覆了println!巨集。我們不再把字串當作參數傳入,而是直接在字串本身上呼叫方法!

解法 5:有了panic!,誰還需要println!

panic!("Hello, world!");

在 Rust 中除了使用println!之外,還有其他印出東西的方法。在這個情況下,我們使用panic!,它會印出字串(作為副作用)並立即終止程式。只要我們只想印出單一字串,這招就管用……

解法 6:我 ♥︎️ Closures(閉包)

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

Rust 允許你在定義 closure 之後直接呼叫它。該 closure 被定義為一個匿名函式,它接受一個字串切片作為參數並將其印出。字串切片則作為參數傳遞給該 closure。

實務上,這對於定義只使用一次、又不想特別取名的 closure 來說很有用。

解法 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函式。因為我們使用原始指標將字串傳給函式,所以它是不安全的。這讓我們稍微了解 FFI(外部函式介面)在 Rust 中的運作方式。

感謝/u/pinespear on Reddit@[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 trait 用於實作<<運算子。cout struct 為任何實作了Display的型別實作Shl,這讓我們可以印出任何可列印的型別。endl struct 則為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:「極速」

我們花大錢買了那麼多 CPU 核心,如果不好好利用它們,要來幹嘛?無畏併發(fearless concurrency)不是 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 語言程式碼大賽。請查看歷屆得獎者名單

如果你其實對氣壓計的故事更感興趣,請閱讀由 Richard Feynman(理查·費曼)所著的Surely You’re Joking, Mr. Feynman!(《別鬧了,費曼先生!》),這位另一位著名的物理學家與諾貝爾獎得主,以其非傳統的思考方式聞名。

我們都應該努力跳脫框架思考,為問題想出非傳統的解法。誰知道呢,也許這正是更深入理解問題本身的關鍵?

原文由 Matthias Endler 發布

本文章由 muse-spark-1.2-contributor 進行翻譯