Entendendo null safety
Um mergulho profundo nas mudanças da linguagem e bibliotecas Dart relacionadas a null safety.
Escrito por Bob Nystrom
Julho 2020
Null safety é a maior mudança que fizemos no Dart desde que substituímos o sistema de tipos opcional original não-seguro (unsound) por um sistema de tipos estático seguro (sound) no Dart 2.0. Quando o Dart foi lançado pela primeira vez, null safety em tempo de compilação era um recurso raro que precisava de uma longa introdução. Hoje, Kotlin, Swift, Rust e outras linguagens têm suas próprias respostas para o que se tornou um problema muito familiar. Aqui está um exemplo:
// Without null safety:
bool isEmpty(String string) => string.length == 0;
void main() {
isEmpty(null);
}
Se você executar este programa Dart sem null safety, ele lança uma
exceção NoSuchMethodError na chamada para .length. O valor
null é uma
instância da classe Null, e Null não tem um getter "length". Falhas em tempo de execução
são ruins. Isso é especialmente verdadeiro em uma linguagem como Dart que é projetada
para executar no dispositivo de um usuário final. Se uma aplicação de servidor falha, você pode frequentemente
reiniciá-la antes que alguém perceba. Mas quando um aplicativo Flutter trava no telefone
de um usuário, eles não ficam felizes. Quando seus usuários não estão felizes, você não está feliz.
Desenvolvedores gostam de linguagens estaticamente tipadas como Dart porque elas permitem que o
type checker encontre erros no código em tempo de compilação, geralmente diretamente na IDE.
Quanto mais cedo você encontra um bug, mais cedo você pode corrigi-lo. Quando designers de linguagens
falam sobre "corrigir erros de referência nula", eles querem dizer enriquecer o type
checker estático para que a linguagem possa detectar erros como a tentativa acima de chamar
.length em um valor que pode ser null.
Não existe uma única solução verdadeira para este problema. Rust e Kotlin têm suas próprias abordagens que fazem sentido no contexto dessas linguagens. Este documento percorre todos os detalhes da nossa resposta para o Dart. Inclui mudanças no sistema de tipos estático e um conjunto de outras modificações e novos recursos de linguagem para permitir que você não apenas escreva código null-safe, mas esperançosamente aproveite fazê-lo.
Este documento é longo. Se você quer algo mais curto que cubra apenas o que você
precisa saber para começar, comece com a visão geral. Quando você estiver
pronto para um entendimento mais profundo e tiver tempo, volte aqui para que você possa
entender como a linguagem lida com null, por que projetamos dessa maneira, e
como escrever código Dart null-safe idiomático e moderno. (Alerta de spoiler: acaba sendo
surpreendentemente próximo de como você escreve Dart hoje.)
As várias maneiras pelas quais uma linguagem pode lidar com erros de referência nula têm seus prós e contras. Estes princípios guiaram as escolhas que fizemos:
-
O código deve ser seguro por padrão. Se você escreve novo código Dart e não usa nenhum recurso explicitamente não-seguro, ele nunca lança um erro de referência nula em tempo de execução. Todos os possíveis erros de referência nula são capturados estaticamente. Se você quer adiar parte dessa verificação para o tempo de execução para obter maior flexibilidade, você pode, mas você tem que escolher isso usando algum recurso que seja textualmente visível no código.
Em outras palavras, não estamos te dando um colete salva-vidas e deixando para você se lembrar de colocá-lo toda vez que você sai na água. Em vez disso, nós te damos um barco que não afunda. Você fica seco a menos que você pule para fora.
-
Código null-safe deve ser fácil de escrever. A maioria do código Dart existente é dinamicamente correto e não lança erros de referência nula. Você gosta do seu programa Dart do jeito que ele parece agora, e nós queremos que você possa continuar escrevendo código dessa maneira. Segurança não deve exigir sacrificar usabilidade, pagar penitência ao type checker, ou ter que mudar significativamente a forma como você pensa.
-
O código null-safe resultante deve ser totalmente seguro (sound). "Soundness" no contexto de verificação estática significa coisas diferentes para pessoas diferentes. Para nós, no contexto de null safety, isso significa que se uma expressão tem um tipo estático que não permite
null, então nenhuma execução possível dessa expressão pode nunca avaliar paranull. A linguagem fornece essa garantia principalmente através de verificações estáticas, mas pode haver algumas verificações em tempo de execução envolvidas também. (Embora, note o primeiro princípio: qualquer lugar onde essas verificações em tempo de execução aconteçam será sua escolha.)Soundness é importante para a confiança do usuário. Um barco que na maioria das vezes fica à tona não é um que você está entusiasmado em enfrentar o mar aberto. Mas também é importante para nossos intrépidos hackers de compilador. Quando a linguagem faz garantias difíceis sobre propriedades semânticas de um programa, isso significa que o compilador pode realizar otimizações que assumem que essas propriedades são verdadeiras. Quando se trata de
null, isso significa que podemos gerar código menor que elimina verificações denulldesnecessárias, e código mais rápido que não precisa verificar se um receptor é não-nullantes de chamar métodos nele.Uma ressalva: Nós apenas garantimos soundness em programas Dart que são totalmente null safe. Dart suporta programas que contêm uma mistura de código null-safe mais novo e código legado mais antigo. Nesses programas de versão mista, erros de referência nula ainda podem ocorrer. Em um programa de versão mista, você obtém todos os benefícios de segurança estática nas partes que são null safe, mas você não obtém soundness completa em tempo de execução até que toda a aplicação seja null safe.
Note que eliminar null não é um objetivo. Não há nada de errado com null.
Pelo contrário, é realmente útil poder representar a ausência de um
valor. Construir suporte para um valor especial "ausente" diretamente na linguagem
torna trabalhar com ausência flexível e utilizável. Isso sustenta parâmetros opcionais, o útil operador null-aware
?., e inicialização padrão. Não
é null que é ruim, é ter null indo onde você não espera
que causa problemas.
Assim, com null safety, nosso objetivo é dar a você controle e visão sobre
onde null pode fluir pelo seu programa e certeza de que ele não pode fluir
para algum lugar que causaria uma falha.
Nullability in the type system
#
Null safety começa no sistema de tipos estático porque tudo o resto repousa sobre
isso. Seu programa Dart tem um universo inteiro de tipos nele: tipos primitivos
como int e String, tipos de coleção como List, e todas as classes
e tipos que você e os pacotes que você usa definem. Antes de null safety, o sistema de
tipos estático permitia que o valor null fluísse para expressões de qualquer um desses
tipos.
Na terminologia da teoria de tipos, o tipo Null era tratado como um subtipo de todos os tipos:
O conjunto de operações—getters, setters, métodos e
operadores—permitidas em algumas expressões são definidas por seu tipo. Se o tipo
é List, você pode chamar .add() ou [] nele. Se é
int, você pode chamar +.
Mas o valor null não define nenhum desses métodos. Permitir que null
flua para uma expressão de algum outro tipo significa que qualquer uma dessas operações pode
falhar. Este é realmente o cerne dos erros de referência nula—toda falha vem
de tentar buscar um método ou propriedade em null que ele não tem.
Non-nullable and nullable types
#
Null safety elimina esse problema na raiz mudando a hierarquia de tipos.
O tipo Null ainda existe, mas não é mais um subtipo de todos os tipos.
Em vez disso, a hierarquia de tipos se parece com isto:
Como Null não é mais um subtipo, nenhum tipo exceto a classe especial Null
permite o valor null. Tornamos todos os tipos non-nullable por padrão. Se você
tem uma variável do tipo String, ela sempre conterá uma string. Pronto,
nós corrigimos todos os erros de referência nula.
Se não achássemos null útil de forma alguma, poderíamos parar aqui. Mas null
é
útil, então ainda precisamos de uma maneira de lidar com ele. Parâmetros opcionais são um bom
caso ilustrativo. Considere este código Dart null-safe:
// Using null safety:
void makeCoffee(String coffee, [String? dairy]) {
if (dairy != null) {
print('$coffee with $dairy');
} else {
print('Black $coffee');
}
}
Aqui, queremos permitir que o parâmetro dairy aceite qualquer string, ou o valor
null, mas nada mais. Para expressar isso, damos a dairy
um nullable type
adicionando ? no final do tipo base subjacente String. Por baixo dos panos,
isso está essencialmente definindo uma união
do tipo subjacente e do tipo Null.
Então String? seria uma abreviação para String|Null se o Dart tivesse
tipos união completos.
Using nullable types
#
Se você tem uma expressão com um nullable type, o que você pode fazer com o resultado?
Como nosso princípio é seguro por padrão, a resposta é não muito. Não podemos deixar você
chamar métodos do tipo subjacente nele porque eles podem falhar se o valor
for null:
// Hypothetical unsound null safety:
void bad(String? maybeString) {
print(maybeString.length);
}
void main() {
bad(null);
}
Isso travaria se deixássemos você executá-lo. Os únicos métodos e propriedades que podemos
seguramente deixar você acessar são os definidos tanto pelo tipo subjacente quanto pela
classe Null. Isso é apenas toString(), ==, e
hashCode. Então você pode usar
nullable types como chaves de map, armazená-los em sets, compará-los a outros valores,
e usá-los em interpolação de string, mas isso é basicamente tudo.
Como eles interagem com non-nullable types? É sempre seguro passar um
tipo não-nullable para algo esperando um nullable type. Se uma função
aceita String? então passar uma String é permitido porque não causará nenhum
problema. Nós modelamos isso fazendo todo nullable type um supertipo de seu
tipo subjacente. Você também pode passar null com segurança para algo esperando um nullable type, então
Null também é um subtipo de todo nullable type:
Mas ir na outra direção e passar um nullable type para algo esperando
o tipo non-nullable subjacente não é seguro. Código que espera uma String
pode
chamar métodos String no valor. Se você passar uma String?
para ele, null poderia
fluir e isso poderia falhar:
// Hypothetical unsound null safety:
void requireStringNotNull(String definitelyString) {
print(definitelyString.length);
}
void main() {
String? maybeString = null; // Or not!
requireStringNotNull(maybeString);
}
Este programa não é seguro e não devemos permiti-lo. No entanto, o Dart sempre teve
essa coisa chamada implicit downcasts. Se você, por exemplo, passa um valor do
tipo Object para uma função esperando uma String, o type checker permite:
// Without null safety:
void requireStringNotObject(String definitelyString) {
print(definitelyString.length);
}
void main() {
Object maybeString = 'it is';
requireStringNotObject(maybeString);
}
Para manter a soundness, o compilador silenciosamente insere um cast as String no
argumento para requireStringNotObject(). Esse cast pode falhar e lançar uma
exceção em tempo de execução, mas em tempo de compilação, o Dart diz que isso está OK. Como
non-nullable types são modelados como subtipos de nullable types, implicit downcasts
permitiriam que você passasse uma String? para algo esperando uma String. Permitir isso
violaria nosso objetivo de ser seguro por padrão. Então com null safety estamos
removendo implicit downcasts completamente.
Isso faz a chamada para requireStringNotNull() produzir um erro de compilação, que
é o que você quer. Mas também significa que todos os implicit downcasts se tornam erros de
compilação, incluindo a chamada para requireStringNotObject(). Você terá que adicionar o
downcast explícito você mesmo:
// Using null safety:
void requireStringNotObject(String definitelyString) {
print(definitelyString.length);
}
void main() {
Object maybeString = 'it is';
requireStringNotObject(maybeString as String);
}
Achamos que isso é uma mudança boa no geral. Nossa impressão é que a maioria dos usuários nunca gostou de implicit downcasts. Em particular, você pode ter sido prejudicado por isto antes:
// Without null safety:
List<int> filterEvens(List<int> ints) {
return ints.where((n) => n.isEven);
}
Percebeu o bug? O método .where() é preguiçoso, então ele retorna um Iterable, não uma
List. Este programa compila mas depois lança uma exceção em tempo de execução quando tenta
fazer cast desse Iterable para o tipo List que filterEvens
declara que
retorna. Com a remoção de implicit downcasts, isso se torna um erro de compilação.
Onde estávamos? Certo, OK, então é como se tivéssemos pegado o universo de tipos em seu programa e dividido em duas metades:
Há uma região de non-nullable types. Esses tipos permitem que você acesse todos os
métodos interessantes, mas nunca podem conter null. E então há uma
família paralela de todos os nullable types correspondentes. Esses permitem null,
mas você não pode fazer muito com eles. Nós deixamos valores fluirem do lado non-nullable
para o lado nullable porque fazer isso é seguro, mas não na outra direção.
Parece que nullable types são basicamente inúteis. Eles não têm métodos e você não pode se livrar deles. Não se preocupe, temos um conjunto inteiro de recursos para ajudá-lo a mover valores da metade nullable para o outro lado que veremos em breve.
Top and bottom
#
Esta seção é um pouco esotérica. Você pode pular a maior parte dela, exceto por duas
marcas no final, a menos que você goste de coisas de sistemas de tipos. Imagine todos os
tipos em seu programa com arestas entre aqueles que são subtipos e supertipos
uns dos outros. Se você fosse desenhá-lo, como os diagramas neste documento, ele formaria
um grafo direcionado enorme com supertipos como Object perto do topo e classes
folha como seus próprios tipos perto da base.
Se esse grafo direcionado chega a um ponto no topo onde há um único tipo que é o supertipo (direta ou indiretamente), esse tipo é chamado de top type. Da mesma forma, se há um tipo estranho na base que é um subtipo de todo tipo, você tem um bottom type. (Neste caso, seu grafo direcionado é um lattice.)
É conveniente se seu sistema de tipos tem um top e bottom type, porque isso significa
que operações em nível de tipo como least upper bound (que type inference usa para
descobrir o tipo de uma expressão condicional baseada nos tipos de seus dois
ramos) sempre podem produzir um tipo. Antes de null safety, Object era o top
type do Dart e Null era seu bottom type.
Como Object é non-nullable agora, não é mais um top type. Null não é um
subtipo dele. Dart não tem um top type nomeado. Se você precisa de um top type, você quer
Object?. Da mesma forma, Null não é mais o bottom type. Se fosse, tudo
ainda seria nullable. Em vez disso, adicionamos um novo bottom type chamado Never:
Na prática, isso significa:
-
Se você quer indicar que você permite um valor de qualquer tipo, use
Object?em vez deObject. Na verdade, torna-se bastante incomum usarObjectjá que esse tipo significa "poderia ser qualquer valor possível exceto este valor estranhamente proibidonull". -
Na rara ocasião em que você precisa de um bottom type, use
Neverem vez deNull. Isso é particularmente útil para indicar que uma função nunca retorna para ajudar na análise de acessibilidade. Se você não sabe se precisa de um bottom type, provavelmente não precisa.
Ensuring correctness
#
Dividimos o universo de tipos em metades nullable e non-nullable. Para
manter a soundness e nosso princípio de que você nunca pode obter um erro de referência
nula em tempo de execução a menos que você peça por isso, precisamos garantir que
null nunca
apareça em qualquer tipo no lado non-nullable.
Livrar-se de implicit downcasts e remover Null como um bottom type cobre
todos os principais lugares onde tipos fluem através de um programa através de atribuições e
de argumentos para parâmetros em chamadas de função. Os principais lugares restantes
onde null pode se infiltrar são quando uma variável aparece pela primeira vez e quando
você sai de uma função. Então há alguns erros de compilação adicionais:
Invalid returns
#
Se uma função tem um non-nullable return type, então todo caminho através da
função deve atingir uma declaração return que retorna um valor. Antes de null
safety, o Dart era bem relaxado sobre retornos faltantes. Por exemplo:
// Without null safety:
String missingReturn() {
// No return.
}
Se você analisou isso, você recebeu uma dica gentil de que talvez você esqueceu um retorno,
mas se não, não há problema. Isso porque se a execução alcança o final de um
corpo de função, então o Dart implicitamente retorna null. Como todo tipo é nullable,
tecnicamente esta função é segura, mesmo que provavelmente não seja o que você
quer.
Com non-nullable types seguros (sound), este programa está completamente errado e inseguro. Com null safety, você obtém um erro de compilação se uma função com um non-nullable return type não retorna confiavelmente um valor. Por "confiavelmente", quero dizer que a linguagem analisa todos os caminhos de fluxo de controle através da função. Desde que todos eles retornem algo, ela está satisfeita. A análise é bem inteligente, então até esta função está OK:
// Using null safety:
String alwaysReturns(int n) {
if (n == 0) {
return 'zero';
} else if (n < 0) {
throw ArgumentError('Negative values not allowed.');
} else {
if (n > 1000) {
return 'big';
} else {
return n.toString();
}
}
}
Vamos mergulhar mais profundamente na nova análise de fluxo na próxima seção.
Uninitialized variables
#
Quando você declara uma variável, se você não der a ela um inicializador explícito, o Dart
inicializa a variável por padrão com null. Isso é conveniente, mas obviamente
totalmente inseguro se o tipo da variável é non-nullable. Então temos que apertar
as coisas para variáveis non-nullable:
-
Declarações de variável de nível superior e campo estático devem ter um inicializador. Como esses podem ser acessados e atribuídos de qualquer lugar no programa, é impossível para o compilador garantir que a variável tenha recebido um valor antes de ser usada. A única opção segura é exigir a própria declaração de ter uma expressão inicializadora que produza um valor do tipo certo:
dart// Using null safety: int topLevel = 0; class SomeClass { static int staticField = 0; } -
Campos de instância devem ter um inicializador na declaração, usar um inicializing formal, ou ser inicializados na lista de inicialização do construtor. Isso é muito jargão. Aqui estão os exemplos:
dart// Using null safety: class SomeClass { int atDeclaration = 0; int initializingFormal; int initializationList; SomeClass(this.initializingFormal) : initializationList = 0; }Em outras palavras, desde que o campo tenha um valor antes de você alcançar o corpo do construtor, você está bem.
-
Variáveis locais são o caso mais flexível. Uma variável local non-nullable não precisa ter um inicializador. Isso é perfeitamente aceitável:
dart// Using null safety: int tracingFibonacci(int n) { int result; if (n < 2) { result = n; } else { result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1); } print(result); return result; }A regra é apenas que uma variável local deve ser definitivamente atribuída antes de ser usada. Chegamos a confiar na nova análise de fluxo que aludi para isso também. Desde que todo caminho para o uso de uma variável a inicialize primeiro, o uso está OK.
-
Parâmetros opcionais devem ter um valor padrão. Se você não passar um argumento para um parâmetro posicional ou nomeado opcional, então a linguagem preenche com o valor padrão. Se você não especifica um valor padrão, o valor padrão padrão é
null, e isso não funciona se o tipo do parâmetro é non-nullable.Então, se você quer que um parâmetro seja opcional, você precisa torná-lo nullable ou especificar um valor padrão não-
nullválido.
Essas restrições soam onerosas, mas não são muito ruins na prática. Elas são
muito similares às restrições existentes em torno de variáveis final
e você provavelmente
tem trabalhado com elas por anos sem sequer realmente notar. Além disso,
lembre-se de que elas só se aplicam a variáveis non-nullable. Você sempre pode tornar
o tipo nullable e então obter a inicialização padrão para null.
Mesmo assim, as regras causam fricção. Felizmente, temos um conjunto de novos recursos de linguagem para lubrificar os padrões mais comuns onde essas novas limitações te atrasam. Primeiro, porém, é hora de falar sobre análise de fluxo.
Flow analysis
#Análise de fluxo de controle existe em compiladores há anos. É principalmente escondida dos usuários e usada durante otimização do compilador, mas algumas linguagens mais novas começaram a usar as mesmas técnicas para recursos de linguagem visíveis. O Dart já tem um pouco de análise de fluxo na forma de type promotion:
// With (or without) null safety:
bool isEmptyList(Object object) {
if (object is List) {
return object.isEmpty; // <-- OK!
} else {
return false;
}
}
Note como na linha marcada, podemos chamar isEmpty em object. Esse método é
definido em List, não em Object. Isso funciona porque o type checker olha para
todas as expressões is e os caminhos de fluxo de controle no programa. Se o
corpo de alguma construção de fluxo de controle só executa quando uma certa expressão
is
em uma variável é verdadeira, então dentro desse corpo o tipo da variável é "promovido"
para o tipo testado.
No exemplo aqui, o ramo then da declaração if só executa quando
object realmente contém uma lista. Portanto, o Dart promove object
para o tipo
List em vez de seu tipo declarado Object. Isso é um recurso útil, mas é
bem limitado. Antes de null safety, o seguinte programa funcionalmente idêntico
não funcionava:
// Without null safety:
bool isEmptyList(Object object) {
if (object is! List) return false;
return object.isEmpty; // <-- Error!
}
Novamente, você só pode alcançar a chamada .isEmpty quando object contém uma lista, então
este programa é dinamicamente correto. Mas as regras de type promotion não eram inteligentes
o suficiente para ver que a declaração return significa que a segunda declaração só pode ser
alcançada quando object é uma lista.
Para null safety, pegamos esta análise limitada e a tornamos muito mais poderosa de várias maneiras.
Reachability analysis
#
Primeiro, corrigimos a reclamação antiga de que type promotion
não é inteligente sobre retornos antecipados e outros caminhos de código inalcançáveis. Ao analisar
uma função, agora leva em conta return, break, throw, e qualquer outra
forma de execução poder terminar cedo em uma função. Com null safety, esta função:
// Using null safety:
bool isEmptyList(Object object) {
if (object is! List) return false;
return object.isEmpty;
}
Agora é perfeitamente válida. Como a declaração if sairá da função quando
object não é uma List, o Dart promove object
para ser List na segunda
declaração. Isso é uma melhoria realmente boa que ajuda muito código Dart, mesmo
coisas não relacionadas a nullability.
Never for unreachable code
#
Você também pode programar esta análise de acessibilidade. O novo bottom type Never
não tem valores. (Que tipo de valor é simultaneamente uma String, bool, e
int?) Então o que significa para uma expressão ter tipo Never? Significa
que a expressão nunca pode terminar de avaliar com sucesso. Ela deve lançar uma
exceção, abortar, ou de outra forma garantir que o código circundante esperando o
resultado da expressão nunca execute.
Na verdade, de acordo com a linguagem, o tipo estático de uma expressão throw é
Never. O tipo Never é declarado nas bibliotecas principais e você pode usá-lo
como uma anotação de tipo. Talvez você tenha uma função auxiliar para facilitar o lançamento
de um certo tipo de exceção:
// Using null safety:
Never wrongType(String type, Object value) {
throw ArgumentError('Expected $type, but was ${value.runtimeType}.');
}
Você pode usá-la assim:
// Using null safety:
class Point {
final int x, y;
Point(this.x, this.y);
Point operator +(Object other) {
if (other is int) return Point(x + other, y + other);
if (other is! Point) wrongType('int | Point', other);
print('Adding two Point instances together: $this + $other');
return Point(x + other.x, y + other.y);
}
// toString, hashCode, and other implementations...
}
Este programa analisa sem erro. Note que a última linha do método +
acessa .x e .y em other. Ele foi promovido para
Point mesmo
que a função não tenha nenhum return ou throw. A análise de fluxo de controle
sabe que o tipo declarado de wrongType() é Never o que significa
o ramo then da declaração if deve abortar de alguma forma. Como a declaração
final só pode ser alcançada quando other é um Point, o Dart o promove.
Em outras palavras, usar Never em suas próprias APIs permite que você estenda a
análise de acessibilidade do Dart.
Definite assignment analysis
#Mencionei isso brevemente com variáveis locais. O Dart precisa garantir que uma variável local non-nullable seja sempre inicializada antes de ser lida. Usamos definite assignment analysis para ser o mais flexível possível sobre isso. A linguagem analisa cada corpo de função e rastreia as atribuições a variáveis locais e parâmetros através de todos os caminhos de fluxo de controle. Desde que a variável seja atribuída em cada caminho que alcança algum uso de uma variável, a variável é considerada inicializada. Isso permite que você declare uma variável sem inicializador e então a inicialize depois usando fluxo de controle complexo, mesmo quando a variável tem um non-nullable type.
Também usamos definite assignment analysis para tornar variáveis final mais
flexíveis. Antes de null safety, pode ser difícil usar final para variáveis
locais se você precisa inicializá-las de qualquer forma interessante:
// Using null safety:
int tracingFibonacci(int n) {
final int result;
if (n < 2) {
result = n;
} else {
result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
}
print(result);
return result;
}
Isso seria um erro já que a variável result é final mas não tem
inicializador. Com a análise de fluxo mais inteligente com null safety, este programa está
bem. A análise pode dizer que result é definitivamente inicializado exatamente uma vez
em cada caminho de fluxo de controle, então as restrições para marcar uma variável
final
são satisfeitas.
Type promotion on null checks
#
A análise de fluxo mais inteligente ajuda muito código Dart, mesmo código não relacionado a
nullability. Mas não é uma coincidência que estamos fazendo essas mudanças agora. Nós
particionamos tipos em conjuntos nullable e non-nullable. Se você tem um valor
de um nullable type, você não pode realmente fazer nada útil com ele. Em casos
onde o valor é null, essa restrição é boa. Está prevenindo você de
travar.
Mas se o valor não é null, seria bom poder movê-lo para
o lado non-nullable para que você possa chamar métodos nele. Análise de fluxo é uma das
formas principais de fazer isso para variáveis locais e parâmetros
(e campos privados final, a partir do Dart 3.2). Estendemos type
promotion para também olhar para expressões == null e != null.
Se você verifica uma variável local com nullable type para ver se ela não é null,
o Dart então promove a variável para o non-nullable type subjacente:
// Using null safety:
String makeCommand(String executable, [List<String>? arguments]) {
var result = executable;
if (arguments != null) {
result += ' ' + arguments.join(' ');
}
return result;
}
Aqui, arguments tem um nullable type. Normalmente, isso proíbe você de chamar
.join() nele. Mas porque protegemos essa chamada em uma declaração
if que
verifica para garantir que o valor não é null, o Dart o promove de
List<String>?
para List<String> e permite que você chame métodos nele ou passe para funções que
esperam listas non-nullable.
Isso parece uma coisa relativamente menor, mas esta promoção baseada em fluxo em verificações de null
é o que faz a maior parte do código Dart existente funcionar com null safety. A maioria do código Dart
é dinamicamente correto e evita lançar erros de referência nula
verificando por null antes de chamar métodos. A nova análise de fluxo em verificações de null
transforma essa correção dinâmica em correção estática comprovável.
Também, é claro, funciona com a análise mais inteligente que fazemos para acessibilidade. A função acima pode ser escrita igualmente bem como:
// Using null safety:
String makeCommand(String executable, [List<String>? arguments]) {
var result = executable;
if (arguments == null) return result;
return result + ' ' + arguments.join(' ');
}
A linguagem também é mais inteligente sobre que tipos de expressões causam promoção. Um
== null ou != null explícito é claro que funciona. Mas casts explícitos usando
as,
ou atribuições, ou o operador postfix !
(que cobriremos mais tarde) também causam
promoção. O objetivo geral é que se o código é dinamicamente correto e é
razoável descobrir isso estaticamente, a análise deve ser inteligente o suficiente
para fazê-lo.
Note que type promotion originalmente só funcionava em variáveis locais, e agora também funciona em campos privados final a partir do Dart 3.2. Para mais informações sobre trabalhar com variáveis não-locais, veja Trabalhando com campos nullable.
Unnecessary code warnings
#
Ter análise de acessibilidade mais inteligente e saber onde null pode fluir através
do seu programa ajuda a garantir que você adicione código para lidar com
null. Mas também podemos
usar a mesma análise para detectar código que você não precisa. Antes de null safety,
se você escreveu algo como:
// Using null safety:
String checkList(List<Object> list) {
if (list?.isEmpty ?? false) {
return 'Got nothing';
}
return 'Got something';
}
O Dart não tinha como saber se aquele operador null-aware ?. é útil ou não.
Pelo que ele sabe, você poderia passar null para a função. Mas em Dart null-safe,
se você anotou essa função com o tipo List agora non-nullable, então
ele sabe que list nunca será null. Isso implica que o
?. nunca fará
nada útil e você pode e deve usar apenas ..
Para ajudá-lo a simplificar seu código, adicionamos avisos para código desnecessário como
este agora que a análise estática é precisa o suficiente para detectá-lo. Usar um
operador null-aware ou mesmo uma verificação como == null ou != null
em um
non-nullable type é relatado como um aviso.
E, claro, isso funciona com promoção de non-nullable type também. Uma vez que uma
variável foi promovida para um non-nullable type, você recebe um aviso se você
redundantemente verificá-la novamente por null:
// Using null safety:
String checkList(List<Object>? list) {
if (list == null) return 'No list';
if (list?.isEmpty ?? false) {
return 'Empty list';
}
return 'Got something';
}
Você recebe um aviso no ?. aqui porque no ponto em que ele executa, já
sabemos que list não pode ser null. O objetivo com esses avisos não é apenas
limpar código inútil. Ao remover verificações desnecessárias por null, garantimos
que as verificações significativas restantes se destaquem. Queremos que você seja capaz de olhar
para seu código e ver onde null pode fluir.
Working with nullable types
#
Agora acorralamos null no conjunto de nullable types. Com análise de fluxo,
podemos seguramente deixar alguns valores não-null pular a cerca para o lado
non-nullable onde podemos usá-los. Isso é um grande passo, mas se pararmos aqui, o
sistema resultante ainda é dolorosamente restritivo. Análise de fluxo só ajuda com
locais, parâmetros e campos privados final.
Para tentar recuperar tanto da flexibilidade que o Dart tinha antes de null safety—e ir além disso em alguns lugares—temos um punhado de outros novos recursos.
Smarter null-aware methods
#
O operador null aware ?. do Dart é muito mais antigo que null safety. A semântica
em tempo de execução afirma que se o receptor for null então o acesso à propriedade no
lado direito é pulado e a expressão avalia para null:
// Without null safety:
String notAString = null;
print(notAString?.length);
Em vez de lançar uma exceção, isso imprime "null". O operador null-aware é uma ferramenta útil para tornar nullable types utilizáveis em Dart. Embora não possamos deixar você chamar métodos em nullable types, podemos e deixamos você usar operadores null-aware neles. A versão pós-null safety do programa é:
// Using null safety:
String? notAString = null;
print(notAString?.length);
Funciona igual ao anterior.
No entanto, se você já usou operadores null-aware em Dart, provavelmente encontrou um aborrecimento ao usá-los em cadeias de métodos. Digamos que você queira ver se o comprimento de uma string potencialmente ausente é um número par (não um problema particularmente realista, eu sei, mas trabalhe comigo aqui):
// Using null safety:
String? notAString = null;
print(notAString?.length.isEven);
Mesmo que este programa use ?., ele ainda lança uma exceção em tempo de execução. O
problema é que o receptor da expressão .isEven é o resultado de
toda a expressão notAString?.length à sua esquerda. Essa expressão avalia para
null, então obtemos um erro de referência nula tentando chamar .isEven. Se você já
usou ?. em Dart, provavelmente aprendeu da maneira difícil que você tem que aplicar
o operador null-aware a toda propriedade ou método em uma cadeia depois de usá-lo
uma vez:
String? notAString = null;
print(notAString?.length?.isEven);
Isso é irritante, mas, pior, obscurece informações importantes. Considere:
// Using null safety:
showGizmo(Thing? thing) {
print(thing?.doohickey?.gizmo);
}
Aqui está uma pergunta para você: O getter doohickey em Thing pode retornar
null?
Parece que poderia porque você está usando ?. no resultado. Mas pode
ser apenas que o segundo ?. está lá apenas para lidar com casos onde
thing é
null, não o resultado de doohickey. Você não pode dizer.
Para abordar isso, pegamos uma ideia inteligente do design do C# do mesmo recurso.
Quando você usa um operador null-aware em uma cadeia de métodos, se o receptor avalia
para null, então o restante inteiro da cadeia de métodos é curto-circuitado e
pulado. Isso significa que se doohickey tem um non-nullable return type, então você
pode e deve escrever:
// Using null safety:
void showGizmo(Thing? thing) {
print(thing?.doohickey.gizmo);
}
Na verdade, você receberá um aviso de código desnecessário no segundo ?. se
não fizer isso. Se você vir código como:
// Using null safety:
void showGizmo(Thing? thing) {
print(thing?.doohickey?.gizmo);
}
Então você sabe com certeza que isso significa que doohickey em si tem um nullable return
type. Cada ?. corresponde a um caminho único que pode causar
null fluir
para a cadeia de métodos. Isso torna operadores null-aware em cadeias de métodos tanto
mais tersas quanto mais precisas.
Enquanto estávamos nisso, adicionamos alguns outros operadores null-aware:
// Using null safety:
// Null-aware cascade:
receiver?..method();
// Null-aware index operator:
receiver?[index];
Não há um operador de chamada de função null-aware, mas você pode escrever:
// Allowed with or without null safety:
function?.call(arg1, arg2);
Not-null assertion operator
#A grande coisa sobre usar análise de fluxo para mover uma variável nullable para o lado non-nullable do mundo é que fazer isso é comprovadamente seguro. Você consegue chamar métodos na variável anteriormente-nullable sem desistir de nada da segurança ou desempenho de non-nullable types.
Mas muitos usos válidos de nullable types não podem ser provados serem seguros de uma forma que agrade a análise estática. Por exemplo:
// Using null safety, incorrectly:
class HttpResponse {
final int code;
final String? error;
HttpResponse.ok()
: code = 200,
error = null;
HttpResponse.notFound()
: code = 404,
error = 'Not found';
@override
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${error.toUpperCase()}';
}
}
Se você tentar executar isso, você obtém um erro de compilação na chamada para toUpperCase().
O campo error é nullable porque não terá um valor em uma
resposta bem-sucedida. Podemos ver inspecionando a classe que nunca acessamos a mensagem
error
quando ela é null. Mas isso requer entender a relação
entre o valor de code e a nullability de error. O type checker
não pode ver essa conexão.
Em outras palavras, nós mantenedores humanos do código sabemos que error não será
null no ponto em que o usamos e precisamos de uma maneira de afirmar isso. Normalmente,
você afirma tipos usando um cast as, e você pode fazer a mesma coisa aqui:
// Using null safety:
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${(error as String).toUpperCase()}';
}
Fazer cast de error para o tipo String non-nullable lançará uma exceção em tempo de execução
se o cast falhar. Caso contrário, nos dá uma string non-nullable que podemos então
chamar métodos.
"Fazer cast da nullability" surge com frequência suficiente que temos uma nova sintaxe
abreviada. Um ponto de exclamação postfix (!) pega a expressão à esquerda e
faz cast para seu tipo non-nullable subjacente. Então a função acima é
equivalente a:
// Using null safety:
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${error!.toUpperCase()}';
}
Este operador "bang" de um caractere é particularmente útil quando o tipo
subjacente é verboso. Seria realmente irritante ter que escrever as Map<TransactionProviderFactory, List<Set<ResponseFilter>>>
apenas para fazer cast de um
único ? de algum tipo.
É claro, como qualquer cast, usar ! vem com uma perda de segurança estática. O cast
deve ser verificado em tempo de execução para preservar soundness e pode falhar e lançar uma
exceção. Mas você tem controle sobre onde esses casts são inseridos, e você pode
sempre vê-los olhando através do seu código.
Late variables
#O lugar mais comum onde o type checker não pode provar a segurança do código é em torno de variáveis e campos de nível superior. Aqui está um exemplo:
// Using null safety, incorrectly:
class Coffee {
String _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature + ' coffee';
}
void main() {
var coffee = Coffee();
coffee.heat();
coffee.serve();
}
Aqui, o método heat() é chamado antes de serve(). Isso significa que _temperature
será inicializado para um valor não-null antes de ser usado. Mas não é viável
para uma análise estática determinar isso. (Pode ser possível para um exemplo
trivial como este, mas o caso geral de tentar rastrear o estado de cada
instância de uma classe é intratável.)
Como o type checker não pode analisar usos de campos e variáveis de nível superior, ele tem uma regra conservadora de que campos non-nullable têm que ser inicializados ou em sua declaração (ou na lista de inicialização do construtor para campos de instância). Então o Dart relata um erro de compilação nesta classe.
Você pode corrigir o erro tornando o campo nullable e então usando operadores not-null assertion nos usos:
// Using null safety:
class Coffee {
String? _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature! + ' coffee';
}
Isso funciona bem. Mas envia um sinal confuso ao mantenedor da classe.
Ao marcar _temperature nullable, você implica que null
é um valor útil,
significativo para aquele campo. Mas não é a intenção. O campo _temperature
nunca deve ser observado em seu estado null.
Para lidar com o padrão comum de estado com inicialização atrasada, adicionamos um
novo modificador, late. Você pode usá-lo assim:
// Using null safety:
class Coffee {
late String _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature + ' coffee';
}
Note que o campo _temperature tem um non-nullable type,
mas não é inicializado.
Além disso, não há not-null assertion explícita quando é usado.
Há alguns modelos que você pode aplicar à semântica de late,
mas eu penso assim: O modificador late significa
"impor as restrições desta variável em tempo de execução em vez de em tempo de compilação".
É quase como a palavra "late" descreve quando
ele impõe as garantias da variável.
Neste caso, já que o campo não é definitivamente inicializado, toda vez que o
campo é lido, uma verificação em tempo de execução é inserida para ter certeza de que foi atribuído um
valor. Se não foi, uma exceção é lançada. Dar à variável o tipo
String significa "você nunca deve me ver com um valor diferente de uma string" e
o modificador late significa "verificar isso em tempo de execução".
De algumas maneiras, o modificador late é mais "mágico" do que usar ? porque qualquer
uso do campo pode falhar, e não há nada textualmente visível no
local de uso. Mas você precisa escrever late na declaração para obter esse
comportamento, e nossa crença é que ver o modificador lá é explícito o suficiente
para que isso seja sustentável.
Em troca, você obtém melhor segurança estática do que usar um nullable type. Porque o
tipo do campo é non-nullable agora, é um erro de compilação tentar atribuir
null ou uma String nullable ao campo. O modificador late
permite que você adie
a inicialização, mas ainda proíbe você de tratá-lo como uma variável
nullable.
Lazy initialization
#
O modificador late tem alguns outros super poderes também. Pode parecer paradoxal,
mas você pode usar late em um campo que tem um inicializador:
// Using null safety:
class Weather {
late int _temperature = _readThermometer();
}
Quando você faz isso, o inicializador se torna lazy. Em vez de executá-lo assim que a instância é construída, ele é adiado e executado preguiçosamente na primeira vez que o campo é acessado. Em outras palavras, funciona exatamente como um inicializador em uma variável de nível superior ou campo estático. Isso pode ser útil quando a expressão de inicialização é custosa e pode não ser necessária.
Executar o inicializador preguiçosamente te dá um bônus extra quando você usa late em
um campo de instância. Normalmente, inicializadores de campos de instância não podem acessar
this
porque você não tem acesso ao novo objeto até que todos os inicializadores de campo tenham
completado. Mas com um campo late, isso não é mais verdade, então você
pode
acessar this, chamar métodos, ou acessar campos na instância.
Late final variables
#Você também pode combinar late com final:
// Using null safety:
class Coffee {
late final String _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature + ' coffee';
}
Ao contrário de campos final normais, você não precisa inicializar o campo em sua
declaração ou na lista de inicialização do construtor. Você pode atribuir a ele
depois em tempo de execução. Mas você só pode atribuir a ele uma vez, e esse fato é verificado
em tempo de execução. Se você tentar atribuir a ele mais de uma vez—como chamar tanto
heat() quanto chill() aqui—a segunda atribuição lança uma exceção.
Esta é uma ótima maneira de modelar estado que é inicializado eventualmente e é
imutável depois.
Em outras palavras, o novo modificador late em combinação com os outros
modificadores de variável do Dart cobre a maior parte do espaço de recursos de lateinit
em Kotlin e
lazy em Swift. Você pode até usá-lo em variáveis locais se quiser um pouco de
avaliação preguiçosa local.
Required named parameters
#
Para garantir que você nunca veja um parâmetro null com um non-nullable type, o
type checker exige que todos os parâmetros opcionais tenham um nullable type ou
um valor padrão. E se você quiser ter um parâmetro nomeado com um non-nullable
type e sem valor padrão? Isso implicaria que você quer exigir que o chamador
sempre passe-o. Em outras palavras, você quer um parâmetro que é nomeado
mas não opcional.
Eu visualizo os vários tipos de parâmetros Dart com esta tabela:
mandatory optional
+------------+------------+
positional | f(int x) | f([int x]) |
+------------+------------+
named | ??? | f({int x}) |
+------------+------------+
Por razões pouco claras, o Dart há muito tempo suporta três cantos desta tabela mas
deixou a combinação de nomeado+obrigatório vazia. Com null safety, preenchemos isso.
Você declara um parâmetro nomeado obrigatório colocando required antes do
parâmetro:
// Using null safety:
function({int? a, required int? b, int? c, required int? d}) {}
Aqui, todos os parâmetros devem ser passados por nome. Os parâmetros a e c são
opcionais e podem ser omitidos. Os parâmetros b e d são obrigatórios e devem
ser passados. Note que obrigatoriedade é independente de nullability. Você pode ter
parâmetros nomeados obrigatórios de nullable types, e parâmetros nomeados opcionais de
non-nullable types (se eles tiverem um valor padrão).
Este é outro daqueles recursos que acho que torna o Dart melhor independentemente de null safety. Simplesmente faz a linguagem parecer mais completa para mim.
Abstract fields
#Um dos recursos legais do Dart é que ele mantém uma coisa chamada princípio de acesso uniforme. Em termos humanos isso significa que campos são indistinguíveis de getters e setters. É um detalhe de implementação se uma "propriedade" em alguma classe Dart é computada ou armazenada. Por causa disso, ao definir uma interface usando uma classe abstrata, é típico usar uma declaração de campo:
abstract class Cup {
Beverage contents;
}
A intenção é que os usuários apenas implementem essa classe e não a estendam. A sintaxe de campo é simplesmente uma forma mais curta de escrever um par getter/setter:
abstract class Cup {
Beverage get contents;
set contents(Beverage);
}
Mas o Dart não sabe que esta classe nunca será usada como um tipo concreto.
Ele vê aquela declaração contents como um campo real.
E, infelizmente, aquele campo é non-nullable e não tem inicializador,
então você obtém um erro de compilação.
Uma correção é usar declarações explícitas de getter/setter abstratos como no segundo exemplo. Mas isso é um pouco verboso, então com null safety também adicionamos suporte para declarações de campo abstrato explícitas:
abstract class Cup {
abstract Beverage contents;
}
Isso se comporta exatamente como o segundo exemplo. Simplesmente declara um getter e setter abstratos com o nome e tipo dados.
Working with nullable fields
#
Esses novos recursos cobrem muitos padrões comuns e tornam trabalhar com null
bastante indolor na maioria das vezes. Mas mesmo assim, nossa experiência é que campos nullable
ainda podem ser difíceis. Em casos onde você pode tornar o campo late
e
non-nullable, você está bem. Mas em muitos casos você precisa verificar
para ver se o
campo tem um valor, e isso requer torná-lo nullable para que você possa observar o
null.
Campos nullable que são tanto privados quanto final podem ser promovidos em tipo (excluindo algumas razões particulares). Se você não pode tornar um campo privado e final por qualquer razão, você ainda precisará de uma solução alternativa.
Por exemplo, você pode esperar que isso funcione:
// Using null safety, incorrectly:
class Coffee {
String? _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
void checkTemp() {
if (_temperature != null) {
print('Ready to serve ' + _temperature + '!');
}
}
String serve() => _temperature! + ' coffee';
}
Dentro de checkTemp(), verificamos para ver se _temperature é null. Se não for, nós
acessamos e acabamos chamando + nele. Infelizmente, isso não é permitido.
Type promotion baseada em fluxo só pode aplicar a campos que são tanto privados quanto final.
Caso contrário, a análise estática não pode provar que o valor do campo não
muda entre o ponto em que você verifica por null e o ponto em que você o usa.
(Considere que em casos patológicos, o campo em si poderia ser sobrescrito por um
getter em uma subclasse que retorna null na segunda vez que é chamado.)
Então, já que nos preocupamos com soundness, campos públicos e/ou não-final não promovem,
e o método acima não compila. Isso é irritante.
Em casos simples como aqui, sua melhor aposta é dar um ! no uso do campo.
Parece redundante, mas isso é mais ou menos como o Dart se comporta hoje.
Outro padrão que ajuda é copiar o campo para uma variável local primeiro e então usar essa em vez disso:
// Using null safety:
void checkTemp() {
var temperature = _temperature;
if (temperature != null) {
print('Ready to serve ' + temperature + '!');
}
}
Como type promotion se aplica a locais, isso agora funciona bem. Se você precisa mudar o valor, apenas lembre-se de armazenar de volta no campo e não apenas no local.
Para mais informações sobre lidar com essas e outras questões de type promotion, veja Corrigindo falhas de type promotion.
Nullability and generics
#Como a maioria das linguagens estaticamente tipadas modernas, o Dart tem classes genéricas e métodos genéricos. Eles interagem com nullability de algumas maneiras que parecem contra-intuitivas mas fazem sentido uma vez que você pensa nas implicações. Primeiro é que "este tipo é nullable?" não é mais uma simples pergunta sim ou não. Considere:
// Using null safety:
class Box<T> {
final T object;
Box(this.object);
}
void main() {
Box<String>('a string');
Box<int?>(null);
}
Na definição de Box, T é um nullable type ou um non-nullable type? Como
você pode ver, pode ser instanciado com qualquer tipo. A resposta é que T
é um
potentially nullable type. Dentro do corpo de uma classe ou método genérico, um
potentially nullable type tem todas as restrições tanto de nullable types
quanto de non-nullable types.
O primeiro significa que você não pode chamar nenhum método nele exceto o punhado definido em Object. O último significa que você deve inicializar quaisquer campos ou variáveis desse tipo antes de serem usados. Isso pode tornar parâmetros de tipo bem difíceis de trabalhar.
Na prática, alguns padrões aparecem. Em classes parecidas com coleções onde o parâmetro de tipo pode ser instanciado com qualquer tipo, você apenas tem que lidar com as restrições. Na maioria dos casos, como o exemplo aqui, isso significa garantir que você tenha acesso a um valor do tipo do argumento de tipo sempre que você precisar trabalhar com um. Felizmente, classes parecidas com coleções raramente chamam métodos em seus elementos.
Em lugares onde você não tem acesso a um valor, você pode tornar o uso do parâmetro de tipo nullable:
// Using null safety:
class Box<T> {
T? object;
Box.empty();
Box.full(this.object);
}
Note o ? na declaração de object. Agora o campo tem um
tipo explicitamente nullable, então está tudo bem deixá-lo sem inicializar.
Quando você torna um parâmetro de tipo nullable como T? aqui, você pode precisar
fazer cast da nullability. A maneira correta de fazer isso é usando um cast as T
explícito, não o operador !:
// Using null safety:
class Box<T> {
T? object;
Box.empty();
Box.full(this.object);
T unbox() => object as T;
}
O operador ! sempre lança se o valor for null. Mas se o parâmetro
de tipo foi instanciado com um nullable type, então null é um valor perfeitamente
válido para T:
// Using null safety:
void main() {
var box = Box<int?>.full(null);
print(box.unbox());
}
Este programa deve executar sem erro. Usar as T consegue isso. Usar
! lançaria uma exceção.
Outros tipos genéricos têm algum limite que restringe os tipos de argumentos de tipo que podem ser aplicados:
// Using null safety:
class Interval<T extends num> {
T min, max;
Interval(this.min, this.max);
bool get isEmpty => max <= min;
}
Se o limite é non-nullable, então o parâmetro de tipo também é non-nullable. Isso significa que você tem as restrições de non-nullable types—você não pode deixar campos e variáveis sem inicializar. A classe de exemplo aqui deve ter um construtor que inicializa os campos.
Em troca por essa restrição, você pode chamar quaisquer métodos em valores do tipo parâmetro de tipo que são declarados em seu limite. Ter um limite non-nullable, no entanto, impede usuários de sua classe genérica de instanciá-la com um argumento de tipo nullable. Essa é provavelmente uma limitação razoável para a maioria das classes.
Você também pode usar um limite nullable:
// Using null safety:
class Interval<T extends num?> {
T min, max;
Interval(this.min, this.max);
bool get isEmpty {
var localMin = min;
var localMax = max;
// No min or max means an open-ended interval.
if (localMin == null || localMax == null) return false;
return localMax <= localMin;
}
}
Isso significa que no corpo da classe você obtém a flexibilidade de tratar o
parâmetro de tipo como nullable, mas você também tem as limitações de nullability.
Você não pode chamar nada em uma variável desse tipo
a menos que você lide com a nullability primeiro. No exemplo aqui,
copiamos os campos em variáveis locais e verificamos essas locais por null
para que a análise de fluxo as promova para non-nullable types antes de usarmos
<=.
Note que um limite nullable não impede usuários de instanciar a classe
com non-nullable types. Um limite nullable significa que o argumento de tipo pode
ser
nullable, não que ele deve. (Na verdade, o limite padrão em parâmetros de tipo se
você não escreve uma cláusula extends é o limite nullable Object?.) Não há
maneira de exigir um argumento de tipo nullable. Se você quer que usos do parâmetro
de tipo sejam confiavelmente nullable e sejam implicitamente inicializados para
null,
você pode usar T? dentro do corpo da classe.
Core library changes
#
Há algumas outras pequenas mudanças aqui e ali na linguagem, mas elas são
menores. Coisas como o tipo padrão de um catch sem cláusula on
agora é
Object em vez de dynamic. Análise de fallthrough em declarações switch usa
a nova análise de fluxo.
As mudanças restantes que realmente importam para você estão nas bibliotecas principais.
Antes de embarcarmos na Grande Aventura de Null Safety, nos preocupávamos que acabaria
não havendo maneira de tornar nossas bibliotecas principais null safe sem massivamente
quebrar o mundo. Acabou não sendo tão terrível. Há algumas mudanças significativas,
mas na maior parte, a migração foi suave. A maioria das bibliotecas principais
ou não aceitava null e naturalmente passam para non-nullable types, ou aceitam e
graciosamente aceitam com um nullable type.
Há alguns cantos importantes, no entanto:
The Map index operator is nullable
#
Isso não é realmente uma mudança, mas mais uma coisa para saber. O operador de índice []
na
classe Map retorna null se a chave não está presente. Isso implica que o
tipo de retorno desse operador deve ser nullable: V? em vez de V.
Poderíamos ter mudado esse método para lançar uma exceção quando a chave não está
presente e então dado a ele um tipo de retorno non-nullable mais fácil de usar. Mas código
que usa o operador de índice e verifica por null para ver se a chave está ausente
é muito comum, cerca de metade de todos os usos baseados em nossa análise. Quebrar todo esse
código teria incendiado o ecossistema Dart.
Em vez disso, o comportamento em tempo de execução é o mesmo e, portanto, o tipo de retorno é obrigado a ser nullable. Isso significa que você geralmente não pode usar imediatamente o resultado de uma busca em map:
// Using null safety, incorrectly:
var map = {'key': 'value'};
print(map['key'].length); // Error.
Isso te dá um erro de compilação na tentativa de chamar .length em uma
string nullable. Em casos onde você sabe que a chave está presente você pode ensinar o type
checker usando !:
// Using null safety:
var map = {'key': 'value'};
print(map['key']!.length); // OK.
Consideramos adicionar outro método ao Map que faria isso para você: buscar a
chave, lançar se não encontrada, ou retornar um valor non-nullable caso contrário. Mas como
chamá-lo? Nenhum nome seria mais curto que o ! de um único caractere, e nenhum
nome de método seria mais claro do que ver um ! com sua semântica embutida bem
ali no local da chamada. Então a maneira idiomática de acessar um elemento conhecido-presente
em um map é usar []!. Você se acostuma.
No unnamed List constructor
#
O construtor sem nome em List cria uma nova lista com o tamanho dado mas
não inicializa nenhum dos elementos. Isso abriria um buraco muito grande nas
garantias de soundness se você criasse uma lista de um non-nullable type e então
acessasse um elemento.
Para evitar isso, removemos o construtor completamente. É um erro chamar
List() em código null-safe, mesmo com um nullable type. Isso parece assustador, mas
na prática a maioria do código cria listas usando literais de lista, List.filled(),
List.generate(), ou como resultado de transformar alguma outra coleção. Para
o caso extremo onde você quer criar uma lista vazia de algum tipo, adicionamos um
novo construtor List.empty().
O padrão de criar uma lista completamente não inicializada sempre pareceu fora de lugar no Dart, e agora é ainda mais. Se você tem código quebrado por isso, você sempre pode corrigi-lo usando uma das muitas outras maneiras de produzir uma lista.
Cannot set a larger length on non-nullable lists
#
Isso é pouco conhecido, mas o getter length em List também tem um
setter correspondente. Você pode definir o length para um valor menor para truncar a lista. E
você também pode defini-lo para um length maior para preencher a lista com elementos
não inicializados.
Se você fizesse isso com uma lista de um non-nullable type, você violaria
soundness quando depois acessasse esses elementos não escritos. Para prevenir isso, o
setter length lançará uma exceção em tempo de execução se (e somente se) a lista tem um
elemento non-nullable type e você defini-lo para um length maior. Ainda está
bem truncar listas de todos os tipos, e você pode aumentar listas de nullable types.
Há uma consequência importante disso se você define seus próprios list types que
estendem ListBase ou aplicam ListMixin. Ambos esses tipos fornecem uma
implementação de insert() que anteriormente fazia espaço para o elemento inserido
definindo o length. Isso falharia com null safety, então em vez disso mudamos
a implementação de insert() em ListMixin (que ListBase
compartilha) para
chamar add() em vez disso. Sua classe de lista personalizada deve fornecer uma definição de
add() se você quer ser capaz de usar esse método insert()
herdado.
Cannot access Iterator.current before or after iteration
#
A classe Iterator é a classe "cursor" mutável usada para percorrer os elementos
de um tipo que implementa Iterable. Você deve chamar moveNext()
antes de acessar quaisquer elementos para avançar para o primeiro elemento. Quando esse método
retorna false, você alcançou o fim e não há mais elementos.
Costumava ser que current retornava null se você o chamasse antes
de chamar moveNext() pela primeira vez ou depois que a iteração terminasse. Com null
safety, isso exigiria que o tipo de retorno de current fosse E?
e não E.
Isso por sua vez significa que todo acesso a elemento exigiria uma verificação de
null em tempo de execução.
Essas verificações seriam inúteis dado que quase ninguém nunca acessa o elemento
atual dessa maneira errônea. Em vez disso, tornamos o tipo de current
ser
E. Como pode não haver um valor desse tipo disponível antes ou depois de
iterar, deixamos o comportamento do iterator indefinido se você chamá-lo quando você
não deveria. A maioria das implementações de Iterator lança um StateError.
Summary
#Essa é uma tour muito detalhada através de todas as mudanças de linguagem e biblioteca em torno de null safety. É muita coisa, mas esta é uma mudança de linguagem bem grande. Mais importante, queríamos chegar a um ponto onde o Dart ainda parecesse coeso e utilizável. Isso requer mudar não apenas o sistema de tipos, mas um número de outros recursos de usabilidade em torno dele. Não queríamos que parecesse como se null safety tivesse sido aparafusado.
Os pontos principais a serem levados são:
Tipos são non-nullable por padrão e tornados nullable adicionando
?.-
Parâmetros opcionais devem ser nullable ou ter um valor padrão. Você pode usar
requiredpara tornar parâmetros nomeados não-opcionais. Variáveis de nível superior e campos estáticos non-nullable devem ter inicializadores. Campos de instância non-nullable devem ser inicializados antes do corpo do construtor começar. -
Cadeias de métodos após operadores null-aware fazem curto-circuito se o receptor é
null. Há novos operadores cascade null-aware (?..) e index (?[]). O operador postfix not-null assertion "bang" (!) faz cast de seu operando nullable para o non-nullable type subjacente. -
Análise de fluxo permite que você transforme com segurança variáveis locais e parâmetros nullable (e campos privados final, a partir do Dart 3.2) em não-nullable utilizáveis. A nova análise de fluxo também tem regras mais inteligentes para type promotion, retornos faltantes, código inalcançável e inicialização de variável.
-
O modificador
latepermite que você use non-nullable types efinalem lugares que você de outra forma não poderia, às custas de verificação em tempo de execução. Também te dá campos inicializados preguiçosamente. A classe
Listfoi mudada para prevenir elementos não inicializados.
Finalmente, uma vez que você absorva tudo isso e coloque seu código no mundo de null safety, você obtém um programa seguro (sound) que os compiladores podem otimizar e onde todo lugar onde um erro de tempo de execução pode ocorrer é visível em seu código. Esperamos que você sinta que vale a pena o esforço para chegar lá.
Salvo indicação em contrário, a documentação neste site reflete Dart 3.13.7. Página atualizada pela última vez em 2026-11-12. Ver código-fonte ou relatar um problema.
Encontrou essa página sem tradução ou que precisa de correção? Abra uma issue e nos ajude a manter esse site em PT-BR para ajudar pessoas como você.