Exception é um vilão?

Exception é um vilão? Tudo bem, não vi ninguém falar que era, mas vi muitos influenciadores, dizendo para parar de utilizar Exceptions, em detrimento de monads (acho válido, mas não em todos os cenários).

Meu intuito aqui não é explicar a melhor forma de lidar com exceptions. Também não quero tentar diferenciar erros de exceções. Quero apenas percorrer pelo assunto sobre de exceptions para identificarmos melhor método! Ué, tudo em tech não é depende? Polêmico não é mesmo!? Esse aqui é só um depende oculto 😎, já vai conseguir identificar.

Esse texto faz parte de um post resumido no meu linkedin, se quiser se conectar Ou& curtir lá, fique avontade: André Luz Link do post: O Exception nosso de cada dia

Se não quiser ler tudo do início, mas quiser uma respota direta e depois se quiser voltar para entender como cheguei a essa resposta, então pode ir direto pra cá Qual melhor método

O que é uma Exception?

Primeiramente é bom darmos uma definição de exceptions. Até para quem não conhece ou ta começando agora.

Uma definição "formal" para exceptions seria:

Exceptions: são condiçōes anormais, eventos fora do fluxo padrão/(fluxo feliz 😊).

Certo, mas e na prática o que é?

É uma estrutura de dados para lidar com erros no fluxo do seu código.

Eu amo exemplos, então vamos ver um exemplo abaixo Ex:

    var on = "Exception".Substring(7, 3);
    Console.WriteLine(on);
Unhandled exception. System.ArgumentOutOfRangeException: Index and length must refer to a location within the string. (Parameter 'length')
   at System.String.ThrowSubstringArgumentOutOfRange(Int32 startIndex, Int32 length)
   at System.String.Substring(Int32 startIndex, Int32 length)

Vê que ele estoura o app instantaneamente, isso porque você não lidou com a exceção.

O que fazemos para lidar com exceção? O básico né...

try .. catch .. finaly

Isso é suficiente. Vou por um código bem besta abaixo, apenas para quem nunca lidou com isso antes...

    try{
        var on = "Exception".Substring(7, 3);
        Console.WriteLine(on);
    } catch (Exception ex) {
        Console.WriteLine($"Nesse caso não é exceção é burrice mesmo! | \n {ex}");
    }

O que é um monad / optional / nullable / error

Vamos com calma. Coloquei vários termos aqui, e não, eu não estou resumindo todo como a mesma coisa. Embora que de modo usual todos tentam resolver a mesma coisa, cada um tem sua particularidade.

Vamos para o mais simples: monads.

Monads

É básicamente um objeto (qualquer que seja, até tipo primitivo) encapsulado em um objeto.

Vamos construir um monad agora em javascript, para exemplo:

class Monad {
  constructor(value) {
    this.value = value;
  }
  map(fn) {
    return this.value !== null ? new Monad(fn(this.value)) : new Monad(null);
  }
}

const monadValue = new Monad(Math.random() > 0.5 ? 'Hello' : null);
const result = monadValue.map((val) => val.toUpperCase()).value || 'Valor ausente';
console.log(result);

Levar em consideração que é um cenário irreal.

Resultado do script

No caso da imagem acima, o resultado foi menor ou igual a 0.5.

Certo, o que isso quer dizer? Não peguei a ideia até agora 🤔.

Ao invés de retornarmos a string Hello ou null (ou qualquer outra coisa em outro cenário), retornamos um wrapper do dado real que queremos, e dessa forma conseguimos lidar com possíveis falhas.

E o que ganhamos com isso? Não precisamo lidar com exceção, porque ou é o meu dado ou não é.

Nullable/Optional

Nullable é basicamente a mesma coisa do que foi explicado acima. Tem um objeto que encapsula o seu dado real.

Eu vou utilizar o C#. A explicação formal para ele isso é:

Enquanto Nullable<T> é uma maneira de permitir que tipos de valor (int, bool, etc..) sejam null, Optional<T> é uma abordagem mais recente e expressiva para lidar com valores que podem estar presentes ou ausentes, oferecendo uma forma melhor para lidar com esses casos.

Por isso vou utilizar o Nullable. Optional tem alguns métodos para lidarmos de modo mais direto com problemas (coisas que raramente vamos utilizar no dia-a-dia).

using System;
class Program
{
    static void Main(string[] args)
    {
        int? nullableValue = 42;
        Console.WriteLine($"Nullable com valor: {nullableValue.HasValue}");

        if (nullableValue.HasValue)
          Console.WriteLine($"Valor do Nullable: {nullableValue.Value}");

        int? nullableEmpty = null;
        Console.WriteLine($"Nullable sem valor: {nullableEmpty.HasValue}");
			
        int defaultValue = 0;
        int valueOrDefault = nullableEmpty ?? defaultValue;
        Console.WriteLine($"Valor padrão: {valueOrDefault}");
    }
}

▶️ Exemplo C# - Nullabe

Nullable com valor: True
Valor do Nullable: 42
Nullable sem valor: False
Valor padrao: 0

Veja que antes de acessar o valor, podemos verificar se existe valor com a propriedade .HasValue, isso nos dá a flexibilidade com NullPointerException.

E mais uma vez não foi preciso lidar com exceção.

Error

No C# em especifico não é algo suportado nativamente, temos essa ótima biblioteca para lidar com isso: ErrorOr

Mas em Go/Rust temos isso suportado e estimulado à utilização.

Em Go seria um error Em Rust seria um Result<T, E>

Vou dá um exemplo em Go, porque conheço praticamente nada de Rust, então tenho pouca propriedade.

package main

import (
	"fmt"
	"errors"
)

func divide(a, b float64) (float64, error) {
	if b == 0 {
		return 0, errors.New("divisão por zero não é permitida")
	}
	return a / b, nil
}
func main() {
	result, err := divide(10, 0)
	if err != nil {
		fmt.Println("Erro:", err)
		return
	}
	fmt.Println("Resultado:", result)
}

Veja que go retorna dois valores para desconstrução, um float64 e um error.

Veja que posteriormente podemos verificar se o retorno de error é diferente de nil (nulo), caso não seja igual a nulo, significa que houve um erro.

Então vamos tratar disso, e não precisou lidar com exceções.

No caso de Go e Rust não possuem exceções.

Mas em C#, utilizando a biblioteca ErrorOr, seria algo assim:

public ErrorOr<float> Divide(int a, int b)
{
    if (b == 0)
    {
        return Error.Unexpected(description: "Cannot divide by zero");
    }

    return a / b;
}

var result = Divide(4, 2);

if (result.IsError)
{
    Console.WriteLine(result.FirstError.Description);
    return;
}

Console.WriteLine(result.Value * 2); // 4

▶️ Exemplo C# - ErrorOr

Veja que é fundamentalmente a mesma coisa.

Qual melhor método

Ceto, e qual a respota para isso? Depende 🤣🤣

Deixando a brincadeira de lado e sendo mais rígido, é o seguite:

Você pode utilizar o que você se sentir melhor ❤️, fato!

E quando se está em uma equipe, ou fazendo algo com uma comunidade. O melhor sempre é trabalhar em conjunto com a comunidade, e o que isso quer dizer?

As especificações do C#, Java, Js, Ts, Go, Rust, V, Nim, Zig etc.. é mais importante! Estamos sempre trabalhando com padrões. Como isso é verdade, então as linguagens também fazem isso, não é pra dizer que é a diferentona, mas para manter consistência em toda base de código que encontrar pela internet.

Se você estava travalhando com C# em 2018, utilize Exceptions para lidar até mesmo com o mais básico, ex: Saber se o retorno de GetFirstUser() Retorna um User/uma referência nula.

Mas de 2019 para cá, com C#, você já tem Nullable<T>, então utilize disso.

Você tem suporte completo do C#, eles não só lançara uma classe que encapsula o resultado, e dane-se... Foi dado operadores novos para lidar com isso de forma mais fácil e intuitiva.

Eu sou time C#, stack que mais utilizo, ela que compra as fraldas 🚼 do meu filho.

Embora o hype enorme com Rust (Eu sei e concordo que é uma ótima linguagem), eu prefiro Go para lidar com erros, rust acaba tornando complicado algo bonito e simples que Go fez para lidar com erros.

E seria altamente esquisito e fora de contexto criar uma biblioteca para ter exceções em Go ou Rust.

E por quê? Porque a linguagem já nasceu lidando com erros, quase como um paradigma de programação.

Bem pessoal, o que gostaria de explicitar o que expliquei acima é. Lide com erros na sua linguagem que está utilizando de modo apropriado ao que já é suportado, para não precisar criar wrappers de exceções.

Eu particularmente sou do tipo que acredita que primeiro o sistema deve se consertar em caso de erro (famoso early return) mas existem casos que não estamos preparados para receber, então usar um exception é importante. Tanto para podermos debugar o código quanto para evitar a quebra de código em produção.

E ressaltando claro que mesmo a comunidade Ruby que possui o famoso TDD (Test Driven Development) ou Desenvolvimento movido (orientado) a testes.

Possui um sistema de exceções

Realmente tem uns casos, ex: IO. Você pode verificar antes escrever em um arquivo, para saber se ele não está em uso. Ai se não estiver você vai e `Write(fh, ....)`, certo? errado, pode ser que no meio tempo de `if (possoEscrever())`, para o `Write(fh, ....)`, alguém escreveu na frente e pronto aplicação quebra, um exception resolve isso. Mas e se eu tivesse em C? C retornar um código de erro informando que não foi possível, não vai simplesmente matar a plicação por completo, como seria o caso de C# ou outras. Em C serial algo como `if (Write(fh, ...)) `, go vai lidar com isso de forma similar. Então, vemos que não tem problema nisso, precisamos lidar de modo adequado para cada linguagem. Com essa biblioteca ErrorOr, que apontei acima, ela resolve problemas em código interno, mas vamos precisar lidar com exceções ainda, prque vamos precisar criar um wrapper de um retorno de IO, e depois converter em um retorno ErrorOr. Acaba que é meio paia.

Bom, primeiramente:

A monad is just a monoid in the category of endofunctors.

Brincadeiras a parte. Acredito que seu artigo é razoável no que tange ao que expressa, mas não responde nem a pergunta: 'exception é um vilão?' nem as objeções mais comuns contra exceptions (como o fato delas serem significativamente mais lentas que erros como valores, o fato de que elas introduzem certa imprevisibilidade na control-flow do código, etc...).

Dito isso, vou deixar meus 2¢ aqui sobre o assunto.

Utilize o padrão da linguagem

Razoável, completamente aceitável e uma boa dica. No entanto, como tudo, pra isso existem exceções claras. A mais óbvia delas é que nenhuma linguagem está de facto pronta. Mesmo C# depois de 20 anos continua a evoluir constantemente, e um fatores que faz com que linguagens mudem e se adaptem e por vezes se tornem melhores é justamente não seguir os padrões predeterminados da linguagem. Usarei C# como um exemplo (já que é minha linguagem de escolha geral, mas isso pode ser aplicado a qualquer uma, de Kotlin a Rust ou mesmo Go). C# se adaptou fortemente nos últimos anos a vários recursos presentes em linguagens de paradigma funcional, como pattern matching, suporte a sintaxes mais declarativas e afins, ele fez isso pois a própria comunidade tendia a utilizar ele de uma forma que levava a isso (e porque ela estava buscando por isso ao criar propostas pra serem incorporadas nas novas versões da linguagem; a própria Microsoft está de olho nisso e mesmo em linguagens como Rust [admitidamente]). Se todos seguissem determinado padrão de apenas programar de forma estrita e orientada a objetos (como é um dos paradigmas principais de quando C# foi criado), não teriamos nenhum desses desenvolvimentos. Então, apesar de eu também indicar manter-se no padrão, acredito que levar isso muito a sério será muito mais prejudicial do que benéfico no longo prazo pra adaptabilidade da linguagem (inclusive, se você sente a necessidade de fazer um "wrapper de alguma coisa" já é um indício de que isso é algo que falta na linguagem em questão, se muitas pessoas sentem isso isso se torna uma modificação efetiva com o tempo).

Da mesma forma, acredito que a dica de utilizar exceptions de acordo com as especificações da linguagem mina a própria maleabilidade da linguagem. Existe por exemplo um certo movimento relacionado a adicionar uniões discriminadas em C# já a algum tempo, motivado justamente por pessoas percebendo as vantagens desse tipo de estrutura e buscando trazer isso para complementar C# (da mesma forma, existem diversos "wrappers" disso, como o clássico OneOf ou o ErrorOr mencionado); na medida da existência dessas libraries e do fato de que elas possuem adopters podemos ver que existe uma busca por justamente quebrar com esse paradigma de tratar exceptions como erros, e disso algo bom pode surgir (ou pode ser barrado pelo time do C#, como tem sido até o momento, os desenvolvedores também são responsáveis por manter certos padrões mesmo com buscas da comunidade em alguns casos, pela própria visão que buscam criar da linguagem mesma, no entanto nada disso é imediatamente óbvio).

Então, discordo de que isso é "mais importante", mas acredito que o sentimento geral de que é benéfico em muitos casos que padrões sejam respeitados é perfeitamente válido.

Minha palhinha sobre exceptions e errors como valores

Acho que as questões acima resumem boa parte do que pensei sobre o artigo em geral, mas acredito que posso trazer algo pra esse campo também.

Penso que em termos gerais: Exceptions são funcionalmente equivalentes a erros como valores. O que isso significa? Significa que, pelo menos no que posso perceber, você não tem vantagens ou desvantagens imediatamente óbvias e funcionalmente discrepantes entre as duas formas de escrever código (e nesse sentido, a discussão é fútil para a maior parte dos casos).

Um dos maiores problemas que vejo nas exceptions é também um problema que vejo no próprio tratamento de erros de Go, e que eu não veria em relação a Swift e Rust. Que é a questão de 'checagem em tempo de compilação'. Tanto C# quanto Go tratam seus erros como questões opcionais de serem lidadas, e tratam os valores como o principal. Ambos dão espaço para erros bobos que podem se alastrar pela aplicação, ambos possuem um ponto de falha na representação dos próprios estados possíveis da aplicação que me parece um erro em si mesmo. Em C# você pode simplesmente não checar as exceptions com um try-catch, e com isso seu programa vai crashar e você vai ter erros e, em alguns casos, pode ter problemas com recursos não fechados ou o que for. Em Go, a situação é a mesma, se não verificamos os erros adquirimos a chance de acessar dados corrompidos ou inválidos, crashar nosso programa e incorrer em diversos problemas. Acredito que isso é semanticamente problemático do ponto de vista da utilização das linguagens, e nesse sentido eu diria que Rust e Swift satisfazem esse problema de uma forma muito superior mesmo adotando formas diferentes de lidar com erros. Em Rust, você tem uma certa verbosidade natural de lidar com os erros, mas garante que o estado X em que seu programa estará vai conter toda a informação que você precisa saber sobre os valores que estão sendo operados: i.e. se você receber um Result<User, Error> ao chamar a função login(...), você sabe que pode estar lidando ou com um User (login bem sucedido) ou com um problema ao fazer login (Error), e terá certeza de estar acessando o estado apropriado dos seus dados na medida em que fizer o 'match' neles (ou usar algo como let-else ou if-let, ou o ?). Rust induz uma abordagem de transformação dos estados, induz a irrepresentabilidade de erros em valores no código na medida em que os problemas que aparecem precisam ser tratados pra consumir os valores que estão sendo contidos nos erros, com isso ele remove a própria possibilidade de você simplesmente ignorar o 'err' e usar o valor como se nada tivesse acontecido. Swift, na mesma medida, consegue uma proeza semelhante ao exigir que você sempre esteja imediatamente verificando se uma função levanta uma exception ou não, e exigindo que você marque suas funções como potenciais geradoras de erros caso você utilize 'throw' no código. Dessa forma, você remove novamente a possibilidade de incorrer em problemas de representação dos estados do programa, ao exigir que os erros sejam tratados de imediato caso ocorra algum problema (ou, como em Rust, ao 'levantar' os erros para que funções anteriores na hierarquia de chamadas lidem com os erros). Uma vez que esse mecanismo está colocado na própria corelib da linguagem (e em todas as utilizações da linguagem), ele também garante que essa integridade de estados seja propagada em todas as libraries que você possa consumir e, com isso, lhe adiciona segurança no código que escreve (é claro que isso não é uma garantia 100% certeira, mesmo em Rust você tem coisas como 'panic' que podem evadir do comportamento tradicional de gerenciamento de erros; mas nessas linguagens recursos do tipo são realmente tratados como 'exceções' ao invés da regra, justamente por possuírem melhores recursos pra lidar com os erros de forma primária).

Então, eu diria que tanto erro como valor quanto exceptions podem e foram implementadas de forma errada e/ou problemática em muitas linguagens, mas acredito que uma vez que fossem corretamente implementadas, não haveria diferença prática na utilização de ambas que não a sintaxe para tal.

Conclusão

Apenas use o que lhe for mais conveniente, se precisar utilizar erros como valores em C# porque quer levar seu código a ter um nível maior de garantias faça isso, se quiser se prender ao padrão e manter um nível mais imediatamente compreensível de código faça isso, mas qualquer que seja sua decisão faça questão de manter isso de forma clara e pública pra todos que forem utilizar seu código possam entender, consumir e escrever na medida dos padrões que você adotar no tratamento de erros (ou em outras áreas, que seja). Em geral discussões de internet são sem sentido, e a maioria das dicas encontradas são inúteis ou pedantes, em muitos casos será melhor que você estude os benefícios e malefícios de cada forma de fazer as coisas e entenda-os bem antes de entrar em qualquer empreitada específica que tenha uma disrupção com os padrões estabelecidos de uma linguagem ou ambiente no qual você está programando. Fora isso, você é um engenheiro de software, tome as benditas decisões que façam seu código ser o melhor possível dentro dos seus limites.

Cheers.

Ah, uma coisinha final. Acredito tanto na questão de levar cada linguagem com sua forma de fazer as coisas que, mesmo pensando ser benéficio que se adicionem DU's em C#, ainda acredito que o que deveria ser feito na linguagem seria a checagem em tempo de compilação de exceptions (ao invés de utilizar erros como valores necessariamente). Isso resolveria uma boa parcela dos problemas que as pessoas veem nas exceptions do C# e tornaria a linguagem muito mais robusta.

No entanto, também introduziria problemas de backwards compatibility (na medida em que todo código anterior a isso se tornaria inerentemente inseguro), então a criação disso possivelmente se daria de uma forma semelhante a como C# lida com 'nullable types' atualmente (os trata como recursos opcionais mas indicados, os quais você pode ignorar quando necessário). Acredito que tratar as exceptions como opcionais moveria o problema uma camada pra cima (da mesma forma que penso que a abordagem que tomaram com nullables move o problema pra cima), mas não se pode ter tudo e, de muitas formas, é melhor ter isso do que não ter nada (e enquanto for possível habilitar isso absolutamente pra novos projetos e manter isso como um warning pra projetos externos mais antigos, também deve ser possível equilibrar os benefícios e malefícios até certo ponto, como o nullable faz).

Acho que as exceções viraram um problem em linguagens sem checked exception como C++ e C# por que os programadores esquecem frequentemente de tratar elas. Então esses mecanismos de retorno de erro facilitam para lembrar para sempre tratar erros. Particulamente, acredito que retorno de erro melhora a leitura código, já que evita aqueles blocos try { } catch {} gigantescos.

Mas o mais importante é: nunca ignore erros e sempre verifique se os argumentos passados para os procedimentos são válidos, o sistema nunca pode ficar em um estado inválido. Mesmo que o código fique verboroso, seja usando exceções ou com retorno de erro.