Flutter - Dart 3α 新特性 Record 和 Patterns 的提前預覽講解
theme: smartblue
由於 Dart 3 還處於 alpha ,某些細節可能還會有所變化,但是總體設定和大部分細節應該不會變太多,大家可以提前嚐鮮。
更多更新也可以關注官方的 records-feature-specification 和 feature-specification.md 相關進展。
Record 和 Patterns 作為 Dart 3 的 Big Things ,無疑是 Flutter 和 Dart 開發者都十分關注的新特性。
簡單來說,Records 支援高效簡潔地建立匿名複合值,不需要再宣告一個類來儲存,而在 Records 組合資料的地方,Patterns 可以將複合資料分解為其組成部分。
眾所周知 Dart 語言本身一直都 “相對保守”,而這次針對 Records 和 Patterns 的支援卻很“徹底”,屬於全能力的模式匹配,能遞迴匹配,有 condition guards ,對於 Flutter 開發者來說無疑是生產力的大幅提升。
當然,也可能是 Bug 的大幅度提升。
Records
如下方程式碼所示,Records 屬於是一種匿名的不可變聚合型別 ,類似於 Map 和 List ,但是 Records 固定大小,組合更靈活,並且支援不同型別儲存。
dart
var record = (1, a: 2, 3, b: 4);
除了大小固定之外,Records 和 Map 和 List 最大不同就是它支援不同型別聚合儲存,也就是你不用再寫
List<Object>
之類的程式碼來承載資料多樣性。
當然,可能你會覺得,這和我定義一個 Class 來承載不同資料物件有什麼區別?其實還是有很大區別的:
- 定義了類,也就是說你的資料集合需要和特定類耦合
- 使用 Records 就不必宣告對應型別,只要具有相同欄位集的記錄, Dart 就會認為它們是相同型別(這個後面會介紹)
所以從上面可以看到, Records 的出現對於Dart 來說是很重要的能力拓展,儘管對於其他語言這也許並不是什麼新鮮特性。
簡單介紹
對於 Records ,我們拓展前面的程式碼,通過列印對應的數值,可以清晰看到 Records 內數值的獲取方式:通過 $
位置欄位或者命名欄位的方式獲取資料。
dart
var record = (1, a: 2, 3, b: 4);
print(record.$1); // Print "1"
print(record.a); // Print "2"
print(record.$2); // Print "3"
print(record.b); // Print "4"
在 Records 的變更記錄裡:現在 Records 開始位置記錄是從
$1
開始,而不是$0
,但是 DartPad 上你可能還會遇到需要從$0
開始。
而定義 Records 是通過 ()
和 ",
" 實現,為什麼要有 ",
" ,如下程式碼所示:
dart
var num = (123); // num
var records = (123,); // record
- 如果沒有 "
,
" ,那麼(123)
就是一個 num 型別的物件 - 有 "
,
" 之後(123,)
才會被識別為是一個 Records 型別
所以,作為一個集合型別,Records 也是可以用來宣告變數,比如:
dart
(bool, num, {int n, String s}) records;
records = (false, 1, n: 12, s : "xxx");
print(records);
當然,如果你如下程式碼一樣賦值就會收穫一個 can't be assigned to a variable of type
的錯誤,因為它們型別不相同,Records 是固定大小的:
dart
records = (false, 1, s : "xxx2");
records = (false, 1, n : 12);
而 Records 上的命名欄位主要在於可以如下這樣賦值:
dart
records = (false, 1, s : "xxx2", n : 12);
records = (s : "xxx2", n : 12, false, 1, );
print(records);
最後,在 Records 的定義裡需要遵循以下規則:
- 同一命名欄位名稱只能出現一次,這個不難理解,比如上面程式碼你不可能定義兩個
s
。 (,)
這樣的表示式是不允許的,但是()
可以是沒有任何欄位的常量空 Records- 有引數但是隻有
()
沒有 ",
" 也不是 Records ,如(6)
- 命令為
hashCode
、runtimeType
、noSuchMethod
, 、toString
的欄位是不允許的 - 以下劃線開頭的命令欄位是不允許的
- 與位置欄位名稱衝突的命令欄位,比如
('pos', $1: 'named')
這樣是不行的,但是($1: 'records')
這樣可以
知道了 Records 的大概邏輯之後,這裡面有個有趣的設定,比如:
dart
var t = (int, String);
print(t);
print(t.$0.runtimeType);
print(t.$1.runtimeType);
通過列印你會發現 t
裡面的 $0
和 $1
是 _Type
型別,也就是如果後面再寫 t = (1, "fff");
,就會收穫這樣的錯誤
其實這個例子沒什麼實際意義,注意強調一下
var t = (int, String);
和(int, String) t
的區別。
最後簡單介紹下 Records 的型別關係:
Record
是Object
、dynamic
的子類和Never
的父類- 所有的 Records 都是
Record
的子類和Never
的父類
如果拓展到 Records 之間進行比較,假設有 A、B 兩個都是 Records 物件,而 B 在和 A 具有相同 shape 的前提下,所有的欄位都是 A 裡欄位的子類,那麼 Records B 可以認為是 Records A 的子類。
進階探索
前面我們介紹過,在 Records 裡,只要具有相同欄位集的記錄, Dart 就會認為它們是相同型別,這怎麼理解呢?
首先需要確定的是,Records 型別裡命名欄位的順序並不重要,就是 {int a, int b}
與{int b, int a}
的型別系統和 runtime 會完全相同。
另外位置欄位不僅僅是名為
$1
、$2
這樣的欄位語法糖,('a', 'b')
和($1: 'a', $2: 'b')
從外部看是具有相同的 members ,只是具有不同的 shapes。
例如 (1.2, name: 's', true, count: 3)
的簽名大概會是這樣:
dart
class extends Record {
double get $1;
String get name;
bool get $2;
int get count;
}
Records 裡每個欄位都有 getter ,並且欄位是不可變的,所以不會又 Setter。
所以由於 Records 本身資料複雜性等原因,所以設定上 Records 的標識就是它的內容,也就是具有相同 shape 和欄位的兩條 Records 是相等的值。
dart
print((a: 1, b: 2) == (b: 2, a: 1)); // true
當然,如果是以下這種情況,因為位置引數順序不一樣,所以它們並不相等,因為 shape 不同,會輸出 false
。
dart
print((true, 2, a: 1, b: 2,) == (2, true, b: 2, a: 1)); // false
同時,Records 執行時的型別由其欄位的執行時的型別確定,例如:
dart
(num, Object) pair = (1, 2.3);
print(pair is (int, double)); // "true".
這裡執行時 pair
是 (int, double)
,不是(num, Object)
,雖然官方文件是這麼提供的,但是 Dartpad 上驗證目前卻很有趣,大家可以自行體會:
我們再看個例子,如下程式碼所示, Records 是可以作為用作 Map 裡的 key 值,因為它們的 shape 和 value 相等,所以可以提取出 Map 裡的值。
dart
var map = {};
map[(1, "aa")] = "value";
print(map[(1, "aa")]); //輸出 "value"
如果我們定義一個 newClass
, 如下程式碼所示,可以預料到輸出結果會是 null
,因為兩個 newClass
並不相等。
```dart
class newClass {
}
var map = {}; map[(1, new newClass())] = "value"; print(map[(1, new newClass())]); //輸出 "null"
```
但是如果給 newClass
的 ==
和 hashCode
進行override
,就可以又看到輸出 "value"
的結果。
```dart class newClass {
@override bool operator ==(Object other) { return true; }
@override int get hashCode => 1111111;
} ```
所以到這裡,你應該就理解了“只要具有相同欄位集的記錄, Dart 就會認為它們是相同型別”這句話的含義。
最後再介紹一個 Runtime 時的特性, Records 中的欄位是從左到右計算的,即使後續實現選擇了重新排序命名欄位也是如此,例如:
```dart int say(int i) { print(i); return i; }
var x = (a: say(1), b: say(2)); var y = (b: say(3), a: say(4));
```
上門結果一定是列印 “1”、“2” / “3”、“4” , 就算是下面程式碼的排列,也是輸出 “0”、“1”、“2” / “3”、“4”、“5” 。
dart
var x = (say(0), a: say(1), b: say(2));
var y = (b: say(3), a: say(4), say(5));
Records 帶來的語法歧義
因為 Dart 3 的 Records 是在以前版本的基礎上升級的,那麼一些語法相容就是必不可少的,這裡整理一下目前官方羅列出來的常見調整。
try/on
首先是 try/on
相關語法, 如果按照以前的設定,第二行的 on
應該是被識別為一個區域性函式,但是在增加了 Records 之後,現在它是可以匹配的 on
Records 型別。
```dart void recordTryOn() { try { } on String { }
on(int, String) {
}
} ```
這裡宣告的型別其實沒什麼意義,只是為了形象展示對比
鑑於消除歧義的目的,如果在早於 Records 支援版本里,on
關鍵字後帶 ()
這樣的型別,將直接被語法解析為 Records 型別,提示為語法錯誤,因為該 Dart 版本不支援 Records 型別。
metadata 註解
如下程式碼所示,因為多了 Records 之後,註解的理解上可能就會多了一些語法歧義:
dart
@metadata (a, b) function() {}
如果不約定好理解,這可能是:
@metadata(a, b)
與沒有返回型別的函式宣告關聯的metadata 註解@metadata
與返回型別為 Records 型別的函式關聯的metadata 註解(a, b)
所以這裡主要通過空格來約定,儘管這樣很容易出現紕漏:
```dart @metadata(a, b) function() {}
@metadata (a, b) function() {} ```
- 前者由於
@metadata
之後沒有空格,所以表示為(a, b)
的 metadata 註解 - 前者由於有空格,所以表示為 Records 返回型別
它們的不同之處可以參考下面的兩種型別:
```dart
// Records 和 metadata 是一起作用在 a
@metadata(x, y) a;
@metadata
// Records 是直接作用在 a ,和 metadata 無關 @metadata (x, y) a;
@metadata (x, y) a;
@metadata/ comment /(x, y) a;
@metadata // Comment. (x,) a; ```
舉個例子,比如下面這種情況 @TestMeta(1, "2")
沒有空格,所以不會有語法錯誤
```dart @TestMeta(1, "2") class C {}
class TestMeta { final String message; final num code;
const TestMeta(this.code, this.message);
@override String toString() => "feature: $code, $message"; } ```
但是如果是 @TestMeta (1, "2")
,就會有 Annotations can't have spaces or comments before the parenthesis.
這樣的錯誤提示。
dart
@TestMeta (1, "2") //Error
class C {}
所以有無空格對於 metadata 註解來說將會變得完全不一樣,可能這對一些第三方外掛的適配使用上會有一定 breaking change。
toString
在 Debug 版本中,Records 的 toString()
方法會通過呼叫每個欄位的 toString()
值,並在其前面加上欄位名稱,後續是否新增 :
字元取決於欄位是否為命名欄位,最終會將每個欄位轉換為字串。
看下面例子可能會更形象。
每個欄位會利用 ,
作為分隔符連線起來,並返回用括號括起來的結果,例如:
print((1, 2, 3).toString()); // "(1, 2, 3)".
print((a: 'str', 'int').toString()); // "(a: str, int)".
在 Debug 版本中,命名欄位出現的順序以及它們如何與位置欄位進行排列是不確定的,只有位置欄位必須按位置順序出現。
所以 toString 內部實現可以自由地為命名欄位選擇規範順序,而與建立記錄的順序無關。
而在釋出或優化構建中,toString()
行為是更不確定的, 所以可能會有選擇地丟棄命名欄位的全名以減少程式碼大小等操作。
所以使用者最好只將 Records 的
toString()
用於除錯,強烈建議不要解析呼叫結果toString()
或依賴它來獲得某些邏輯判斷,避免產生歧義。
Patterns
如果只是單純 Records 可能還看不到巨大的價值,但是如果配合上 Patterns ,那開發效率就可以得到進一步提升,其中最值得關注的就是多個返回值的支援。
簡單介紹
關於 Patterns 這裡不會有太長的篇幅,首先目前 Patterns 在 DartPad 上還是 disabled 的狀態,其次 Patterns 的複雜度和帶來的語法歧義問題實在太多,它目前還具有太多未確定性。
從提案上看,未來感覺也不會一次性所有能力全部發布。
多返回值
回到主題,我們知道,使用 Records 可以讓我們的方法實現多個返回值,例如下面程式碼的實現
```dart (double, double) geoCode(String city) { var lat = // Calculate... var long = // Calculate...
return (lat, long); // Wrap in record and return. } ```
但是當我們需要獲取這些值的時候,就需要 Patterns 的解構賦值,例如:
dart
var (lat, long) = geoCode('Aarhus');
print('Location lat:$lat, long:$long');
當然 Patterns 下的解構賦值不只是針對 Records ,例如對 List
或者 Map
也可以:
```dart var list = [1, 2, 3]; var [a, b, c] = list; print(a + b + c); // 6.
var map = {'first': 1, 'second': 2}; var {'first': a, 'second': b} = map; print(a + b); // 3. ```
更近一步還可以解構並分配給現有變數:
dart
var (a, b) = ('left', 'right');
(b, a) = (a, b); // Swap!
print('$a $b'); // Prints "right left".
有沒有覺得程式碼變得難閱讀了?哈哈哈哈
代數資料型別
就如 Flutter Forward 介紹那樣,現在類層次結構基本上已經可以對代數資料型別進行建模,Patterns 下提供了新的模式匹配結構,例如程式碼可以變成這樣:
```dart ///before double calculateArea(Shape shape) { if (shape is Square) { return shape.length + shape.length; } else if (shape is Circle) { return math.pi * shape.radius * shape.radius; } else { throw ArgumentError("Unexpected shape."); } }
//after double calculateArea(Shape shape) => switch (shape) { Square(length: var l) => l * l, Circle(radius: var r) => math.pi * r * r }; ```
甚至
switch
都不需要新增case
關鍵字,並且用上了後面會簡單介紹的可變模式。
Patterns
目前 Dart 上 Patterns 的設定還挺複雜,簡單來說是:
通過一些簡潔、可組合的符號,排列後確定一個物件是否符合條件,並從中解構出資料,然後僅當所有這些都為 true 時才執行程式碼。
也就是你會看到一系列充滿操作符的簡短程式碼,如 "||"
、 " && "
、 "=="
、 "<"
、 "as"
、 "?"
、 "_"
、"[]"
、"()"
、"{}"
等的排列組合,並嘗試逐個去理解它們,例如:
dart
var isPrimary = switch (color) {
Color.red || Color.yellow || Color.blue => true,
_ => false
};
用 "||"
可以在 switch 中讓多個 case 共享一個主體,"_"
表示預設,甚至如下程式碼所示,你還可以在繫結 s
之後,多個共享一個 when
條件:
dart
switch (shape) {
case Square(size: var s) || Circle(size: var s) when s > 0:
print('Non-empty symmetric shape');
case Square() || Circle():
print('Empty symmetric shape');
default:
print('Asymmetric shape');
}
這種寫法可以大大優化 switch
的結構 ,如下所示可以看到,類似寫法程式碼得到了很大程度的精簡:
```dart String asciiCharType(int char) { const space = 32; const zero = 48; const nine = 57;
return switch (char) { < space => 'control', == space => 'space', > space && < zero => 'punctuation', >= zero && <= nine => 'digit' // Etc... } } ```
當然,還有一些很奇葩的設定,比如利用 ?
匹配非空值,很明顯這樣的寫法很反直覺,最終是否這樣落地還是要看社群討論的結果:
dart
String? maybeString = ...
switch (maybeString) {
case var s?:
// s has type non-nullable String here.
}
更進一步還有在解構的 position 賦值時通過 !
強制轉為非空,還有在 switch 匹配時第一個列為 'user'
時 name
不為空。
```dart (int?, int?) position = ...
// We know if we get here that the coordinates should be present: var (x!, y!) = position;
List
// If the first column is 'user', we expect to have a name after it. switch (row) { case ['user', var name!]: // name is a non-nullable string here. } ```
如果搭配上 Records 就更難理解了,比如下程式碼,可變 pattern 將匹配值繫結到新變數,這裡的 var a
和 var b
是可變模式,最終分別繫結到 1
和 2
上。
```dart switch ((1, 2)) { case (var a, var b): ... }
switch (record) { case (int x, String s): print('First field is int $x and second is String $s.'); } ```
其實就類似於 Flutter Forword 介紹的能力,case
下可以做對應的繫結,如上 switch (record)
也是類似這種繫結。
如果使用變數的名稱是
_
,那麼它不繫結任何變數
更多的可能還有如 List、 Map 、 Records、 Object 等相關的 pattern 匹配等,可以看到 Patterns 將很大程度改變 Dart 程式碼的編寫和邏輯組織風格:
```dart var list = [1, 2, 3]; var [, two, ] = list;
var [a, b, ...rest, c, d] = [1, 2, 3, 4, 5, 6, 7]; print('$a $b $rest $c $d'); // Prints "1 2 [3, 4, 5] 6 7".
// Variable: var (untyped: untyped, typed: int typed) = ... var (:untyped, :int typed) = ...
switch (obj) { case (untyped: var untyped, typed: int typed): ... case (:var untyped, :int typed): ... }
// Null-check and null-assert: switch (obj) { case (checked: var checked?, asserted: var asserted!): ... case (:var checked?, :var asserted!): ... }
// Cast: var (field: field as int) = ... var (:field as int) = ...
class Rect { final double width, height;
Rect(this.width, this.height); }
display(Object obj) { switch (obj) { case Rect(width: var w, height: var h): print('Rect $w x $h'); default: print(obj); } } ```
從目前看來,這會是一種自己寫起來很爽,別人看起來可能很累的特性,同時也可能會帶來不少的 breaking change ,更多詳細可見:patterns-feature-specification
好了,關於 Patterns 的這裡就不再繼續展開,它落地會如何最終還不完全確定,但是從我的角度來看,它絕對會是一把雙刃劍,希望 Patterns 到來的同時不會引入太多的 Bug。
最後
其實我相信大多數人可能都只關心 Records 和解構賦值,從而實現函式的多返回值能力,這對我們來說是最直觀和最實用的。
至於 switch 如何匹配和 Patterns 如何精簡程式碼結構,這都是後話了。
現在,或者你可以選擇 Dart 3 嚐嚐鮮了~
- 面向 ChatGPT 開發 ,我是如何被 AI 從 “逼瘋” 到 “覺悟” ,未來又如何落地
- 維護高 Star Github 專案,會遇到什麼有趣的問題 2023 版
- Flutter - Dart 3α 新特性 Record 和 Patterns 的提前預覽講解
- 2023 年第一彈, Flutter 3.7 釋出啦,快來看看有什麼新特性
- 2023 Flutter Forward 大會回顧,快來看看 Flutter 的未來會有什麼
- Flutter 的下一步, Dart 3 重大變更即將在 2023 到來
- Flutter 小技巧之快速理解手勢邏輯
- 一文快速帶你瞭解 KMM 、 Compose 和 Flutter 的現狀
- Flutter 工程化框架選擇 — 混合開發的摸爬滾打
- 如何利用 Flutter 實現炫酷的 3D 卡片和帥氣的 360° 展示效果
- Flutter 工程化框架選擇——搞定 Flutter 動畫
- Flutter 實現 “真” 3D 動畫效果,用純程式碼實現立體 Dash 和 3D 掘金 Logo
- Android Studio Dolphin | 2021.3.1 釋出,快來看看有什麼更新吧~
- 掘金 XDC 2022 - 普通技術人的彎道超車指南
- Flutter 工程化框架選擇 — 搞定 UI 生產力
- Dart 2.18 釋出,Objective-C 和 Swift interop
- Flutter 工程化框架選擇 — 搞定資料儲存選型
- 2022 年 App 上架稽核問題集錦,全面踩坑上線不迷路
- React Native 0.70 版本釋出,Hermes 終於成為預設 Engine
- Flutter 3.3 之 SelectionArea 好不好用?用 “Bug” 帶你全面瞭解它