深入理解 Dart 空安全

導語

最近在遷移司內項目至空安全的過程中,深入研究了 Dart 的空安全特性。這項特性不僅能讓開發者在編譯階段發現代碼中存在的空指針異常,也能提升程序的運行效率。下面將從靜態分析的角度講一講 Dart 如何對空安全特性進行支持、新舊版本之間的編碼差異、如何遷移舊項目至空安全以及整個遷移原理做詳細說明。

一、引入空安全

1.1 什麼是空安全特性

Dart 語言在版本 2.12 中引入一項叫做空安全的新特性,在空安全版本下,運行時的 NPE(NullPointer Exception) 異常被提前到了開發階段。

比如下面這個例子,在未引入空安全以前,是可以編譯通過的;而引入了空安全以後,IDE 編譯器的靜態檢查階段就能分析出該變量未被初始化,這樣以致於不會把異常拋到運行時。

1.2 爲什麼要使用空安全

有 Java 編碼經驗的應該都知道,Java 在編寫的時候經常會遇到 NPE(NullPointerException) 問題。相比 Java,Kotlin 的最大優點之一就是可以避免 NPE 問題,而 Kotlin 能避免空指針問題的本質就是 Kotlin 對類型系統進行了可空非空的劃分。有了這個類型劃分之後,每當定義一個非空變量但是沒有進行初始化編譯器就會提示報錯,只有延遲初始化或者立即初始化報錯纔會消失;而當定義了一個可空變量,IDE 會提示需要進行判空處理,這樣一來就能有效解決空指針異常的問題了。

Dart 的空安全本質和 Kotlin 是一樣的,在未開啓空安全之前,定義了一個變量,沒有經過初始化就直接使用,編譯器是無法檢測到的,一旦使用了這個未初始化的變量就會在運行時拋出異常;而啓用空安全版本之後,這些異常在開發階段就能很好地提醒開發者,大大降低了運行時的空指針異常。

健全的空安全使得 Dart 的類型系統更加豐富,而 Dart 編譯器也能基於健全的空安全來生成更快、更小的代碼。

int getAge(Animal a){
  return a.age;
}

比如上面這個 Dart 代碼,在 Dart2.0 版本下通過一次 AOT 編譯,可以生成如下 10 條機器指令,藍色部分是該方法的開頭和結尾(用於設置和恢復堆棧),紅色部分執行空值檢查,爲空則跳轉到 helper 。

如果是在 Dart2.12 版本下通過一次 AOT 編譯,生成的指令減少了 3 條,主要減少的就是空檢查部分的指令。藉助健全空安全,可以將此方法生成的代碼減少到最少,不需要運行時檢查和額外修補代碼,更多的處理發生在編譯時,最終得到了運行時更小、更快的代碼,對性能提升幫助很大。

二、理解 Dart 的空安全原理

2.1 類型體系的改變

如下圖所示:在空安全推出之前,靜態類型系統允許所有類型的值爲 null,因爲 Null 是所有類型的子類。

圖摘自 Understanding null safety

因此在變量沒有被初始化的時候,變量的默認值是 null

void main() {
  ///未啓用空安全
  int a;
  print(a); //null
}

而在 Dart 空安全版本中,所有類型變成了默認不可空類型,Null 不再是所有類型的子類,Null 變成了和其他類型並行的類。

圖摘自 Understanding null safety

這時候如果我們在沒有初始化變量的情況下使用這個變量,就會報編譯檢查的錯誤。比如下面這個例子, inta; 聲明語句告訴編譯器該變量不能爲空,而卻在後面使用了沒有被賦值的 a,此時編譯檢查出錯,

在類型體系發生了變化之後,如果我們要使用一個可以爲空的 int變量,需要添加一個 ?標記,告訴編譯器這個變量可以接收的變量是 int 或者 Null 類型。

2.2 靜態檢查分析

Dart2.0 版本中通過使用靜態檢查運行時檢查來保證類型安全。靜態檢查使用 Dart 的靜態分析器在編譯時找到錯誤,而空安全在編譯時的錯誤提醒也是藉助於靜態分析器實現的。

查看 SDK 源碼可以發現,Dart 在對變量是否爲空進行推斷的時候,是將代碼轉換爲一個可空推斷圖,然後對其進行可達性分析。分析代碼中的所有流程控制語句,如果變量在控制流程中的每條路徑都被明確賦值,則認爲該變量是非空的,反之則將變量推斷爲可空類型的。對於 int型變量,可空 int?和非空 int是兩個不同類型,定義的類型和推測的類型不符合則會報編譯錯誤。

/// 可空推斷圖
class NullabilityGraph {
  final NullabilityMigrationInstrumentation? instrumentation;
  ///一個可空節點
  final NullabilityNode always;
  ///一個非空節點
  final NullabilityNode never;
  /// Set containing all sources being migrated.
  final _sourcesBeingMigrated = <Source>{};
  /// Set containing paths to all sources being migrated.
  final _pathsBeingMigrated = <String>{};
  /// 圖中的所有節點
  final Set<NullabilityNode?> nodes = {};
  NullabilityGraph({this.instrumentation})
      : always = _NullabilityNodeImmutable('always', true),
        never = _NullabilityNodeImmutable('never', false);
}

比如下面這個例子,靜態分析在語句 print(b);提示錯誤,我們按照源碼的思路將其轉換爲一個可達性分析圖。由於 inta=1;語句被明確賦了值,所以 a的類型是非空的, intb;沒有被賦值,所以暫時被推斷爲可空的。接着進入 if流程,會出現兩條分支,一條分支 b

被賦了值,所以 b被推斷爲非空的,另一條沒有被賦值, b依然是可空類型,最後 print(b);語句對 b 進行使用,它就會檢查該節點中 b的類型,發現此時 b 的類型是與定義時候不符合,此時就會提示編譯錯誤。

///引入空安全
void main() {
  int a=1;
  int b;
  if(a>2){
    b=3;
  }
  print(b); //編譯出錯
}

2.3 編碼時的流程分析

空安全特性依賴於更強的流程分析,而流程分析會對編碼做出更加嚴格的限制。比如下面幾點改變:

在引入空安全以前的 Dart 中,如下的代碼是可以通過編譯的,編譯器將爲程序自動的返回 Null。

///引入空安全以前
String foo(){}

那麼在編寫複雜代碼的時候,就很容易出現如以下代碼情況:

///引入空安全以前
String foo(int a){
  if(a==1){
    return "1";
  }else if(a==2){
    return "2";
  }
}

上面這段代碼出現了沒有返回值的情況,很容易使得程序在運行時發生異常。而在啓用空安全的 Dart 中這段代碼不能通過編譯檢查,減少了開發者容易發生錯誤的情況。

和上面一個例子類似,在編寫一些 ifelse 的情況下容易忽略某些變量在某個分支未被初始化的情況。例如如下代碼,開發者可能會忘記給不滿十八歲的用戶賦值,可能會在運行時出現空指針異常 。在啓用空安全的 Dart 中則會提示下這段代碼是無法通過編譯的,變量 law 一定要在所有控制流程分支中被賦值。

String calcType(int age){
  String law;
  if(age>=18){
    law = "good";
  }
  return law;
}

三、編碼差異

3.1 空安全的基本使用

爲了實現空安全,Dart 新增了一些語法分別是:?!laterequired ,下面來看具體如何使用這些符號。

3.1.1 空類型聲明符 ?

在空安全中,所有類型在默認情況下都是非空的。如果定義了一個 String 類型的字符串,那麼它應該總是包含一個字符串。如果想要一個變量接收任何字符串或者 null,那麼需要在後面添加一個 ? 表示該變量可以爲空。

該符號執行編譯時檢查,聲明一個可空類型的變量。

另外,對於集合和 map 來說,可空又分爲集合可空以及數據項是否可空。具體區別如下:

I870o5

3.1.2 非空斷言 !

如果確定某個可爲空的表達式爲非空,則可以使用非空斷言操作符 !將其視爲非空。該符號執行運行時檢查,表示當前值一定不爲空,但操作不當容易報運行時錯誤。

例如在開發過程中,我們可能對某些可空變量進行了非空判斷後,編譯器依然無法智能判斷其非空,從而無法使用非空類型的方法和屬性。

而此時我們確定了此處邏輯中變量是非空的,就可以使用非空斷言 !,明確告訴編譯器這是一個不爲空的變量,使其通過靜態檢查。

注:要注意使用了非空斷言必須保證變量不爲 null,否則會在運行時拋出異常。

3.1.3 late 延遲初始化

該符號執行運行時檢查,表示延遲初始化變量,在編碼的時候可以使當前暫未初始化的變量通過靜態的非空檢查。從前面可以知道 Dart 在加入空安全特性之後對於非空類型的變量需要進行初始化,初始化又分爲聲明默認初始化延遲初始化。但並非所有場景都適合使用聲明處默認初始化,因此新增關鍵字 late表示延遲初始化,使用的使用一定要保證變量在調用前被賦值,否則會報運行時錯誤。

常見適用場景:通過異步操作賦值的非空變量;對於非內置基本數據類型一般建議採用。

///引入空安全
void main(){
  ///對於非內置數據類型,建議採用late延遲初始化
  late Student student;
  ///對於基本數據類型,如果沒有嚴格初始化,則可以直接採用默認值進行初始化,有延遲初始化需要再使用late
  String name='';
}
class Student{
  String name;
  int age;
  Student(this.name,this.age);
}

3.1.4 required 關鍵字

空安全出現之前,可以使用 @required註解的方式來定義必須的命名參數,現在 required作爲一個內置修飾符,可以根據需要標記任何命名參數,在使用時一定要給他們賦值,使得他們不爲空。

例如,在空安全版本中定義一個非空的命名參數,如果不給他賦默認值的話會報錯,

解決方案是加上 required 修飾符或者設置默認值,要麼就將該命名參數設置成可空類型。

3.2 詳細編碼差異

在實際開發過程中,我們更關心的是如何寫出符合空安全規範的代碼。編碼差異集中在如下幾個部分:

3.2.1 非空變量

由於全局變量和靜態變量能夠在程序任何位置被訪問到,引入空安全以後,要求這些變量在聲明的時候被初始化,除非聲明的是可空類型。

///引入空安全
int a=1;
class someClass{
  static int filed=0;
}
///未引入空安全
int a;//未執行初始化不會報錯
class someClass{
  static int filed;
}

引入空安全以後,爲保證實例變量的非空性,實例變量必須被初始化,可以直接進行初始化,或者是在構造函數中被初始化。

///引入空安全
class testClass{
  int par_a;
  ///直接初始化
  int par_b=1;
  int par_c;
  ///或在在構造函數中被初始化
  testClass(this.par_c):par_a=2;
}

3.2.2 內置類型

空安全版本中 List 的非命名構造函數已經被廢棄了,因爲非命名構造函數會創建一個沒有對任何元素初始化的列表,如果不小心訪問了其中元素,就會出現異常。

  /// If the element type is not nullable, [length] must not be greater than
  /// zero.
  @Deprecated("Use a list literal, [], or the List.filled constructor instead")
  external factory List([int? length]);

爲了保障健全的空安全特性,官方推薦直接賦值、 List.generate()List.filled() 或者其他集合轉換生成列表,若是需要創建某個類型的一個空列表,則可以通過 List.empty() 來創建。

///引入空安全
void main(){
  ///移除了非命名構造函數,直接使用編譯不通過
  // List<int> ii=List();
  ///創建一個空List
  List<String> ls_string=List.empty();
  ///使用其他方法生成一個非空List
  List<int>  ls_int=List.generate(3, (index) => index+2);
  List<String> ls_a=List.filled(3, "2");
  List<String> ls_double=List.from(ls_a);
   List<String> a=["f","a"];
}

Map 類的 []索引操作符會在鍵值不存在的時候返回 null,這就暗示了操作符的返回類型必須是可空而不是非空的。因此如果此時直接調用 map 對象索引值的屬性或者方法,無論鍵值存在與否,都會報編譯錯誤,

如果我們在編碼中確定該 map 中鍵存在並且鍵所對應的值存在,則可以在代碼中加上一個非空斷言 !來消除編譯錯誤。

3.2.3 函數

在引入空安全以前,如果一個函數返回值類型不爲空,代碼執行到最後,Dart 會隱式返回一個 null 值。因爲所有類型都是可空的,所以從代碼層面來講,這個函數是安全的。

而在引入空安全以後,這樣的操作是會編譯報錯的,函數體在執行過程中必須返回一個值。

///啓用空安全
String fun(){
  //必須返回值,否則編譯器報錯
  return "";
}

並且在這裏,分析器能夠很智能地對函數中所有的控制流進行分析,只要有一個函數控制流返回了內容,就不會編譯報錯。

///啓用了空安全
String absoluteReturn(){
  int a=1;
  if(a==1){
    return "a=1";
  }else{
    return "a=2";
  }
}

在未使用空安全以前,如果一個可選的位置參數或者命名參數可以沒有默認值,在調用時沒有內容傳遞的情況下,Dart 會使用 null 進行填充。

在啓用空安全之後,在函數中使用可選參數,要麼它是可空類型 (type?),否則它必須具有一個非空的默認值。

//啓用了空安全
//不可空的可選參數必須具有默認值
fun1([int a=1]){
}
//定義可選參數爲可空
fun1([int? a]){
}

另外對於命名參數而言,還可以直接使用上文提到的標識符 required定義一個必須的命名參數。

//必需的命名參數
void requireFun({required int a}){
}

3.2.4 操作符

  ///啓用空安全
  List<String>? lsName;
  String? name=lsName?[1]; //null
///未使用空安全
  String notArr;
  // 運行時報錯
  // print(notArr?.length.isEven);  
  // 安全運行
  print(notArr?.length?.isEven); //null

這樣的操作不僅繁瑣,而且還會對程序的分析造成干擾,因爲在鏈式調用過程沒法判斷後面的避空運算符是針對哪一階段值爲 null 的處理。

Dart 空安全爲了解決這個問題,在鏈式調用使用避空運算符的情況下,如果對象爲 null,那麼鏈式調用的後半部分都會被截斷,表達式的值爲 null。

  ///啓用空安全
  String? notArr;
  //安全運行
  print(notArr?.length.isEven); //null

級聯運算符有了新的判空運算符 ?.. , 他在級聯操作的對象不爲 null 時執行,且只能用在級聯序列中的第一級運算符。

  ///啓用空安全
  Receiver? receiver;
  receiver?..showA()..showB();

3.2.5 late

late final 關鍵字主要用於常量延遲初始化,且該常量只能被賦值一次。

///啓用空安全
late final int number;//聲明頂層延遲初始化 final 變量
number = 100;//合法
number = 200;//非法

3.2.6 更智能的流程分析

控制流程分析通常只在進行編譯優化中使用,對於使用者而言是不可見的。Dart 引入空安全以後,以類型提升的方式實現了部分流程分析,並且使用絕對賦值分析,能靈活地處理局部變量初始化。

例如以下這個例子,在未啓用空安全以前,是沒法通過靜態分析檢查的,雖然此時 else分支僅會在 object 爲 List 類型的時候執行。

啓用了空安全以後,在執行到 else分支的時候,Dart 會以類型提升的方式將 object的類型提升至 List,這樣就能方便調用 List類型的屬性和方法。

Dart 引入空安全之後,類型被劃分爲了可空和非空類型,可空類型在沒經過特殊處理之前,基本上不能對其進行任何有用的操作。而當我們在代碼中對對象進行了 ==null!=null 的空判斷之後,Dart 就會將這個變量的類型提升至對應的非空類型,這樣一來就可以調用類型所對應的方法了。

///啓用空安全
String doSomething(String a,String? b){
  if(b!=null){
    ///至此b轉化爲非空的String類型
    return a+b;
  }
  else{
    ///編譯報錯,b依然爲可空類型,故無法使用String的操作符
    return a+b;
  }
}

Dart 能夠追蹤所有控制流路徑的局部變量和參數的賦值,只要這個局部變量和參數在某一路徑中被賦值,就視爲已被初始化。

例如下面這個例子,聲明一個未初始化的局部變量 result,Dart 經過流程分析可知在 if 、else 語句中 result 一定會被賦值,因此可以將非空的 result返回。而如果將 ifelse 語句註釋掉,則 return 語句處會報錯。

///啓用了空安全
int tracingProcess(int n){
  int result;
  //如果沒有if、else語句,則後面的return語句報錯
  if(n<2){
    result=1;
  }else{
    result=3;
  }
  //result在控制流路徑中一定會被賦值,因此可以看作已被初始化過
  return result;
}

四、如何遷移庫 / 項目?

4.1 遷移步驟

從上一小節看出,引入了空安全機制後,Dart 新舊代碼之間產生了互相不兼容的問題。爲了解決這個問題,需要遵循如下遷移過程:

  1. 遵循的遷移規則:

按順序進行遷移,先遷移依賴關係中處於最末端的依賴。例如 C 依賴 B,B 依賴 A,那麼應該按照 A->B->C 的遷移順序。

  1. 首先檢查依賴是否完全升級到空安全的版本:

這一步驟將檢查 pubspec.yaml 文件下依賴的所有外部庫對空安全的支持情況如何。

dart pub outdated 
--mode=
null
-safety  
# or 'flutter pub outdated --mode=null-safety'
  1. 將依賴升級至所支持的空安全版本

這一步驟會將支持空安全的庫自動遷移至空安全版本,並自動修改 pubspec.yaml 文件。

dart pub upgrade 
--
null
-safety
  1. 遷移:

所有依賴的外部庫都遷移至空安全之後,就可以對當前項目進行空安全的版本遷移了。這裏有兩種遷移方式(一般使用自動遷移):

dart migrate

注:使用該命令前需要保證當前代碼沒有編譯錯誤, 且項目中所依賴的庫都支持空安全

environment:
sdk: ">=2.12.0 <3.0.0"

然後執行 dart pub get 命令,原始文件會出現很多報紅的地方,逐一對照手動修改即可。

  1. 分析

任意使用一種方式遷移完成之後,更新 package,接下來使用 dart 的分析工具進行分析:

dart analyze

該命令通過靜態檢查的方式,可以進一步檢查出遷移後的代碼是否有無效的空安全。

  1. 測試

通過分析之後,接下來使用如下命令進行測試:

 dart test       
# or `flutter test`

該命令通過運行時檢查來檢查 test 文件夾下的代碼是否有運行時錯誤。

4.2 實際項目的遷移過程

官方提供的遷移方法基本能遷移大部分依賴簡單以及本身不算複雜的工程。但是在實際情況下,我們的工程可能包含了很多未遷移至空安全的依賴,以及靜態分析無法處理的邏輯,這就需要更多的運行時檢查來幫助處理了。這裏以一個實際項目的遷移過程爲例來展示具體的遷移過程。

4.2.1 檢查依賴情況

執行命令:

dart pub outdated 
--mode=
null
-safety

可以發現項目所依賴的 test_coverage 還未支持空安全,這是暫不支持空安全的開源庫,可以 clone 到本地作進一步分析。

同樣使用上述命令檢查 test_coverage 庫_,_發現 test_coverage 庫又出現一個還不支持空安全的庫 lcov。這種情況下一般有兩種解決方式,尋找類似的已經支持空安全的庫或者自己去遷移。經過查找發現 pub.dart 中已經有一個支持空安全的庫 lcov_dart 7.0.0 ,直接替換使用即可。

4.2.2 升級依賴

繼續回到 test_coverage 庫,執行以下命令:

dart pub upgrade 
--
null
-safety

這樣就可以繼續將 test_coverage 的其餘依賴升級爲支持空安全的版本_。_將 test_coverage 遷移完成後,繼續回到主庫執行升級依賴的命令,發現他所依賴的庫也全部遷移至空安全,現在可以進行真正的遷移工作了。

4.2.3 遷移

這裏使用工具進行自動遷移,在主庫的根目錄下執行以下命令:

dart migrate

這裏又出現了內部包的導入問題,這個原因在於 dart 遷移命令在執行過程中會檢查所有外部和內部導入的庫,看其是否支持空安全。內部庫是從待遷移文件頭部導入的,這些文件也是需要被遷移的,可以通過如下命令來忽略內部庫的空安全依賴問題:

dart migrate  
--skip-
import
-check

接着又出現了新的問題,主要是測試代碼的編譯錯誤,由於這個庫目前還在開發中,有些代碼還沒寫完。但這部分的代碼不影響主庫,暫時將這部分的出錯代碼移出去,等主庫遷移完成之後再來處理也可以。

暫時將有編譯問題的測試文件夾移除之後,執行命令發現這次成功了。最後遷移工具會生成一個遷移完成的 url 地址,打開就能看到靜態分析工具推斷出的建議修改的空安全代碼,可以逐個打開修改分析不符合預期的地方,然後直接將所有修改應用到源代碼。

4.2.4 手動修復

用工具遷移完成之後,還會有部分代碼沒法通過靜態分析檢查,這時候就需要手動去修復這些問題。

4.2.5 分析

執行到這一步說明已經將代碼遷移至靜態分析通過的空安全版本,接下來使用如下命令作進一步的檢查分析:

dart analyze

靜態分析工具可以標記出一些代碼中一些不規範的地方,當然也包括使用不規範的空安全,這個時候手動將不正確的空安全處理掉即可。

4.2.6 測試

處理好上一部分的空安全問題之後,接下來來到代碼測試階段。這裏根據實際情況,我們測試了待測試文件下的代碼運行情況,運行時出現了一些在靜態檢查階段沒有被發現的空安全問題,接下來繼續手動修復這些運行時的空安全問題,逐一修復之後最後這個 example 能運行在空安全庫上了。

4.3 遷移過程常見問題

await
 _udpConn!.close();

解決:這種錯誤常出現在用 await 去執行沒有返回值的異步函數,若是內部函數則將異步函數的返回值修改爲 Future<void>;若是外部函數,則在不修改語義的前提下將 await 去掉。

var
 filters = 
List
<
String
?>(filter.length + c.filter?.length);

解決:用空安全支持的方式初始化 List。

4.4 非健全空安全

一個 Dart 程序可以包含已經是空安全和未遷移至空安全的庫,這種混合模式的程序會運行在非健全的空安全版本下。在遷移過程中,可以將暫時不考慮遷移的 Dart 文件頂部加上語言版本註釋:

// @dart=2.9

這樣在 2.12 版本的 package 中爲庫指定爲 2.9 的語言版本可以減少一些遷移的分析錯誤。

五、遷移源碼分析

雖然官網提供了遷移工具能夠快速地將舊版本遷移至空安全,但是遷移完之後還需要手動修改一部分,以及調整運行時異常,這對於不斷迭代的舊項目來說造成了遷移困難。爲了分析能夠優化這個步驟,我們繼續對遷移的關鍵方法進行分析,分別是 dart migratedart analyze命令。

5.1 dart migrate

該命令的入口函數如下:

  /// 執行遷移過程
  Future<void> run() async {
    ///遷移過程
    _fixCodeProcessor = _FixCodeProcessor(analysisContext, this);
    ...    
    try {
      //遷移
      var analysisResult = await _fixCodeProcessor!.runFirstPhase();
      ....
    }

遷移過程從 runFirstPhase函數開始,這個函數對沒有錯誤信息或忽略錯誤信息的單元進行遷移,遷移函數是 prepareUnit,函數內部主要調用 prepareInput函數。

  ///主要遷移過程
  Future<AnalysisResult> runFirstPhase() async {
    var analysisErrors = <AnalysisError>[];
      ....
      _progressBar.tick();
      //在分析dart源碼時出現錯誤
      List<AnalysisError> errors = result.errors
          .where((error) => error.severity == Severity.error)
          .toList();
      if (errors.isNotEmpty) {
        analysisErrors.addAll(errors);
        _migrationCli.lineInfo[result.path] = result.lineInfo;
      }
      //忽略錯誤信息或者分析源碼時沒有錯誤信息
      if (_migrationCli.options.ignoreErrors! || analysisErrors.isEmpty) {
        //對源碼的每一個單元進行遷移
        await _task!.prepareUnit(result);
      }
    });
   ....
    return AnalysisResult(
        analysisErrors,
        _migrationCli.lineInfo,
        _migrationCli.pathContext,
        _migrationCli.options.directory,
        allSourcesAlreadyMigrated);
  }
}

prepareInput函數中主要對 ResolvedUnitResult對象的 unit屬性進行處理,查看源碼可以知道 unitAstNode的子類, AstNode一般對應抽象語法樹中的節點。這裏就可以知道這個過程是以遍歷 AST 節點的方式來對待遷移節點進行分析,所有節點都會繼承 AstNodeAstNode提供了 accpet方法對樹進行遍歷操作,遍歷過程是以訪問者模式進行的,這裏需要傳入一個被實現的 visitor

  ///以遍歷AST節點的方式來對待遷移節點進行分析
    void prepareInput(ResolvedUnitResult result) {
    assert(
        !_queriedUnmigratedDependencies,
        'Should only query unmigratedDependencies after all calls to '
        'prepareInput');
    ....
    }
    //AST的根節點
    //result的unit的類型爲CompilationUnit
    var unit = result.unit;
    try {
      DecoratedTypeParameterBounds.current = _decoratedTypeParameterBounds;
      //accept方法可以遍歷這顆樹
      unit.accept(NodeBuilder(
          _variables,
          unit.declaredElement!.source,
          _permissive! ? listener : null,
          _graph,
          result.typeProvider,
          _getLineInfo,
          instrumentation: _instrumentation));
    } finally {
      DecoratedTypeParameterBounds.current = null;
    }
  }
abstract class CompilationUnit implements AstNode {
  ....
  List<AstNode> get sortedDirectivesAndDeclarations;
}

繼續查看 NodeBuilder,可以看到它確實是一個繼承 AstVisitor的子類。對於 AstVisitor 而言,每遍歷到一個節點就會走到其對應的訪問方法裏。例如遍歷到構造函數,就會走到 visitConstructorDeclaration方法裏。並且 NodeBuilder中出現了 NullabilityGraph類型的屬性,可以推測出,遷移過程是將源碼經過靜態分析先轉換成 AST,然後以訪問者模式對樹節點進行訪問,在訪問過程中構造出可空推斷圖,來分析節點之間的可達性,最後將推斷的類型返回到源碼的相應部分。

/// 繼承自AstVisitor,visitor每遍歷到一個節點就會走到相應的方法例
class NodeBuilder extends GeneralizingAstVisitor<DecoratedType>
    with
        PermissiveModeVisitor<DecoratedType>,
        CompletenessTracker<DecoratedType> {
  /// Constraint variables and decorated types are stored here.
  final Variables? _variables;
  @override
  final Source? source;
  final LineInfo Function(String) _getLineInfo;
  Map<String, DecoratedType?>? _namedParameters;
  List<DecoratedType?>? _positionalParameters;
  NullabilityNodeTarget? _target;
  final NullabilityMigrationListener? listener;
  final NullabilityMigrationInstrumentation? instrumentation;
 ///可空推斷圖
  final NullabilityGraph _graph;
  final TypeProvider _typeProvider;
  NodeBuilder(this._variables, this.source, this.listener, this._graph,
      this._typeProvider, this._getLineInfo,
      {this.instrumentation});

5.2 dart analyze

在靜態分析過程中還有一個比較重要的命令就是 dart analyze, 我們繼續對其進行分析。

分析命令將文件路徑作爲 path 參數傳入,對輸入的文件進行分析,返回一個 ParseStringResult對象。

ParseStringResult parseFile(
    {required String path,
    ResourceProvider? resourceProvider,
    required FeatureSet featureSet,
    bool throwIfDiagnostics = true}) {
  resourceProvider ??= PhysicalResourceProvider.INSTANCE;
  var content = (resourceProvider.getResource(path) as File).readAsStringSync();
  return parseString(
      content: content,
      path: path,
      featureSet: featureSet,
      throwIfDiagnostics: throwIfDiagnostics);
}

ParseStringResult類中,存在一個 CompilationUnit類型的屬性 unit,註釋表示爲一個未處理的編譯單元。

abstract class ParseStringResult {
  String get content;
  /// The analysis errors that were computed during analysis.
  List<AnalysisError> get errors;
  /// Information about lines in the content.
  LineInfo get lineInfo;
  /// The parsed, unresolved compilation unit for the [content].
  CompilationUnit get unit;
}

繼續看 CompilationUnit這個類,從上面的分析可知這個類實現了 AstNode接口,可以知道這個類就是用於存儲 AST 數據的,那麼 ParseStringResult中的 unit應該就是所有樹的根節點,從這個根節點遍歷,應該就能提取出源碼中所有節點信息,通過進一步的對節點信息進行推斷便可檢查出轉換後的代碼存在的問題。

// Clients may not extend, implement or mix-in this class.
abstract class CompilationUnit implements AstNode {
  NodeList<CompilationUnitMember> get declarations;
 ....
  CompilationUnitElement? get declaredElement;
}

5.3 小結

參考文章

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/gWRAcw2sZnX5Jucs7IoK5g