Introdução

Veja a playlist no Youtube com todos os vídeos do curso.

1 Tipos?! Pra quê?

Nós, como seres humanos, naturalmente gostamos de classificar as mais diversas coisas. Objetos pesados ou leves, pinturas bonitas ou feias, comidas picantes, leves, saborosas, cheirosas, … De fato, usamos adjetivos como uma maneira de classificar algo atribuindo-lhe características gerais que são comuns a uma série de coisas.

Tipos nada mais são do que esta mesma ideia empregada na programação de computadores. Valores do tipo inteiro podem ser somados, multiplicados ou divididos, por exemplo. Por outro lado podemos pensar em textos (strings) como sendo concatenáveis, imprimíveis, etc. O tipo de um valor define o conjunto de operações que podem ser feitas sobre ele. Por exemplo, é razoável calcular o quadrado de um número (inteiro ou fracionário) mas não faz sentido algum tentarmos fazer o mesmo com uma string.

Assim, de maneira mais pragmática, no contexto de programação, os tipos podem ser vistos como uma maneira de restringir os valores recebidos, usados e devolvidos por funções1. Uma função que devolve valores do tipo inteiro não poderá devolver como resposta um valor que seja uma número fracionário ou uma string. A função que calcula a raiz quadrada funciona sobre valores de tipos numéricos (ou mais informalmente sobre tipos numéricos), mas não sobre strings.

E é aqui que uma das diferenças mais significativas entre as linguagens de programação começa a aparecer. Algumas linguagens de programação com tipagem estática , avaliam se as operações efetuadas nos valores do programa estão condizentes com os seus tipos durante a compilação. Por outro lado, linguagens de programação com tipagem dinâmica , avaliam apenas em tempo de execução se os valores são apropriados para uma determinada operação.

Perceba que evitamos deliberadamente o uso de expressões como tipagem forte e tipagem fraca. Como veremos adiante, não é razoável separar as linguagens de programação em duas categorias opostas. Na verdade existe todo um espectro de variações que, em alguns casos, fazem as linguagens terem características mistas.

1.1 Ok, mas para que tudo isso?

Objetivo : eliminar tantos erros de execução quanto possíveis durante a compilação. Santo Graal : provar durante a compilação que todos os erros em tempo de execução foram eliminados.

A maior parte dos programadores já têm uma ideia bem clara de como fazer isso. Mas vamos dar uma olhada!

2 Níveis de segurança oferecidos por algumas linguagens

(uma classificação nada científica e totalmente arbitrária)

Em ordem de segurança crescente:

  • Level -1 - Assembly
  • Level 0 - Python, JavaScript, Erlang/Elixir, Ruby, Smalltalk, Lisps (incluindo Clojure), …
  • Level 1 - C, C++*, TypeScript
  • Level 2 - Java, C#, C++, TypeScript
  • Level 3 - Haskell, Rust
  • Level 4 - Agda, Idris, Coq

Este texto não tem como objetivo começar uma outra flame war sobre:
\(\cdot\) Tipagem dinâmica vs. tipagem estática
\(\cdot\) Estilo funcional vs estilo imperativo
\(\cdot\) Linguagens compiladas vs. linguagens interpretadas
\(\cdot\) Emacs vs. Vim (obviamente Emacs é melhor)
(…quando você possui 20 dedos na mão esquerda)
\(\cdot\) [insira a sua flame war favorita aqui]

A ideia aqui é apresentar como as características de tipagem de cada uma das linguagens podem ser usadas para nos aproximarmos cada vez mais do nosso objetivo final, i.e., eliminar os erros em tempo de execução.

Note que na classificação acima, algumas linguagens aparecem em dois níveis diferentes. Isso é deliberado. Linguagens como C++ se mal utilizadas podem degenerar para C assim como TypeScript pode se degenerar para JavaScript. Aqui estamos assumindo programadores que usam as ferramentas da maneira que elas foram criadas para serem usadas. (Sim, mantemos a nossa fé na humanidade!).

Vamos avaliar para cada um dos níveis definidos há pouco, como os tipos podem nos ajudar.

2.1 Nível -1

# MIPS Assembly
.data
  str: .asciiz "Hello World!"
.text
  la $t0, str
  mtc1 $t0, $f0
  sqrt.s $f12, $f0
  li $v0, 2 # print float syscall
  syscall

O código acima compila (na verdade talvez devesse dizer monta) e executa sem erros!

Vamos avaliar o que ele faz.

A seção .data define uma string str terminada em \0.

A seção .text contem o executável do programa. Ela carrega o endereço (la) de str para $t0, carrega o endereço para o registrador $f0 do coprocessador matemático (mtc1) calcula a raiz quadrada em precisão simples do valor em $f0 e armazena o resultado em $f12, e imprime o resultado.

Apesar de rodar sem qualquer problema, o valor impresso no final é sem sentido: ele é a raiz quadrada de um endereço de memória. Mais ainda, a depender do ambiente onde o programa for executado, pode ser que a saída nem seja determinística, ou seja, ela pode variar entre execuções de maneira imprevisível.

Por que isso acontece?

Tudo isso ocorreu pois pudemos carregar um endereço no coprocessador matemático como se fosse um valor numérico fracionário de precisão simples. Para o processador, que é o nível que estamos trabalhando quando escrevemos código assembly, tudo não passa de uma sequencias de bits. A interpretação do que eles significa é dada pelo conjunto de operações. Se elas não são coerentes, pouco importa. Para o processador não passam de bits e mais bits.

Ok, será que podemos fazer melhor?

2.2 Nível 0

# Python
import math

str = "Hello World!"
print(math.sqrt(str))

Muito melhor! Agora sim, ele tenta executar mas na hora ele emite um erro em tempo de execução (runtime error):

TypeError: must be real number, not str

A função math.sqrt verifica antes de efetuar o cálculo da raiz se o valor sobre o qual a operação será feita está dentro do esperado. Caso não esteja, ela emite um erro em tempo de execução.

Programadores em linguagens com tipagens dinâmicas frequentemente precisam fazer estes tipos de verificação repetidamente. Mais ainda, para garantir que tudo funciona mesmo na presença de valores indesejados, é preciso que escrevam (espero!) testes automatizados não apenas para verificar o comportamento do código para entradas razoáveis assim como para entradas descabidas como aquelas do exemplo acima.

Não seria muito melhor se o compilador nos avisasse antes que o programa fosse executado para evitarmos ter que escrever validações para cada uma das possibilidades de valores inapropriados para nossa função?

2.3 Nível 1

Considere o seguinte código em C:

//C
#include <stdio.h>
#include <math.h>
void main() {
  char str[] = "Hello World!";
  printf("%f\n", sqrt(str));
}

Se você tentar compilar o código acima vai receber um erro! Opa! Finalmente, um erro em tempo de compilação (compile time error):

main.c:7:23: error: incompatible type for argument 1 of ‘sqrt’
  7 |     printf("%f\n", sqrt(str));

Agora não precisamos mais testar se o valor recebido é do tipo correto ou não. O compilador faz esse trabalho para nós. Mais ainda, uma série de testes (automatizados ou não) já nem fazem mais sentido de serem feitos! O compilador assumiu para ele o trabalho de verificação.

Vamos olhar com um pouco mais de cuidado como isso foi feito. Note que str é do tipo char [], ou seja, um vetor de caracteres. No momento em que o compilador tenta passar este valor para a função sqrt ele vai confrontar o tipo do parâmetro de sqrt com o tipo de srt. Vamos ver a declaração de sqrt:

double sqrt(double x);

Como double não bate com char [] o compilador grita! Legal!

Mas, o mundo não é só de flores. Vamos mudar o exemplo acima só um pouquinho:

#include <stdio.h>
void main() {
    printf(">>%d\n", 42 / 0);
}

O código ainda compila. Não estamos mandando nenhum valor de tipos incompatíveis para nenhuma função ou operador. Contudo, quando executamos o código:

Floating point exception (core dumped)

Putz! Não só voltamos ao mundo dos erros em tempo de execução como também vamos precisar, para evitar que tais erros capotem a nossa aplicação, checar pelos valores em tempo de execução!

2.4 Nível 2

A linguagem Java nos dá uma outra abordagem para lidar com exceções (!) para o caso comum:

// Java
public class Main {
    public static void main (String[] args) {
	try {
	    System.out.println(42 / 0);
	} catch (ArithmeticException e) {
	    // Deal with the error
	}
    }
}

Legal! Agora se executarmos, quando ocorrer um erro o bloco de tratamento de exceção vai ser executado e não precisamos nos preocupar em verificar os valores antes de efetuar as operações. Certo?

Errado! Aqui usamos o operador / que poderia ser muito bem um método div. Dentro da implementação do método ainda vamos precisar verificar se os valores estão dentro do esperado. Mais ainda, o compilador não vai nos avisar caso não escrevamos o bloco de tratamento para ArithmeticException, já que é uma exceção de tempo de execução ou uma exceção não checada (unchecked exception).

O problema com exceções não checadas é que é fácil esquecer delas. Mais ainda, se você tentar transformá-las em checadas (checked exception) o seu programa vai ficar rapidamente muito complicado e difícil de ler.

Nesse exemplo, o motivo de nosso problema é que nossas implementações não são totais. Uma função é total quando é definida para todos os valores de entrada possíveis segundo os tipos especificados. Uma função é parcial quando há pelo menos um valor que pertence ao tipo de entrada para o qual a função não tem um valor definido.

Vamos então tentar novamente. Vamos transformar a nossa função em total:

// Java
public static Integer safeDiv (int x, int  y) {
    if (y == 0) return null;
    return x / y;
}

public static void main (String[] args) {
    System.out.println(safeDiv(42, 0) + 5);
}

Parece que melhoramos um pouco! agora não é mais preciso tratar as exceções, mas ainda assim é preciso que do lado do chamador seja verificada a presença de null como resposta, sob pena de tomar um NPE. Ou seja, jogamos a sujeira para debaixo do tapete. Agora o erro não ocorre mais na nossa função, mas pode explodir em qualquer outro lugar…

A classe Optional (Maybe)

Precisamos de uma maneira de elevar o conceito da presença ou a ausência de um resultado para o nível de tipos. Assim, o compilador vai ser capaz (já que os valores sobre os quais as operações vão trabalhar só serão, normalmente, conhecidos em tempo de execução) de nos auxiliar a verificar se o tratamento está sendo feito corretamente.

Na nossa tentativas anterior com null, criamos um valor em tempo de execução que representa uma falha ou a ausência do resultado. O problema é que null é um cara esquisito®, pois é um valor válido que pode ser passado pelas funções a vontade. Mas ele só revela ser uma falha se você tenta usá-lo ou quando verifica explicitamente!

Estávamos no caminho certo, mas faltava algo. Faltava incluir no tipo que um determinado valor poderia estar ausente ou ter ocorrido uma falha no seu cálculo. Assim, todas as funções que recebessem tal valor seriam obrigadas a lidar com esse fato e não varrer pra debaixo do tapete!

Felizmente há uma alternativa, o tipo Optional e que em algumas linguagens, como Haskell, é chamado de Maybe. Neste tipo há duas possibilidades, uma ausência de valor (seja por erro ou qualquer outro motivo) ou um valor encapsulado. Para usar o valor você tem que, obrigatoriamente, passar por esse encapsulamento.

Vejamos um exemplo em Java:

//Java
public static Optional<Integer> safeDiv (int x, int  y) {
    if (y == 0) return Optional.empty();
    return Optional.of(x / y);
}

public static String getResult() {
  Optional<Integer> x = safeDiv(42, 0);
  Optional<Integer> y = safeDiv(10, 2);
  Optional<Integer> z = x.flatMap(x2 ->
			y.flatMap(y2 ->
			safeDiv(x2, y2)));
  return z.map(z3 -> z3.toString()).orElse("");
}

public static void main (String[] args) {
  System.out.println(getResult());
}

Aqui definimos um método safeDiv que recebe dois valores inteiros puros . Se y for 0, o método devolve Optional.empty(), ou seja, a indicação de ausência de um valor. Caso contrário, devolve Optional.of(x / y), ou seja, o valor encapsulado (não puro). Qualquer um que quiser usar este valor agora precisará lidar com o fato de que o valor não é diretamente acessível.

A sintaxe de Java não tendo sido pensada para este tipo de manipulação acaba ficando um tanto verbosa. Não fica imediatamente claro se isso é melhor do que tínhamos antes.

E se você é daqueles que curtem um C++, o mesmo código fica assim:

//C++
std::optional<int> safeDiv (int x, int y) {
    if (y == 0) return std::nullopt;
    return x / y;
}

std::string getResult() {
    auto x = safeDiv(42, 0);
    auto y = safeDiv(10, 2);
    auto w = x.and_then([y](int x2) {
	return y.and_then([x2](auto y2) {
	    return safeDiv(x2, y2);
	});
    });
    return w.transform([](auto v) {return std::to_string(v);}).value_or("");
}

int main() {
    std::cout << getResult() << '\n';
}

Basicamente a mesma coisa que em Java, mas um pouquinho mais feio. :) Notavelmente, as seguintes funções assumem os nomes pouco convencionais e estão (estarão) disponíveis a partir do C++23:

  • flatMap (Java) \(\to\) and_then (C++)
  • map (Java) \(\to\) transform (C++)
  • orElse (Java) \(\to\) value_or (C++)

Infelizmente, neste caso essas operações são específicas do optional e não são parte de uma interface monádica mais geral.

2.5 Nível 3

Se em Java ou C++ o código acima é um pouco verboso, veja o equivalente em Haskell:

safeDiv :: Int -> Int -> Maybe Int
safeDiv x 0 = Nothing
safeDiv x y = Just $ x `div` y

getResult :: String
getResult =
  maybe "" show $ do
    x <- safeDiv 42 0
    y <- safeDiv 10 2
    safeDiv x y

main :: IO ()
main = putStrLn getResult

Ou melhor ainda, uma versão mais próxima do que seria escrito por um programador experiente:

safeDiv :: Int -> Int -> Maybe Int
safeDiv x 0 = Nothing
safeDiv x y = Just $ x `div` y

getResult = maybe "" show (safeDiv <$> safeDiv 42 0 <*> safeDiv 10 2)

main :: IO ()
main = putStrLn getResult

Note que mesmo na primeira versão em Haskell que é mais longa, o uso da do notation tira a necessidade da definição das expressões lambda (como é o caso de Java) na mão. A primeira versão usa o fato de que Maybe é uma Mônada e na segunda versão usamos o fato de que Maybe também é um Functor Aplicativo.

2.6 Nível 4

Algumas linguagens como Agda levam essa brincadeira mais além. Linguagens como Agda e Idris tem o que é chamado de tipos dependentes (dependent types).

Isso significa que os tipos podem depender de valores computados pelo programa durante a sua execução!

Como que tal coisa pode funcionar? Será que isso faz sentido?

A ideia é que o compilador é capaz de provar fatos sobre os valores analisando as operações que foram feitas sobre eles além da estrutura do programa.

O suporte para tipos dependentes de verdade é raro, e está presente em apenas algumas linguagens. Contudo há algumas abordagens muito próximas (como por exemplo tipos líquidos e tipos de refinamento - liquid types, refinement types) que são mais comuns.

O exemplo abaixo é escrito em LiquidHaskell:

{-@ bar :: {n: Int | n > 10} -> String @-}
bar :: Int -> String
bar n = show n ++ " is a large number!" -- Here n is guaranteed > 10

foo :: Int -> String
foo x = if x > 15
	then bar x -- here we know for a fact that x > 15 and thus > 10
	else "Too small!"

Repare na anotação em cima da função bar. Ela especifica que o parâmetro da função deve ser um inteiro e que também deve ser maior do que 10. Ou seja, não há a necessidade de verificar dentro de bar se n é ou não maior que 10. O compilador garante que a função nunca será chamada caso não seja.

Mas como o compilador pode saber de tal coisa? Até onde ele sabe o n pode ter sido digitado pelo usuário, pode ser qualquer coisa! Contudo, analisando o código da função foo que chama a função bar, fica claro que isso é feito apenas no branch do if que verifica a condição x > 15. Ou seja, neste branch x > 15 e portanto x > 10 e assim a chamada bar x é segura, i.e., satisfaz a sua especificação.

Como configurar e rodar o LiquidHaskell pode ser um caminho um tanto tortuoso, caso queira brincar com o código acima, use o repositório https://github.com/francesquini/liquidhaskell-example
Basta fazer stack build (e stack run) que ele baixa todas as dependências necessárias para a compilação.

3 Sobre esta série de posts

Nesta série de posts sobre Programação Orientada a Tipos nós vamos explorar em muito mais detalhes exemplos e aplicações de técnicas similares e muito mais elaboradas às descritas acima. Ao fim da leitura destes posts você deve ser capaz de criar aplicações que, se não forem 100% type safe, pelo menos serão capazes de evitar vários erros em tempo de execução.

Se apenas um dos posts te salvar de você ter que trabalhar na véspera de natal para atender um chamado por um pau que deu em produção, já vai ter valido a pena! :-D

4 Disclaimer

Estes slides foram preparados para os cursos de Paradigmas de Programação e Desenvolvimento Orientado a Tipos na UFABC.

Este material pode ser usado livremente desde que sejam mantidos, além deste aviso, os créditos aos autores e instituições.


  1. Você vai reparar que a partir de um certo momento nestes posts usaremos apenas a palavra função. Esse uso é deliberado e está vinculado ao termo para o animal matemático que dado alguns valores como parâmetros devolve um outro valor e não à diferenciação entre métodos e funções que por vezes é feita em algumas linguagens de programação como, por exemplo, Python ↩︎