空安全:常見問題
在遷移程式碼時,我應該注意哪些執行時的改動?
如果在測試中的值始終為 null 應該怎樣處理?
新的 required 和 @required 關鍵詞有何異同?
我應該如何遷移應該為 final 而目前並不是的欄位?
我應該如何遷移 built_value 類?
我應該如何遷移可能返回 null 的工廠方法?
我應該如何遷移現在提示無用的 assert(x != null)?
我應該如何遷移現在提示不必要的執行時空判斷?
Iterable.firstWhere 方法不再接受 orElse: () => null。
我應該如何處理有 setters 的屬性?
我需要怎樣標記對映的返回值為非空型別?
為什麼我的 List/Map 中的泛型是可空的?
預設的 List 構造有什麼改動?
我在遷移使用了 package:ffi 的程式碼的時候遇到了 Dart_CObject_kUnsupported 的錯誤。發生了什麼?
為什麼遷移工具在我的程式碼中添加了註釋
關於編譯為 JavaScript 時的空安全我應該知道什麼?
資源
此處涵蓋了一些我們在遷移 Google 內部程式碼至 空安全 時遇到的常見問題。
在遷移程式碼時,我應該注意哪些執行時的改動?
在空安全遷移中的大部分影響,不會立刻出現在剛剛遷移完成的開發者身上:
-
靜態的空安全檢查,會在開發者完成遷移後立刻生效。
-
完整的空安全檢查,只會在所有程式碼都已遷移,並且啟用了完整的空安全模式時生效。
但是有兩項例外你需要注意:
-
對於任何模式而言,
!
運運算元都是在執行時進行的空檢查。所以在進行遷移時,請確保你僅對null
可能由混合模式造成汙染的程式碼位置新增!
,就算發起呼叫的程式碼還未遷移至空安全,也應如此。 -
late
會在執行時期檢查。所以請你僅在確定它被使用前一定會被初始化的情況下使用late
。
null
應該怎樣處理?
如果在測試中的值始終為 如果在測試中某個值始終為 null
,可以透過將測試傳值和測試需要的值改為非空,來改進你的測試。
required
和 @required
關鍵詞有何異同?
新的 @required
註解會將引數標記為必須傳遞。如果未傳,分析器會給出一個提示。
有了空安全,非空型別的命名引數要麼需要預設值,要麼需要使用 required
關鍵字修飾。否則,它在未傳遞時預設會是 null
,就顯得不合理了。
在舊的程式碼中,required
關鍵詞會被看作 @required
註解,即引數未傳遞時會顯示一個分析器提示。
當你在空安全的程式碼中使用空安全程式碼時,如果 required
修飾的引數未傳遞,會顯示一個錯誤。
那麼它對於遷移來說意味著什麼呢?當你給以前沒有使用 @required
註解的引數加上 required
時要十分小心。任何沒有傳遞新需要的引數的程式碼,都無法進行編譯。實際上,你可以加上預設值,或是將引數變為可空型別。
final
而目前並不是的欄位?
我應該如何遷移應該為 一些賦值計算可以移動到靜態的初始化中。與其使用下面的方式:
// Initialized without values
ListQueue _context;
Float32List _buffer;
dynamic _readObject;
Vec2D(Map<String, dynamic> object) {
_buffer = Float32List.fromList([0.0, 0.0]);
_readObject = object['container'];
_context = ListQueue<dynamic>();
}
你可以這樣做:
// Initialized with values
final ListQueue _context = ListQueue<dynamic>();
final Float32List _buffer = Float32List.fromList([0.0, 0.0]);
final dynamic _readObject;
Vec2D(Map<String, dynamic> object) : _readObject = object['container'];
然而,在建構函式中透過計算進行初始化的欄位,是無法為 final
的。在使用空安全的時候,你會發現想讓它們的型別為非空,也不是一件容易的事。如果初始化的時機不合適,那麼直到初始化前,它都只能是可空的型別。幸運的是,你還有其他選擇:
-
將構造轉變為工廠方法,並將其委託給一個直接初始化所有欄位的真正的建構函式。在 Dart 中,這樣的私有構造通常是一個下劃線:
_
。如此一來,欄位就可以是final
且非空了。在空安全遷移介入 之前,你就可以這樣進行調整。 -
或者,將欄位標記為
late final
。這會使得欄位只被初始化一次。在它被讀取之前必須被初始化。
built_value
類?
我應該如何遷移 使用了 @nullable
註解的 getter 應當直接轉變為可空的型別,然後移除所有的 @nullable
註解。例如:
@nullable
int get count;
變成
int? get count; // Variable initialized with ?
就算遷移工具建議,沒有 使用 @nullable
註解的 getter 也不應該是可空的型別。這時可以根據需要新增 !
運運算元,並且重新進行分析。
null
的工廠方法?
我應該如何遷移可能返回 優先使用不返回 null 的工廠方法。我們看到了很多程式碼,本意是想在呼叫不正確時丟擲一個例外,但最終卻返回了空。
與其這樣寫:
factory StreamReader(dynamic data) {
StreamReader reader;
if (data is ByteData) {
reader = BlockReader(data);
} else if (data is Map) {
reader = JSONBlockReader(data);
}
return reader;
}
不如這樣:
factory StreamReader(dynamic data) {
if (data is ByteData) {
// Move the readIndex forward for the binary reader.
return BlockReader(data);
} else if (data is Map) {
return JSONBlockReader(data);
} else {
throw ArgumentError('Unexpected type for data');
}
}
如果一個工廠方法的初衷就是可能返回空值,將其轉為可返回 null
的靜態方法是更好的選擇。
assert(x != null)
?
我應該如何遷移現在提示無用的 對於完全遷移的程式碼而言,這個斷言是不必要的,但是如果你希望保留該檢查,那麼它 也需要 留下。幾種方式可供你選擇:
-
確定是否真的需要這個斷言,然後將其刪除。當斷言啟用時,這是一種行為上的變更。
-
確定斷言始終會被檢查,接著將其轉換為
ArgumentError.checkNotNull
。當斷言未啟用時,這是一種行為上的變更。 -
透過新增
//ignore: unnecessary_null_comparison
來繞過警告並且保持原有的行為。
我應該如何遷移現在提示不必要的執行時空判斷?
如果 arg
為非空時,編譯器會在執行時將顯式空安全判斷標記為非必要。
if (arg == null) throw ArgumentError(...)`
混合模式下的程式必須包含這樣的判斷。在所有程式碼都遷移且執行在完全的空安全模式下前,
arg
仍然可能為 null
。
保留這項行為的最簡單的方法是將判斷改為
ArgumentError.checkNotNull
。
執行時的檢查同樣適用。如果 arg
指定了靜態型別為 String
,那麼 if (arg is! String)
實際上是在檢查 arg
是否為 null
。儘管程式碼在遷移到空安全後,arg
應該是永遠不為 null
的,但是在不完全的空安全中它仍有可能為 null
的。
Iterable.firstWhere
方法不再接受 orElse: () => null
。
匯入 package:collection
package 並使用 firstWhereOrNull
擴充方法代替 firstWhere
。
我應該如何處理有 setters 的屬性?
與上文說到的 late final
的建議不同的是,這些欄位不能被標記為終值。通常,可被修改的屬性也沒有初始值,因為它們可能會在稍後才被賦值。
在這樣的情況下,你有兩種選擇:
-
為其設定初始值。通常情況下,初始值未被設定是無意的錯誤,而不是有意為之。
-
如果你 確定 這個屬性需要在存取之前被賦值,將它標記為
late
。注意:
late
關鍵詞會在執行時新增檢查。如果在set
之前呼叫了get
,會在執行時丟擲例外。
我需要怎樣標記對映的返回值為非空型別?
對映型別的
查詢運運算元
([]
) 返回的值預設是可空型別。此處沒有辦法告訴 Dart ,返回的值一定是非空的。
在這種情況下,你應該使用強制非空運運算元 (!
) 將可空的型別轉為非空 (V)。
return blockTypes[key]!;
如果 map 返回了 null,則會丟擲例外。如果你希望手動處理這些情況:
var result = blockTypes[key];
if (result != null) return result;
// Handle the null case here, e.g. throw with explanation.
為什麼我的 List/Map 中的泛型是可空的?
下面這樣以可空內容結尾的程式碼是一種典型的程式碼異味:
List<Foo?> fooList; // fooList can contain null values
它隱含了 fooList
可能包含空值的資訊。在你以長度初始化列表並迴圈填入值時,這種情況可能會出現。
如果你僅僅想要以相同的值初始化列表,你應該使用
filled
構造。
_jellyCounts = List<int?>(jellyMax + 1);
for (var i = 0; i <= jellyMax; i++) {
_jellyCounts[i] = 0; // List initialized with the same value
}
_jellyCounts = List<int>.filled(jellyMax + 1, 0); // List initialized with filled constructor
如果你需要透過索引來設定元素,或者使用不同的值填充每個元素,則應該使用列表的字面量表達式來建構列表。
_jellyPoints = List<Vec2D?>(jellyMax + 1);
for (var i = 0; i <= jellyMax; i++) {
_jellyPoints[i] = Vec2D(); // Each list element is a distinct Vec2D
}
_jellyPoints = [
for (var i = 0; i <= jellyMax; i++)
Vec2D() // Each list element is a distinct Vec2D
];
你可以使用 List.generate
構造加
growable
引數設定為 false
來產生固定長度的列表:
_jellyPoints = List.generate(jellyMax, (_) => Vec2D(), growable: false);
預設的 List 構造有什麼改動?
你可能會遇到這樣的錯誤:
The default 'List' constructor isn't available when null safety is enabled. #default_list_constructor
預設的列表構造會將列表用 null
填充,會造成問題。
將它變為 List.filled(length, default)
即可。
package:ffi
的程式碼的時候遇到了 Dart_CObject_kUnsupported
的錯誤。發生了什麼?
我在遷移使用了 透過 ffi 傳送的列表只能是 List<dynamic>
,而不能是 List<Object>
或 List<Object?>
。就算你未在遷移過程中手動更改型別,型別也可能會被改變,因為啟用空安全後,型別推導推算出了這樣的結果。
手動建立 List<dynamic>
型別的列表可以解決這個問題。
為什麼遷移工具在我的程式碼中添加了註釋
空安全模式下,在當某個表示式的結果一定為 false 或 true 的時候,遷移工具會自動新增 /* == false */
或者 /* == true */
這樣的註釋。這樣的註釋將會導致自動遷移出現錯誤,並且需要人工干預。例如:
if (registry.viewFactory(viewDescriptor.id) == null /* == false */)
在這些情況下,遷移工具無法區分防禦性編碼情況或是確實需要空值的情況。那麼該工具會告訴你「這看起來永遠為 false!」並讓你進行決定。
關於編譯為 JavaScript 時的空安全我應該知道什麼?
空安全帶來了程式碼體積減小及效能提升等最佳化。表面上 Flutter 編譯為原生端的建構的最佳化會更加明顯,例如 AOT。我們先前已經在 Web 的生產建構器上已經引入了一些類似空安全的最佳化。所以,Web 應用上的變化可能並不如原生端明顯。
依然有幾點值得注意:
-
生產環境下的 JavaScript 編譯器會產生
!
空斷言,在比較輸出的時候你可能不會注意到它。這是因為編譯器已支援了對空值進行檢查。 -
無論是健全或非健全的安全,又或是不同的最佳化等級,編譯器都會產生這些空斷言,實際上在使用
-O3
或--omit-implicit-checks
時編譯器都不會移除!
。 -
生產環境下的 JavaScript 編譯器可能會移除沒有必要的空檢查,一般會發生在生產環境下的 Web 編譯器在空安全之前做了一些最佳化,當它知道值不為空的時候,就刪除了這些檢查。
-
預設情況下,編譯器會產生引數子類別型的檢查。(用於確保協變的虛擬呼叫使用了合適的引數的執行時期檢查)。與先前相同,使用
--omit-implicit-checks
編譯器會省略它們。回想一下,如果型別無效,這個開關會讓程式出現例外,因此我們依然建議程式碼測試覆蓋率儘可能地高,以避免任何事故。特別是編譯器會基於傳入值符合型別宣告這一條件來最佳化程式碼。如果程式碼提供了無效型別的引數,最佳化將不是正確的,導致程式例外。這對於之前的型別不一致是正確的,對於現在健全的空安全的可空性不一致也是如此。 -
你可能會注意到開發版的 JavaScript 編譯器和 Dart VM 對於空檢查的錯誤有比較特殊的錯誤提示,但是為了保持應用的輕量體積,生產環境下的編譯器並沒有這樣的提示。
-
你可能會看到
.toString
在null
上未找到的錯誤。這不是一個 bug,是編譯器一直以來新增的空檢查。編譯器會透過對接收者物件屬性的存取來壓縮一些空檢查。它產生的是a.toString
而不是if (a == null) throw
。在 JavaScript 物件中定義的toString
方法可以快速驗證物件是否可空。如果空檢查後的第一個行為是當值為空時會崩潰,編譯器可以刪除空檢查並讓動作丟擲錯誤。
例如,
print(a!.foo());
陳述式可以直接轉換為:P.print(a.foo$0());
這是因為呼叫
a.foo$()
會在a
為空時崩潰。如果 dart2js 內聯foo
,它將保留空檢查。例如,如果foo
是int foo() => 1;
,編譯器可能會產生:a.toString; P.print(1);
如果內聯方法做的第一件事是對接收者的欄位存取,例如
int foo() => this.x + 1;
,那麼 dart2js 可以再次刪除多餘的a.toString
空檢查,就像非內聯呼叫一樣產生:P.print(a.x + 1);