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! 的呼叫,並在字串後面加上一個換行字元。

真正的程式碼要怪得多。如果你想大開眼界,可以在這個檔案裡搜尋 printwrite! 本身則會脫糖為對 write_fmt 的呼叫,而 write_fmtWrite trait 的一個方法。

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

解法 2:逐字元迭代

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

這顯示你可以用 Rust 強大的迭代器來實作 println!。在這裡,我們迭代字串中的每個字元並逐一印出。

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

想了解更多關於迭代器的資訊,請參考這裡

解法 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 中的 trait 是如何運作的:我們定義一個實作了 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:我 ♥︎️ 閉包

(|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 函式。因為我們使用原始指標(raw pointer)把字串傳給函式,所以這是不安全的(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 trait 用來實作 << 運算子。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:「極速」

我們花大錢買了那麼多 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 語言程式碼大賽。請在這裡查看歷屆得獎作品

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

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

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

留言