Flutter - Dart 3α 新特性 Record 和 Patterns 的提前預覽講解

由于 Dart 3 還處于 alpha ,某些細節可能還會有所變化,但是總體設定和大部分細節應該不會變太多,大家可以提前嘗鮮。

更多更新也可以關注官方的 records-feature-specificationfeature-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 固定大小,組合更靈活,并且支持不同類型存儲。

var record = (1, a: 2, 3, b: 4);

除了大小固定之外,Records 和 Map 和 List 最大不同就是它支持不同類型聚合存儲,也就是你不用再寫 List<Object> 之類的代碼來承載數據多樣性。

當然,可能你會覺得,這和我定義一個 Class 來承載不同數據對象有什么區別?其實還是有很大區別的:

  • 定義了類,也就是說你的數據集合需要和特定類耦合
  • 使用 Records 就不必聲明對應類型,只要具有相同字段集的記錄, Dart 就會認為它們是相同類型(這個后面會介紹)

所以從上面可以看到, Records 的出現對于Dart 來說是很重要的能力拓展,盡管對于其他語言這也許并不是什么新鮮特性。

簡單介紹

對于 Records ,我們拓展前面的代碼,通過打印對應的數值,可以清晰看到 Records 內數值的獲取方式:通過 $ 位置字段或者命名字段的方式獲取數據

  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 是通過 () 和 "," 實現,為什么要有 "," ,如下代碼所示:

  var num = (123);      // num
  var records = (123,); // record
  • 如果沒有 "," ,那么 (123) 就是一個 num 類型的對象
  • 有 "," 之后 (123,) 才會被識別為是一個 Records 類型

所以,作為一個集合類型,Records 也是可以用來聲明變量,比如:

  (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 是固定大小的:

  records = (false, 1,  s : "xxx2");
  records = (false, 1,  n : 12);

而 Records 上的命名字段主要在于可以如下這樣賦值:

  records = (false, 1, s : "xxx2",  n : 12);
  records = (s : "xxx2",  n : 12, false, 1, );
  print(records); 

最后,在 Records 的定義里需要遵循以下規則:

  • 同一命名字段名稱只能出現一次,這個不難理解,比如上面代碼你不可能定義兩個 s
  • (,) 這樣的表達式是不允許的,但是 () 可以是沒有任何字段的常量空 Records
  • 有參數但是只有 () 沒有 "," 也不是 Records ,如 (6)
  • 命令為 hashCoderuntimeTypenoSuchMethod, 、toString 的字段是不允許的
  • 以下劃線開頭的命令字段是不允許的
  • 與位置字段名稱沖突的命令字段,比如 ('pos', $1: 'named') 這樣是不行的,但是 ($1: 'records') 這樣可以

知道了 Records 的大概邏輯之后,這里面有個有趣的設定,比如:

   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 的類型關系:

  • RecordObjectdynamic 的子類和 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) 的簽名大概會是這樣:

class extends Record {
  double get $1;
  String get name;
  bool get $2;
  int get count;
}

Records 里每個字段都有 getter ,并且字段是不可變的,所以不會又 Setter

所以由于 Records 本身數據復雜性等原因,所以設定上 Records 的標識就是它的內容,也就是具有相同 shape 和字段的兩條 Records 是相等的值

print((a: 1, b: 2) == (b: 2, a: 1)); // true

當然,如果是以下這種情況,因為位置參數順序不一樣,所以它們并不相等,因為 shape 不同,會輸出 false

print((true, 2, a: 1, b: 2,) == (2, true, b: 2, a: 1)); // false

同時,Records 運行時的類型由其字段的運行時的類型確定,例如:

(num, Object) pair = (1, 2.3);
print(pair is (int, double)); // "true".

這里運行時 pair(int, double),不是(num, Object) ,雖然官方文檔是這么提供的,但是 Dartpad 上驗證目前卻很有趣,大家可以自行體會:

我們再看個例子,如下代碼所示, Records 是可以作為用作 Map 里的 key 值,因為它們的 shape 和 value 相等,所以可以提取出 Map 里的值。

  var map = {};
    map[(1, "aa")] = "value";
  print(map[(1, "aa")]); //輸出 "value"

如果我們定義一個 newClass , 如下代碼所示,可以預料到輸出結果會是 null ,因為兩個 newClass 并不相等。


  class newClass  {

  }

  var map = {};
  map[(1, new newClass())] = "value";
  print(map[(1, new newClass())]); //輸出 "null"

但是如果給 newClass==hashCode 進行override,就可以又看到輸出 "value" 的結果。

class newClass  {

  @override
  bool operator ==(Object other) {
    return true;
  }

  @override
  int get hashCode => 1111111;

}

所以到這里,你應該就理解了“只要具有相同字段集的記錄, Dart 就會認為它們是相同類型”這句話的含義。

最后再介紹一個 Runtime 時的特性, Records 中的字段是從左到右計算的,即使后續實現選擇了重新排序命名字段也是如此,例如:

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”

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 類型。

  void recordTryOn() {
    try {
    } on String {
    } 

    on(int, String) {
    }
  }

這里聲明的類型其實沒什么意義,只是為了形象展示對比

鑒于消除歧義的目的,如果在早于 Records 支持版本里,on 關鍵字后帶 () 這樣的類型,將直接被語法解析為 Records 類型,提示為語法錯誤,因為該 Dart 版本不支持 Records 類型。

metadata 注解

如下代碼所示,因為多了 Records 之后,注解的理解上可能就會多了一些語法歧義:

@metadata (a, b) function() {}

如果不約定好理解,這可能是:

  • @metadata(a, b) 與沒有返回類型的函數聲明關聯的metadata 注解
  • @metadata與返回類型為 Records 類型的函數關聯的metadata 注解 (a, b)

所以這里主要通過空格來約定,盡管這樣很容易出現紕漏:

@metadata(a, b) function() {}

@metadata (a, b) function() {}
  • 前者由于 @metadata 之后沒有空格,所以表示為 (a, b) 的 metadata 注解
  • 前者由于有空格,所以表示為 Records 返回類型

它們的不同之處可以參考下面的兩種類型:

//  Records 和 metadata 是一起作用在 a 
@metadata(x, y) a;
@metadata<T>(x, y) a;
@metadata <T>(x, y) a;

//  Records 是直接作用在 a ,和 metadata 無關
@metadata (x, y) a;

@metadata
(x, y) a;

@metadata/* comment */(x, y) a;

@metadata // Comment.
(x,) a;

舉個例子,比如下面這種情況 @TestMeta(1, "2") 沒有空格,所以不會有語法錯誤

@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. 這樣的錯誤提示。

@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 可以讓我們的方法實現多個返回值,例如下面代碼的實現

(double, double) geoCode(String city) {
  var lat = // Calculate...
  var long = // Calculate...

  return (lat, long); // Wrap in record and return.
}

但是當我們需要獲取這些值的時候,就需要 Patterns 的解構賦值,例如:

var (lat, long) = geoCode('Aarhus');
print('Location lat:$lat, long:$long');

當然 Patterns 下的解構賦值不只是針對 Records ,例如對 List 或者 Map 也可以:

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.

更近一步還可以解構并分配給現有變量:

var (a, b) = ('left', 'right');
(b, a) = (a, b); // Swap!
print('$a $b'); // Prints "right left".

有沒有覺得代碼變得難閱讀了?哈哈哈哈

代數數據類型

就如 Flutter Forward 介紹那樣,現在類層次結構基本上已經可以對代數數據類型進行建模,Patterns 下提供了新的模式匹配結構,例如代碼可以變成這樣:

///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""?""_""[]""()""{}"等的排列組合,并嘗試逐個去理解它們,例如:

var isPrimary = switch (color) {
  Color.red || Color.yellow || Color.blue => true,
  _ => false
};

"||" 可以在 switch 中讓多個 case 共享一個主體,"_" 表示默認,甚至如下代碼所示,你還可以在綁定 s 之后,多個共享一個 when 條件:

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 的結構 ,如下所示可以看到,類似寫法代碼得到了很大程度的精簡:

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...
  }
}

當然,還有一些很奇葩的設定,比如利用 ? 匹配非空值,很明顯這樣的寫法很反直覺,最終是否這樣落地還是要看社區討論的結果:

String? maybeString = ...
switch (maybeString) {
  case var s?:
    // s has type non-nullable String here.
}

更進一步還有在解構的 position 賦值時通過 ! 強制轉為非空,還有在 switch 匹配時第一個列為 'user'name 不為空。

(int?, int?) position = ...

// We know if we get here that the coordinates should be present:
var (x!, y!) = position;

List<String?> row = ...

// 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 avar b 是可變模式,最終分別綁定到 12 上。

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 代碼的編寫和邏輯組織風格

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 嘗嘗鮮了~

作者:戀貓de小郭
鏈接:https://juejin.cn/post/7194741144482218045

文末福利

如果想要成為架構師或想突破20~30K薪資范疇,那就不要局限在編碼,業務,要會選型、擴展,提升編程思維。此外,良好的職業規劃也很重要,學習的習慣很重要,但是最重要的還是要能持之以恒,任何不能堅持落實的計劃都是空談。

如果你沒有方向,這里給大家分享一套由阿里高級架構師編寫的《Android八大模塊進階筆記》,幫大家將雜亂、零散、碎片化的知識進行體系化的整理,讓大家系統而高效地掌握Android開發的各個知識點。

相對于我們平時看的碎片化內容,這份筆記的知識點更系統化,更容易理解和記憶,是嚴格按照知識體系編排的。

全套視頻資料:

一、面試合集

二、源碼解析合集

三、開源框架合集

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容