上上一篇匆匆忙忙不知不覺中就完成了WebAssembly,都還沒介紹這是什麼(?)。我們一起看一下WebAssembly的官網:
裡面提到WebAssembly(縮寫為wasm)是基於堆疊式虛擬機的二進制指令稿。並且有著以下特性:
怎麼好像這系列介紹的的東西,每樣都說自己是安全且快速(?)。
基本上JavaScript和wasm互有所長,如果是DOM元件的操作,還是建議用JavaScript就好;如果涉及比較複雜的演算法,可以考慮使用wasm,因為它快;如果需要比較精確的數字處理,比如金額(錢錢)的計算,可以考慮使用wasm,因為他不會亂進位。
所以wasm不是用來取代JavaScript的,是來加強我們前端的,畫面刻版依舊還是JS/CSS框架,需要加強效能或安全性,或是不想讓普羅大眾也看到我們在網站上裸奔的JavaScript長啥樣(?),就可以考慮選擇wasm。(那我不想加強JavaScript就不用學wasm了)
以下附上一些效能評測的資料供參考:
n=3
時 JavaScript比較快,但當 n=25
時 JavaScript明顯慢很多,所以推得wasm在進行複雜運算時有優勢,但因其不像JavaScript是立即執行,wasm需要進行載入的動作,所以相比JavaScript在進行簡單運算時,會有額外的載入成本耗損。再次印證了寫程式採用的解法就是取捨。
怕有人誤會以為用rust就無敵,畢竟還是有很多朋友沒有 去過中國廚藝訓練學院 受過計概的訓練。畢竟現在是一個看了網路YouTube教學就可以速成寫程式的年代。在JavaScript中的數字精確度非常非常的低,常常在四則運算中就錯了,不信的話把0.1加3次看看:
怕有人看不懂第一行又再補了第二行 XDD
這個其實嚴格來說不算是bug(?),相對地,它是一個標準(IEEE 754)。原理版請自己去K文件,這裡講通俗版,大家知道第18篇講過溢位了,因為電腦的記憶體空間有限,當整數運算超出了空間就爆了;這裡也是類似,因為記憶體空間有限,不能像數學裡的小數可以無限切割(又不是在積分?),所以在電腦裡的實數是不連續的。它會有很多洞填不滿。只是在運算時,剛好有時候多一點點,有時候少一點點,平均下來就差不多了:
所以如果真的要在前端寫到有精確度要求的運算,可以使用decimal.js處理,雖然它提供的API實在是有夠難用:
new Decimal(33).add(5).times(20); // (33 + 5) * 20
而rust沒有像C#一樣內建decimal的資料型別(其實deciaml也不保證不會有問題,只是我們日常情境夠用,所以更難遇到像上面JS算錯的例子而已。)在rust中已經有類似的套件rust_decimal可以使用。
這邊舉個栗子:
// examples/money/src/main.rs
use rust_decimal::prelude::*;
use rust_decimal_macros::dec;
fn main() {
let a = 0.1; // f64
let b = 0.1;
let c = 0.1;
println!("a + b + c = {}", a + b + c);
println!("a / 0.01 = {}", a / 0.03 );
let d1 = dec!(0.1);
let d2 = dec!(0.1);
let d3 = dec!(0.1);
println!("d1 + d2 + d3 = {}", d1 + d2 + d3);
let d4 = dec!(0.03);
println!("d1 / d4 = {}", d1 / d4 );
}
執行結果:
其實有些系統對於精確度有明確要求的,會自己實作Currency/Money物件,比如MartinFowler有在PoEAA中提到Money物件。中譯本為Martin Fowler談企業級軟體架構模式,快去買一本放在桌上假裝自己很厲害的樣子(X)。
曾經聽過有些系統在語言/資料庫/商業需求等綜合考量下,為了效能或其他的特殊需求,使用整數型態進行儲存,再另外存小數位數去處理,比如 100.00 就存成 int: 10000 的資料型別,最後在UI/報表上再折2位顯示。
這裡有針對浮點數與整數的benchmark和討論,有興趣的可以自己去看一下。
wasm 的另一個非web端的應用就是容器(?),基於其本身就是設計在沙盒中的虛擬環境,所以其抽象的程度更高,不能直接存取作業系統層的操作,需透過wasm的執行環境(runtime)。
Docker聯合創始人Solomon Hykes曾在twitter(X)上說,如果在2008年當時有wasm+wasi,那麼也許就不會產生docker了。很多地方都會引用這句話來說明WASM的重要性(包含我在這也是在引用)。隨後Solomon Hykes又說以現今來說,docker仍有其地位,wasm將是windows容器或linux容器外的另一種容器選擇。
所以有學docker的,可以繼續把wasm學起來(?),WebAssembly官網有列表各種程式語言編譯WebAssebmly的資源,大家有興趣可以看看,雖然我個人還是比較推薦使用rust。
有興趣的朋友可以實際去下載js及wasm的runtime相互比較看看。JavaScript的runtime,大多用nodejs,後來有Deno想改進nodejs的技術債而重寫,現在還有個很fancy的Bun。而wasm的runtime執行環境,可以看官網Roadmap,或是擲杯Wasmtime或wasmer二選一。
其實rust編譯wasm還有一些限制。我們來幫 service 層加 tokio的參考:
@@ service/Cargo.toml @@
[dependencies]
+tokio = { workspace = true }
然後建置wasm時就爆了:
因為之前說wasm處在沙盒中,不能開socket連線,所以在編譯時參考到mio(tokio使用的low-level I/O library)時就會報錯,這時候cargo 的feature就派上用場了,終於知道為什麼要分feature了(以後不能總想著懶人包直接full全部feature加進來了):
@@ service/Cargo.toml @@
[dependencies]
+tokio = { version = "1.32" , features = ["macros", "rt", "time"] }
-tokio = { workspace = true }
所以第一項限制是不能編譯比較接近OS底層的操作。不然連編譯都通過不了。接著再試試:
// service/src/demo.rs
pub async fn hello_async() -> String {
// wait().await; // 加這行在執行時會失效
"Hello async!".to_string()
}
pub async fn wait() {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
// wasm/src/demo.rs
use wasm_bindgen::prelude::wasm_bindgen;
use service::demo;
#[wasm_bindgen]
pub async fn hello_async() -> String {
demo::hello_async().await
}
前端加上
<!-- app/src/lib/Greet.svelte -->
<script lang="ts">
import init, { hello_async } from '../../../wasm/pkg/wasm'
import { onMount } from "svelte";
let hello = null;
async function greet() {
let s = await hello_async();
console.log(s);
}
onMount(async () => {
await init();
hello = hello_async;
});
</script>
一開始執行是成功的:
拿掉註解後,裡面的 tokio::time::sleep
在wasm中不能正確使用:
這是另一個無法正確執行的限制,所以如果寫的程式是要被wasm參考的,最好是一邊寫一邊開著watch看有沒有建置成功,不然等到都寫完了,再拆就比較麻煩。
上上一篇說要講的Closure在這裡也補一下。我們開個範例檔說明比較直觀,在examples底下新增一個closure專案:
examples/closure/
├── Cargo.toml
└── src
└── main.rs
# Cargo.toml
[package]
name = "example-closure"
version = "0.1.0"
edition = "2021"
[dependencies]
// examples/closure/src/main.rs
fn add_2(x: &i32) -> i32 { // 注意這個fn的簽章與下面map裡所需的簽章是一致的
x + 2
}
fn main() {
let a = vec![1, 2, 3];
let b: Vec<i32> = a.iter().map(|x| x + 2).collect();
let c: Vec<i32> = a.iter().map(add_2).collect();
println!("a: {:?}", a);
println!("b: {:?}", b);
println!("c: {:?}", c);
}
這個不常跑所以就不特別寫到批次檔(run.sh/run.ps1)裡,但還是可以在命令列watch起來
~demo-app$ cargo watch -q -c -w ./examples/closure/ -x 'run -p example-closure'
說明:
let b = ...
呼叫map
時使用閉包,丟一個參數,所以閉包相當於是一個迷你版的fn,在其他語言通常叫匿名函數(Anonymous function),也有人叫lambda函數,在js/ts裡又叫arrow function。let c = ...
的map直接呼叫 add_2
這個 fn,這裡的fn是作為參數傳遞,有點像functional promgramming的用法,因為在這裡是當參數傳遞,所以不能加()
,因為我們沒有要在這裡呼叫,一旦在函數後面加括號()
,就表示我們要呼叫執行它,所以參數值拿到的就會變成執行fn的回傳值了。然後我們可以改一下:
fn main() {
let a = vec![1, 2, 3];
let times_2 = |x| x * 2; // closure直接assign給變數
let b: Vec<i32> = a.iter().map(|x| x * 2).collect();
let c: Vec<i32> = a.iter().map(times_2).collect();
println!("a: {:?}", a);
println!("b: {:?}", b);
println!("c: {:?}", c);
// let f = vec![1.2,3.4];
// let d: Vec<f64> = f.iter().map(times_2).collect();
}
上面演示了閉包也可以指派給變數,這個變數雖然我們沒有明確給予型別指派,但rust可以透過上下文推斷出來。如果我們把註解給取消,編譯器就會報錯,說在let d = ...
裡使用的time_2參數應該要是float的類別,但是卻拿到i32:
最後一個例子:
fn main() {
let a = vec![1, 2, 3];
let b: Vec<i32> = a.iter().map(|x| {
println!("正在處理 x: {:?}", x);
x + 2 // 多行的closure,記得最後一行表達式是回傳值
}).collect();
let c: Vec<i32> = a.iter()
.inspect(|x| println!(" x在map前: {:?}", x))
.map(|x| x + 2)
.inspect(|x| println!(" x在map後: {:?}", x))
.collect();
let d: Vec<i32> = a.iter().map(|_| 0).collect();
// let e: Vec<i32> = a.iter().map(|| 0).collect();
println!("a: {:?}", a);
println!("b: {:?}", b);
println!("c: {:?}", c);
println!("d: {:?}", d);
}
let c = ...
用到的inspect,可以檢驗當前的值,方便debug使用,如果又要串.filter.map
...的話就可以幫助我們查驗每個階段的值分別會是什麼。let d = ...
就是乘以0,都是0,所以不需要x,這時候||
裡面還是要放一個佔位符,因為我們變數接到後直接拋棄不用所以寫成_
。如果不放的話,像let e = ...
,就會報錯,因為rust是強型別,要求簽章要一致,我們沒寫出來的i32
等,是rust在背後幫忙補上了,不代表不需要寫哦,把e前面的註解拿掉就會報錯如下:本系列專案源始碼放置於 https://github.com/kenstt/demo-app