哈嘍,我是老劉
前兩天的文章講了老劉對Dart宏功能的期待和Dart官方取消宏的一點觀點。
Dart的宏取消了,期待3年的功能,説沒就沒了?
有人評論説高級編程語言是不需要宏功能的。
雖然老劉自己是非常支持宏的,但是不得不説這個觀點其實也是有一定的道理的。
為啥這麼説呢?
接下來我就來對比一下C語言的宏和Dart的build_runner,看看各自的優劣在哪裏。
相信對比完大家也就能理解兩種處理代碼的方式各自的優劣,以及Dart團隊在宏功能上的野望。
相同點:編譯時代碼生成的共同優勢
雖然C語言宏和Dart的build_runner在實現機制上截然不同,但它們的目標卻是一致的。
都是為了在編譯之前就把事情搞定,避免運行時的各種開銷。
第一個共同點:編譯前代碼生成
想象一下,你寫了一個JSON序列化的代碼。
如果用運行時反射,每次序列化都要通過反射去讀寫對象的屬性。
但如果用編譯前代碼生成,編譯器直接幫你生成好了專門的序列化代碼。
運行時直接調用,速度肯定是提升了很多。
第二個共同點:告別樣板代碼地獄
寫過Java的同學都知道,getter和setter能把人寫到懷疑人生。
寫過C++的同學也知道,各種重複的函數聲明和實現能讓人抓狂。
C語言宏可以這樣解決:
#define GETTER_SETTER(type, name) \
type get_##name() { return this->name; } \
void set_##name(type value) { this->name = value; }
Dart的build_runner也能實現類似的功能,比如json_annotation:
@JsonSerializable()
class User {
final String name;
final int age;
}
編譯器自動生成fromJson和toJson方法。
一行註解,省下幾十行代碼。
關鍵差異:實現機制的不同
説完了相同點,咱們來聊聊最核心的差異。
文本替換 vs 語義理解:兩種截然不同的技術路線
C語言宏:文本替換
C語言的宏本質上就是一個文本替換工具。
編譯器在詞法分析之前,預處理器就把宏展開了。
舉個例子:
#define PI 3.14159
#define SQUARE(x) ((x) * (x))
double area = PI * SQUARE(radius);
預處理器會直接替換成:
double area = 3.14159 * ((radius) * (radius));
C語言的宏本質上就是文本替換。
預處理器不理解代碼語義,只是把宏名替換成宏體。
當然這個替換不是簡單機械的,而是可以通過條件判斷來實現不同場景的替換。
Dart build_runner:基於AST的智能生成
Dart的build_runner完全不同。
它工作在AST(抽象語法樹)層面,真正理解Dart語言的語法和語義。
比如json_serializable這個包:
@JsonSerializable()
class User {
final String name;
final int? email;
final DateTime createdAt;
}
build_runner會:
- 解析這個類的AST結構
- 理解每個字段的類型
- 知道哪些字段可以為null
- 生成類型安全的序列化代碼
生成的代碼可能是這樣的:
Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
'name': instance.name,
'email': instance.email,
'createdAt': instance.createdAt.toIso8601String(),
};
注意,它知道DateTime需要調用toIso8601String()方法。
這就是語義理解的威力。
類型安全性:天壤之別
C語言宏最大的問題就是沒有類型檢查。
#define SWAP(a, b) { typeof(a) temp = a; a = b; b = temp; }
int x = 5;
char* y = "hello";
SWAP(x, y); // 預編譯通過,但邏輯錯誤
而Dart的build_runner生成的代碼是完全類型安全的:
// 如果你的字段類型不支持JSON序列化
class User {
final File file; // File類型無法序列化
}
build_runner會直接報錯,告訴你這個類型不支持序列化。
錯誤信息清晰明瞭,開發時就能發現問題。
作用域和衞生性
C語言宏還有一個致命問題:不衞生(non-hygienic)。
#define SWAP(a, b) { int temp = a; a = b; b = temp; }
int main() {
int temp = 10;
int x = 5, y = 7;
SWAP(x, y); // temp變量衝突!
}
宏展開後:
int main() {
int temp = 10;
int x = 5, y = 7;
{ int temp = x; x = y; y = temp; } // 變量名衝突
}
這種問題在複雜項目中就可能會造成很多難以定位的bug。
Dart的build_runner完全不會有這個問題。
它生成的代碼遵循正常的Dart作用域規則,不會產生任何意外的變量捕獲。
生成的代碼就像手寫的一樣規範。
C語言宏的獨特優勢
説了這麼多build_runner的好處,是不是覺得C語言宏已經過時了?
別急,老劉要為C語言宏正名了。
有些事情,build_runner真的做不到,而C語言宏卻能輕鬆搞定。
真正的條件編譯
最典型的例子就是DEBUG_PRINT。
在C語言中,你可以這樣寫:
#ifdef DEBUG
#define DEBUG_PRINT(x) printf("Debug: %s\n", x); expensive_memory_check()
#else
#define DEBUG_PRINT(x) // 完全消失,不產生任何代碼
#endif
注意這裏的關鍵:在Release模式下,DEBUG_PRINT(x)這行代碼完全不存在。
不是被優化掉,不是被跳過,而是根本就沒有這行代碼。
連函數調用的開銷都沒有,連參數計算的開銷都沒有。
這就是真正的條件編譯。
Dart的替代方案
Dart當然也有類似的機制,比如kDebugMode常量:
if (kDebugMode) {
print('Debug: $message');
expensiveMemoryCheck();
}
看起來很像對吧?
但是有個問題:雖然kDebugMode是編譯時常量,但是這些代碼是真實存在的。
雖然可以通過Dart的tree-shaking能力來移除這些代碼,但是這個效果還是有限的。
特別是當你的調試代碼比較複雜時,編譯器不一定能完全優化掉。
而且另一個問題是每次打印debug信息都需要手動添加kDebugMode判斷,真正寫過代碼的人都知道這是個非常麻煩的事。
還有assert語句:
assert(() {
print('Debug: $message');
expensiveMemoryCheck();
return true;
}());
assert在release模式下確實會被完全移除。
但是語法太不優雅了,而且使用場景受限。
你總不能把所有調試代碼都塞到assert裏面吧?
build_runner的根本侷限
為什麼build_runner做不到真正的條件編譯?
這是由它的工作機制決定的。
時機問題:build_runner是在編譯前生成代碼,它無法根據編譯模式(debug/release)來差異化生成代碼。
作用域限制:build_runner只能生成新的文件,無法在調用點進行條件替換。
語義層面:build_runner工作在AST(抽象語法樹)層面,而條件編譯需要更底層的預處理器支持。
舉個例子,你想用build_runner實現DEBUG_PRINT,最多隻能生成這樣的代碼:
void debugPrint(String message) {
if (kDebugMode) {
print('Debug: $message');
}
}
但這樣的話,函數調用本身還是存在的。
參數的計算也還是存在的。
如果你傳給debugPrint的調試信息需要複雜計算,這些計算在release模式下依然會執行。
性能敏感場景的差異
在一些性能敏感的場景下,這種差異就很明顯了。
比如遊戲引擎的渲染循環,每幀可能要執行數萬次的調試檢查。
C語言宏可以讓這些檢查在release版本中完全消失。
而Dart的方案,即使被優化,也可能留下一些痕跡。
當然,對於大多數應用開發來説,這點性能差異可能不重要。
但在某些特定場景下,這就是C語言宏不可替代的優勢。
開發者體驗
説完了技術層面的差異,我們再來聊聊開發者體驗。
C語言宏:無感知的完美體驗
C語言宏最牛逼的地方,就是你完全感覺不到它的存在。
寫代碼的時候,你就像寫普通代碼一樣。
編譯的時候,編譯器自動幫你處理好一切。
IDE的語法高亮、代碼補全、錯誤提示,全都是即時可用的。
你不需要記住任何額外的命令。
不需要等待任何生成過程。
不需要擔心生成的代碼是否最新。
舉個例子,你在寫一個C項目,定義了一個DEBUG_PRINT宏:
#ifdef DEBUG
#define DEBUG_PRINT(fmt, ...) printf("Debug: " fmt "\n", ##__VA_ARGS__)
#else
#define DEBUG_PRINT(fmt, ...)
#endif
然後在代碼裏直接用:
DEBUG_PRINT("User login: %s", username);
IDE立馬就能識別這個宏,給你語法高亮。
編譯的時候,根據是否定義了DEBUG,這行代碼要麼變成printf調用,要麼完全消失。
整個過程,你作為開發者完全不需要干預任何事情。
build_runner:需要人工干預
再看看Dart的build_runner,體驗就完全不同了。
首先,你得記住一堆命令:
dart run build_runner build # 生成代碼
dart run build_runner watch # 監聽文件變化
dart run build_runner clean # 清理生成的文件
這還只是基礎的。
如果你想要增量構建,你得用watch模式。
但watch模式有時候會卡住,你得手動重啓。
更要命的是,在代碼生成之前,IDE會顯示一堆錯誤。
比如你寫了這樣的代碼:
@JsonSerializable()
class User {
final String name;
final int age;
User({required this.name, required this.age});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
在你運行build_runner之前,IDE會告訴你:
"_$UserFromJson未定義"
"_$UserToJson未定義"
滿屏的紅色波浪線。
新手看到這種情況,第一反應可能是:我是不是寫錯了什麼?
IDE集成的不完善
雖然現在的IDE(比如VS Code、Android Studio)對build_runner有一定的支持。
但遠遠達不到C語言宏那種無縫集成的程度。
比如,你在VS Code裏修改了一個帶註解的類。
IDE不會自動觸發代碼生成。
你得手動運行命令,或者依賴watch模式。
而watch模式有時候會出現問題:
- 文件變化沒有被檢測到
- 生成過程卡住了
- 和其他工具衝突
這些問題,在C語言的宏系統裏是不存在的。
總結一下,在開發者體驗方面:
C語言宏:
- 零干預,完全透明
- IDE支持完美
- 無需記憶額外命令
- 無穩定性問題
build_runner:
- 需要手動執行命令
- IDE集成不完善
- 需要學習和記憶工作流程
- 存在穩定性和性能問題
- 多包項目複雜性高
這就是為什麼很多開發者懷念宏功能的原因。
不僅僅是因為build_runner功能不夠強大。
還是因為它在用户體驗上,確實有很大的改進空間。
相信看到這裏大家也能明白當初dart團隊對宏的野心有多大了,他們既想要build_runner的先進性,也想要C語言宏的各種優勢。
但是通過前面的對比我們可以發現,這兩者各自的優勢都直指對方的劣勢。
而這種結果都是源於他們底層的實現邏輯。
想要不付出任何代價的集合兩者的優勢,同時又不引入任何的副作用,似乎有點異想天開。
我想可能這也就是為什麼Dart的宏功能在這樣龐大的構想下,開發中碰到了各種難以解決的問題,並最終被放棄的底層原因。
七、結論:技術選擇的多維度思考
通過前面的深度對比,老劉想説一個很重要的觀點:
技術沒有絕對的先進和落後,只有適合和不適合。
很多人一聽到"宏"這個詞,就覺得這是古老的、落後的技術。
但事實上,在某些特定場景下,C語言宏的簡潔和高效,是build_runner無法比擬的。
同樣,很多人覺得build_runner複雜、麻煩。
但在類型安全、語義理解、代碼質量方面,它確實比傳統宏要先進得多。
也許未來的技術發展方向,就是在保持先進性的同時,不斷優化開發者體驗。
讓複雜的技術變得簡單易用,讓強大的功能變得透明無感。
這可能才是技術進步的真正意義。
真正的技術高手,不是隻推崇一種方案的專家,而是能在不同場景選擇最適合工具的智者。技術的先進性不僅體現在功能上,更體現在對開發者體驗的關注上。
好了,如果看到這裏的同學對客户端、Flutter開發或者MCP感興趣,歡迎聯繫老劉,我們互相學習。
點擊免費領老劉整理的《Flutter開發手冊》,覆蓋90%應用開發場景。
可以作為Flutter學習的知識地圖。
覆蓋90%開發場景的《Flutter開發手冊》