Pular para o conteúdo principal

O sistema de tipos do Dart

Por que e como escrever código Dart sound.

A linguagem Dart é type safe: ela usa uma combinação de verificação estática de tipos e verificações em tempo de execução para garantir que o valor de uma variável sempre corresponda ao tipo estático da variável, algo às vezes chamado de tipagem sound. Embora os tipos sejam obrigatórios, as anotações de tipo são opcionais por causa da inferência de tipos.

Um dos benefícios da verificação estática de tipos é a capacidade de encontrar bugs em tempo de compilação usando o analisador estático do Dart.

Você pode corrigir a maioria dos erros de análise estática adicionando anotações de tipo a classes genéricas. As classes genéricas mais comuns são os tipos de coleção List<T> e Map<K,V>.

Por exemplo, no código a seguir a função printInts() imprime uma lista de inteiros, e main() cria uma lista e a passa para printInts().

análise estática: falha dart
void printInts(List<int> a) => print(a);
​
void main() {
  final list = [];
  list.add(1);
  list.add('2');
  printInts(list);
}

O código anterior resulta em um erro de tipo em list (destacado acima) na chamada de printInts(list):

error - The argument type 'List<dynamic>' can't be assigned to the parameter type 'List<int>'. - argument_type_not_assignable

O erro destaca uma conversão implícita não sound de List<dynamic> para List<int>. A variável list tem o tipo estático List<dynamic>. Isso acontece porque a declaração de inicialização var list = [] não fornece ao analisador informação suficiente para inferir um argumento de tipo mais específico que dynamic. A função printInts() espera um parâmetro do tipo List<int>, o que causa uma incompatibilidade de tipos.

Ao adicionar uma anotação de tipo (<int>) na criação da lista (destacada abaixo), o analisador reclama que um argumento string não pode ser atribuído a um parâmetro int. Remover as aspas em list.add('2') resulta em código que passa na análise estática e roda sem erros ou avisos.

análise estática: sucesso dart
void printInts(List<int> a) => print(a);
​
void main() {
  final list = <int>[];
  list.add(1);
  list.add(2);
  printInts(list);
}

Experimente no DartPad.

O que é soundness?

#

Soundness trata de garantir que seu programa não consiga chegar a certos estados inválidos. Um sistema de tipos sound significa que você nunca pode chegar a um estado em que uma expressão avalia para um valor que não corresponde ao tipo estático da expressão. Por exemplo, se o tipo estático de uma expressão for String, em tempo de execução você tem a garantia de obter apenas uma string quando avaliá-la.

O sistema de tipos do Dart, assim como os sistemas de tipos de Java e C#, é sound. Ele garante essa soundness usando uma combinação de verificação estática (erros em tempo de compilação) e verificações em tempo de execução. Por exemplo, atribuir uma String a um int é um erro em tempo de compilação. Converter um objeto para String usando as String falha com um erro em tempo de execução se o objeto não for uma String.

Os benefícios da soundness

#

Um sistema de tipos sound tem vários benefícios:

  • Revelar bugs relacionados a tipos em tempo de compilação.
    Um sistema de tipos sound força o código a ser inequívoco sobre seus tipos, então bugs relacionados a tipos que seriam difíceis de encontrar em tempo de execução são revelados em tempo de compilação.

  • Código mais legível.
    O código é mais fácil de ler porque você pode confiar que um valor realmente tem o tipo especificado. Em Dart sound, os tipos não mentem.

  • Código mais fácil de manter.
    Com um sistema de tipos sound, quando você muda um trecho de código, o sistema de tipos pode avisar sobre os outros trechos de código que acabaram de quebrar.

  • Melhor compilação antecipada (AOT).
    Embora a compilação AOT seja possível sem tipos, o código gerado é bem menos eficiente.

Dicas para passar na análise estática

#

A maioria das regras para tipos estáticos é fácil de entender. Aqui estão algumas das regras menos óbvias:

  • Use tipos de retorno sound ao sobrescrever métodos.
  • Use tipos de parâmetro sound ao sobrescrever métodos.
  • Não use uma lista dinâmica como se fosse uma lista tipada.

Vamos ver essas regras em detalhe, com exemplos que usam a seguinte hierarquia de tipos:

a hierarchy of animals where the supertype is Animal and the subtypes are Alligator, Cat, and HoneyBadger. Cat has the subtypes of Lion and MaineCoon

Use tipos de retorno sound ao sobrescrever métodos

#

O tipo de retorno de um método em uma subclasse deve ser o mesmo tipo ou um subtipo do tipo de retorno do método na superclasse. Considere o método getter na classe Animal:

dart
class Animal {
  void chase(Animal a) {
     ...
  }
  Animal get parent => ...
}

O método getter parent retorna um Animal. Na subclasse HoneyBadger, você pode substituir o tipo de retorno do getter por HoneyBadger (ou qualquer outro subtipo de Animal), mas um tipo não relacionado não é permitido.

análise estática: sucesso dart
class HoneyBadger extends Animal {
  @override
  void chase(Animal a) {
     ...
  }
​
  @override
  HoneyBadger get parent => ...
}
análise estática: falha dart
class HoneyBadger extends Animal {
  @override
  void chase(Animal a) {
     ...
  }
​
  @override
  Root get parent => ...
}

Use tipos de parâmetro sound ao sobrescrever métodos

#

O parâmetro de um método sobrescrito deve ter o mesmo tipo ou um supertipo do parâmetro correspondente na superclasse. Não "restrinja" o tipo do parâmetro substituindo-o por um subtipo do parâmetro original.

Considere o método chase(Animal) da classe Animal:

dart
class Animal {
  void chase(Animal a) {
     ...
  }
  Animal get parent => ...
}

O método chase() recebe um Animal. Um HoneyBadger persegue qualquer coisa. É aceitável sobrescrever o método chase() para receber qualquer coisa (Object).

análise estática: sucesso dart
class HoneyBadger extends Animal {
  @override
  void chase(Object a) {
     ...
  }
​
  @override
  Animal get parent => ...
}

O código a seguir restringe o parâmetro do método chase() de Animal para Mouse, uma subclasse de Animal.

análise estática: falha dart
class Mouse extends Animal {
   ...
}
​
class Cat extends Animal {
  @override
  void chase(Mouse a) {
     ...
  }
}

Esse código não é type safe, porque seria possível definir um gato e mandá-lo atrás de um crocodilo:

dart
Animal a = Cat();
a.chase(Alligator()); // Not type safe or feline safe.

Não use uma lista dinâmica como lista tipada

#

Uma lista dynamic é útil quando você quer uma lista com coisas de tipos diferentes. No entanto, você não pode usar uma lista dynamic como se fosse uma lista tipada.

Essa regra também se aplica a instâncias de tipos genéricos.

O código a seguir cria uma lista dynamic de Dog e a atribui a uma lista do tipo Cat, o que gera um erro durante a análise estática.

análise estática: falha dart
void main() {
  List<Cat> foo = <dynamic>[Dog()]; // Error
  List<dynamic> bar = <dynamic>[Dog(), Cat()]; // OK
}

Verificações em tempo de execução

#

Verificações em tempo de execução lidam com problemas de type safety que não podem ser detectados em tempo de compilação.

Por exemplo, o código a seguir lança uma exceção em tempo de execução porque é um erro converter uma lista de cachorros em uma lista de gatos:

runtime: falha dart
void main() {
  List<Animal> animals = <Dog>[Dog()];
  List<Cat> cats = animals as List<Cat>;
}

Downcasts implícitos a partir de dynamic

#

Expressões com tipo estático dynamic podem ser convertidas implicitamente para um tipo mais específico. Se o tipo real não corresponder, a conversão lança um erro em tempo de execução. Considere o método assumeString a seguir:

análise estática: sucesso dart
int assumeString(dynamic object) {
  String string = object; // Check at run time that `object` is a `String`.
  return string.length;
}

Neste exemplo, se object for uma String, a conversão funciona. Se não for um subtipo de String, como um int, um TypeError é lançado:

runtime: falha dart
final length = assumeString(1);

Inferência de tipos

#

O analisador consegue inferir tipos de campos, métodos, variáveis locais e a maioria dos argumentos de tipo genéricos. Quando o analisador não tem informação suficiente para inferir um tipo específico, ele usa o tipo dynamic.

Aqui está um exemplo de como a inferência de tipos funciona com genéricos. Neste exemplo, uma variável chamada arguments guarda um map que associa chaves string a valores de vários tipos.

Se você tipar a variável explicitamente, pode escrever isto:

dart
Map<String, Object?> arguments = {'argA': 'hello', 'argB': 42};

Alternativamente, você pode usar var ou final e deixar o Dart inferir o tipo:

dart
var arguments = {'argA': 'hello', 'argB': 42}; // Map<String, Object>

O literal de map infere seu tipo a partir das entradas, e então a variável infere seu tipo a partir do tipo do literal de map. Neste map, as chaves são ambas strings, mas os valores têm tipos diferentes (String e int, cujo limite superior é Object). Então o literal de map tem o tipo Map<String, Object>, e a variável arguments também.

Inferência de campos e métodos

#

Um campo ou método sem tipo especificado que sobrescreve um campo ou método da superclasse herda o tipo do método ou campo da superclasse.

Um campo que não tem tipo declarado nem herdado, mas que é declarado com um valor inicial, recebe um tipo inferido com base nesse valor inicial.

Inferência de campos estáticos

#

Campos e variáveis estáticos recebem seus tipos inferidos a partir do inicializador. Observe que a inferência falha se encontrar um ciclo (ou seja, inferir o tipo da variável depende de conhecer o tipo dessa própria variável).

Inferência de variáveis locais

#

Os tipos de variáveis locais são inferidos a partir do inicializador, se houver. Atribuições posteriores não são consideradas. Isso pode fazer com que um tipo preciso demais seja inferido. Se for o caso, você pode adicionar uma anotação de tipo.

análise estática: falha dart
var x = 3; // x is inferred as an int.
x = 4.0;
análise estática: sucesso dart
num y = 3; // A num can be double or int.
y = 4.0;

Inferência de argumentos de tipo

#

Os argumentos de tipo em chamadas de construtores e em invocações de métodos genéricos são inferidos com base na combinação de informação descendente, vinda do contexto de ocorrência, e informação ascendente, vinda dos argumentos do construtor ou do método genérico. Se a inferência não estiver fazendo o que você quer ou espera, você sempre pode especificar explicitamente os argumentos de tipo.

análise estática: sucesso dart
// Inferred as if you wrote <int>[].
List<int> listOfInt = [];
​
// Inferred as if you wrote <double>[3.0].
var listOfDouble = [3.0];
​
// Inferred as Iterable<int>.
var ints = listOfDouble.map((x) => x.toInt());

No último exemplo, x é inferido como double usando informação descendente. O tipo de retorno da closure é inferido como int usando informação ascendente. O Dart usa esse tipo de retorno como informação ascendente ao inferir o argumento de tipo do método map(): <int>.

Inferência usando limites

#

Com o recurso de inferência usando limites, o algoritmo de inferência de tipos do Dart gera restrições combinando as restrições existentes com os limites de tipo declarados, não apenas aproximações do tipo "melhor esforço".

Isso é especialmente importante para tipos F-bounded, em que a inferência usando limites infere corretamente que, no exemplo abaixo, X pode ser vinculado a B. Sem o recurso, o argumento de tipo precisa ser especificado explicitamente: f<B>(C()):

dart
class A<X extends A<X>> {}
​
class B extends A<B> {}
​
class C extends B {}
​
void f<X extends A<X>>(X x) {}
​
void main() {
  f(B()); // OK.
​
  // OK. Without using bounds, inference relying on best-effort approximations
  // would fail after detecting that `C` is not a subtype of `A<C>`.
  f(C());
​
  f<B>(C()); // OK.
}

Aqui está um exemplo mais realista, usando tipos do dia a dia no Dart como int ou num:

dart
X max<X extends Comparable<X>>(X x1, X x2) => x1.compareTo(x2) > 0 ? x1 : x2;
​
void main() {
  // Inferred as `max<num>(3, 7)` with the feature, fails without it.
  max(3, 7);
}

Com a inferência usando limites, o Dart consegue desconstruir argumentos de tipo, extraindo informação de tipo do limite de um parâmetro de tipo genérico. Isso permite que funções como f no exemplo a seguir preservem tanto o tipo específico do iterável (List ou Set) quanto o tipo dos elementos. Antes da inferência usando limites, isso não era possível sem perder type safety ou a informação de tipo específica.

dart
(X, Y) f<X extends Iterable<Y>, Y>(X x) => (x, x.first);
​
void main() {
  var (myList, myInt) = f([1]);
  myInt.whatever; // Compile-time error, `myInt` has type `int`.
​
  var (mySet, myString) = f({'Hello!'});
  mySet.union({}); // Works, `mySet` has type `Set<String>`.
}

Sem a inferência usando limites, myInt teria o tipo dynamic. O algoritmo anterior não detectaria a expressão incorreta myInt.whatever em tempo de compilação, e lançaria um erro em tempo de execução. Por outro lado, mySet.union({}) seria um erro em tempo de compilação sem a inferência usando limites, porque o algoritmo anterior não conseguia preservar a informação de que mySet é um Set.

Para mais informações sobre o algoritmo de inferência usando limites, leia o documento de design.

Substituindo tipos

#

Ao sobrescrever um método, você está substituindo algo de um tipo (no método antigo) por algo que pode ter um novo tipo (no novo método). De forma semelhante, ao passar um argumento para uma função, você está substituindo algo que tem um tipo (um parâmetro com tipo declarado) por algo que tem outro tipo (o argumento real). Quando você pode substituir algo que tem um tipo por algo que tem um subtipo ou um supertipo?

Ao substituir tipos, ajuda pensar em termos de consumidores e produtores. Um consumidor absorve um tipo, e um produtor gera um tipo.

Você pode substituir o tipo de um consumidor por um supertipo, e o tipo de um produtor por um subtipo.

Vejamos exemplos de atribuição de tipo simples e de atribuição com tipos genéricos.

Atribuição de tipo simples

#

Ao atribuir objetos a objetos, quando você pode substituir um tipo por um tipo diferente? A resposta depende de o objeto ser um consumidor ou um produtor.

Considere a seguinte hierarquia de tipos:

a hierarchy of animals where the supertype is Animal and the subtypes are Alligator, Cat, and HoneyBadger. Cat has the subtypes of Lion and MaineCoon

Considere a seguinte atribuição simples, em que Cat c é um consumidor e Cat() é um produtor:

dart
Cat c = Cat();

Em uma posição de consumo, é seguro substituir algo que consome um tipo específico (Cat) por algo que consome qualquer coisa (Animal), então substituir Cat c por Animal c é permitido, porque Animal é um supertipo de Cat.

análise estática: sucesso dart
Animal c = Cat();

Mas substituir Cat c por MaineCoon c quebra a type safety, porque a superclasse pode fornecer um tipo Cat com comportamentos diferentes, como Lion:

análise estática: falha dart
MaineCoon c = Cat();

Em uma posição de produção, é seguro substituir algo que produz um tipo (Cat) por um tipo mais específico (MaineCoon). Portanto, o seguinte é permitido:

análise estática: sucesso dart
Cat c = MaineCoon();

Atribuição de tipo genérico

#

As regras são as mesmas para tipos genéricos? Sim. Considere a hierarquia de listas de animais: uma List de Cat é um subtipo de uma List de Animal, e um supertipo de uma List de MaineCoon:

List<Animal> -> List<Cat> -> List<MaineCoon>

No exemplo a seguir, você pode atribuir uma lista de MaineCoon a myCats, porque List<MaineCoon> é um subtipo de List<Cat>:

análise estática: sucesso dart
List<MaineCoon> myMaineCoons = ...
List<Cat> myCats = myMaineCoons;

E na direção contrária? Você pode atribuir uma lista de Animal a uma List<Cat>?

análise estática: falha dart
List<Animal> myAnimals = ...
List<Cat> myCats = myAnimals;

Essa atribuição não passa na análise estática, porque cria uma conversão implícita para baixo (downcast), que é proibida a partir de tipos não-dynamic, como Animal.

Para que esse tipo de código passe na análise estática, você pode usar um cast explícito.

dart
List<Animal> myAnimals = ...
List<Cat> myCats = myAnimals as List<Cat>;

Um cast explícito ainda pode falhar em tempo de execução, dependendo do tipo real da lista que está sendo convertida (myAnimals).

Métodos

#

Ao sobrescrever um método, as regras de produtor e consumidor continuam valendo. Por exemplo:

Animal class showing the chase method as the consumer and the parent getter as the producer

Para um consumidor (como o método chase(Animal)), você pode substituir o tipo do parâmetro por um supertipo. Para um produtor (como o método getter parent), você pode substituir o tipo de retorno por um subtipo.

Para mais informações, veja Use tipos de retorno sound ao sobrescrever métodos e Use tipos de parâmetro sound ao sobrescrever métodos.

Parâmetros covariant

#

Alguns padrões de código (raramente usados) dependem de restringir um tipo sobrescrevendo o tipo de um parâmetro com um subtipo, o que é inválido. Nesse caso, você pode usar a keyword covariant para informar ao analisador que você está fazendo isso intencionalmente. Isso remove o erro estático e, em vez disso, verifica um tipo de argumento inválido em tempo de execução.

A seguir, um exemplo de como você pode usar covariant:

análise estática: sucesso dart
class Animal {
  void chase(Animal x) {
     ...
  }
}
​
class Mouse extends Animal {
   ...
}
​
class Cat extends Animal {
  @override
  void chase(covariant Mouse x) {
     ...
  }
}

Embora este exemplo mostre o uso de covariant na subclasse, a keyword covariant pode ser colocada tanto no método da superclasse quanto no da subclasse. Normalmente, o método da superclasse é o melhor lugar para ela. A keyword covariant se aplica a um único parâmetro e também é suportada em setters e campos.

Outros recursos

#

Os recursos a seguir têm mais informações sobre Dart sound:

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