Devo adicionar código redundante agora, caso seja necessário no futuro?

114

Certo ou errado, atualmente acredito que sempre devo tentar tornar meu código o mais robusto possível, mesmo que isso signifique adicionar código / verificações redundantes que sei que não serão úteis no momento, mas eles pode ser x quantidade de anos abaixo da linha.

Por exemplo, atualmente estou trabalhando em um aplicativo para dispositivos móveis que possui este código:

public static CalendarRow AssignAppointmentToRow(Appointment app, List<CalendarRow> rows)
{
    //1. Is rows equal to null? - This will be the case if this is the first appointment.
    if (rows == null) {
        rows = new List<CalendarRow> ();
    }

    //2. Is rows empty? - This will be the case if this is the first appointment / some other unknown reason.
    if(rows.Count == 0)
    {
        rows.Add (new CalendarRow (0));
        rows [0].Appointments.Add (app);
    }

    //blah...
}

Observando especificamente a seção dois, sei que se a seção um for verdadeira, a seção dois também será verdadeira. Não consigo pensar em nenhuma razão para o fato de a seção um ser falsa e a segunda avaliar verdadeira, o que torna a segunda ifafirmação redundante.

No entanto, pode haver um caso no futuro em que essa segunda ifdeclaração seja realmente necessária e por um motivo conhecido.

Algumas pessoas podem olhar para isso inicialmente e pensar que estou programando com o futuro em mente, o que obviamente é uma coisa boa. Mas conheço algumas instâncias em que esse tipo de código me "ocultou" erros. Isso significa que demorei ainda mais para descobrir por que a função xyzestá funcionando abcquando realmente deveria estar def.

Por outro lado, também houve vários casos em que esse tipo de código tornou muito, muito mais fácil aprimorar o código com novos comportamentos, porque não preciso voltar atrás e garantir que todas as verificações relevantes estejam em vigor.

Há algum gerais regra de polegar diretrizes para esse tipo de código? (Eu também estaria interessado em saber se isso seria considerado uma boa ou má prática?)

NB: Isso pode ser considerado semelhante a esta pergunta, mas , diferentemente dessa pergunta, eu gostaria de uma resposta, assumindo que não há prazos.

TLDR: Devo ir ao ponto de adicionar código redundante para torná-lo potencialmente mais robusto no futuro?

KidCode
fonte
95
No desenvolvimento de software, coisas que nunca deveriam acontecer acontecem o tempo todo.
Roman Reiner
34
Se você sabe if(rows.Count == 0)que nunca acontecerá, poderá gerar uma exceção quando isso acontecer - e verificar por que sua suposição se enganou.
Knut
9
Irrelevante para a pergunta, mas suspeito que seu código tenha um erro. Quando linhas é nula, uma nova lista é criada e (suponho) jogada fora. Mas quando as linhas não são nulas, uma lista existente é alterada. Um design melhor pode ser insistir para que o cliente passe em uma lista que pode ou não estar vazia.
Theodore Norvell 27/03
9
Por que rowsseria nulo? Nunca há um bom motivo, pelo menos no .NET, para uma coleção ser nula. Vazio , claro, mas não nulo . Eu lançaria uma exceção se rowsfor nulo, porque significa que há um lapso na lógica do chamador.
Kyralessa 28/03

Respostas:

176

Como exercício, primeiro vamos verificar sua lógica. Embora, como veremos, você tenha problemas maiores que qualquer problema lógico.

Chame a primeira condição A e a segunda condição B.

Você diz primeiro:

Olhando especificamente para a seção dois, sei que se a seção um for verdadeira, a seção dois também será verdadeira.

Ou seja: A implica B, ou, em termos mais básicos (NOT A) OR B

E depois:

Não consigo pensar em nenhuma razão para o fato de a seção um ser falsa e a segunda avaliar verdadeira, o que torna a segunda se redundante.

Ou seja: NOT((NOT A) AND B). Aplique a lei de Demorgan para obter (NOT B) OR Aque B implica A.

Portanto, se as duas afirmações forem verdadeiras, A implica B e B implica A, o que significa que elas devem ser iguais.

Portanto, sim, as verificações são redundantes. Você parece ter quatro caminhos de código no programa, mas na verdade você tem apenas dois.

Então agora a pergunta é: como escrever o código? A verdadeira questão é: qual é o contrato declarado do método ? A questão de saber se as condições são redundantes é um arenque vermelho. A verdadeira questão é "eu projetei um contrato sensato e meu método implementa claramente esse contrato?"

Vejamos a declaração:

public static CalendarRow AssignAppointmentToRow(
    Appointment app,    
    List<CalendarRow> rows)

É público, por isso deve ser robusto a dados ruins de chamadores arbitrários.

Ele retorna um valor, portanto deve ser útil pelo seu valor de retorno, não pelo efeito colateral.

E, no entanto, o nome do método é um verbo, sugerindo que é útil por seu efeito colateral.

O contrato do parâmetro da lista é:

  • Uma lista nula está OK
  • Uma lista com um ou mais elementos está OK
  • Uma lista sem elementos está incorreta e não deve ser possível.

Este contrato é insano . Imagine escrever a documentação para isso! Imagine escrever casos de teste!

Meu conselho: recomeçar. Essa API possui uma interface de máquina de doces escrita por toda parte. (A expressão é de uma história antiga sobre as máquinas de doces da Microsoft, onde os preços e as seleções são números de dois dígitos, e é super fácil digitar "85", que é o preço do item 75, e você obtém Fato curioso: sim, fiz isso por engano quando estava tentando extrair chiclete de uma máquina de venda automática da Microsoft!)

Veja como criar um contrato sensato:

Torne seu método útil para o efeito colateral ou o valor de retorno, não para os dois.

Não aceite tipos mutáveis ​​como entradas, como listas; se você precisar de uma sequência de informações, use um IEnumerable. Leia apenas a sequência; não grave em uma coleção passada, a menos que seja muito claro que este é o contrato do método. Ao usar o IEnumerable, você envia uma mensagem para o chamador informando que não irá alterar a coleção deles.

Nunca aceite nulos; uma sequência nula é uma abominação. Exija que o chamador passe uma sequência vazia, se isso fizer sentido, nunca nulo.

Falha imediatamente se o interlocutor violar seu contrato, para ensiná-lo que você está falando sério e para que eles detectem seus erros nos testes, não na produção.

Projete o contrato primeiro para ser o mais sensato possível e, em seguida, implemente claramente o contrato. Esse é o caminho para um design à prova de futuro.

Agora, conversamos apenas sobre o seu caso específico e você fez uma pergunta geral. Então, aqui estão alguns conselhos gerais adicionais:

  • Se houver um fato que você como desenvolvedor possa deduzir, mas o compilador não puder, use uma asserção para documentar esse fato. Se outro desenvolvedor, como o futuro você ou um de seus colegas de trabalho, violar essa suposição, a afirmação informará.

  • Obtenha uma ferramenta de cobertura de teste. Verifique se seus testes abrangem todas as linhas de código. Se houver um código descoberto, você terá um teste ausente ou um código morto. O código morto é surpreendentemente perigoso, porque geralmente não se destina a estar morto! A incrivelmente terrível falha de segurança da Apple que "vai falhar" há alguns anos vem imediatamente à mente.

  • Obtenha uma ferramenta de análise estática. Heck, pegue vários; toda ferramenta tem sua própria especialidade específica e nenhuma é um superconjunto das outras. Preste atenção ao informar que há um código inacessível ou redundante. Novamente, esses são prováveis ​​erros.

Se parece que estou dizendo: primeiro, projete bem o código e, segundo, teste o que há de errado para garantir que esteja correto hoje, é o que estou dizendo. Fazer essas coisas tornará muito mais fácil lidar com o futuro; a parte mais difícil do futuro é lidar com todo o código de máquina de doces de buggy que as pessoas escreveram no passado. Faça o que está certo hoje e os custos serão mais baixos no futuro.

Eric Lippert
fonte
24
Eu nunca pensei sobre métodos como esse antes, pensando nisso agora, sempre pareço tentar cobrir todas as eventualidades, quando, na realidade, se a pessoa / método que chama meu método não me passa o que eu preciso, não deveria ' Para tentar consertar os erros deles (quando eu realmente não sei o que eles pretendiam), eu deveria apenas travar. Obrigado por isso, uma lição valiosa foi aprendida!
KidCode
4
O fato de um método retornar um valor não implica que ele também não deva ser útil pelo efeito colateral. No código simultâneo, a capacidade das funções de retornar ambas é frequentemente vital (imagine CompareExchangesem essa capacidade!), E mesmo em cenários não concorrentes, algo como "adicione um registro, se não existir, e retorne o pass-in registro ou o que existia "pode ​​ser mais conveniente do que qualquer abordagem que não empregue um efeito colateral e um valor de retorno.
28616
5
@KidCode Sim, Eric é realmente bom em explicar as coisas claramente, mesmo alguns tópicos realmente complicados. :)
Mason Wheeler
3
@ supercat Claro, mas também é mais difícil de raciocinar. Primeiro, você provavelmente desejará examinar algo que não modifique um estado global, evitando problemas de concorrência e corrupção de estado. Quando isso não for razoável, você ainda vai querer isolar os dois, o que fornece lugares claros onde a simultaneidade é um problema (e, portanto, é considerado extremamente perigoso) e os locais onde é tratada. Essa foi uma das idéias centrais do documento original da POO e está no centro da utilidade dos atores. Nenhuma regra religiosa - basta preferir separar as duas quando fizer sentido. Geralmente faz.
Luaan 29/03/16
5
Este é um post muito útil para praticamente todos!
Adrian Buzea
89

O que você está fazendo no código que está mostrando acima não é tanto uma prova futura, mas também uma codificação defensiva .

Ambas as ifinstruções testam coisas diferentes. Ambos são testes adequados, dependendo de suas necessidades.

A Seção 1 testa e corrige um nullobjeto. Nota lateral: Criar a lista não cria nenhum item filho (por exemplo, CalendarRow).

A Seção 2 testa e corrige erros de usuário e / ou implementação. Só porque você tem um List<CalendarRow>não significa que você possui itens na lista. Usuários e implementadores farão coisas que você não pode imaginar apenas porque têm permissão para fazê-lo, faça sentido para você ou não.

Adam Zuckerman
fonte
1
Na verdade, estou usando 'truques estúpidos de usuário' como entrada. SIM, você nunca deve confiar na entrada. Mesmo fora da sua classe. Validar! Se você acha que isso é apenas uma preocupação futura, ficaria feliz em invadir você hoje.
candied_orange
1
@CandiedOrange, essa era a intenção, mas o texto não transmitia a tentativa de humor. Eu mudei a redação.
Adam Zuckerman 27/03
3
Apenas uma pergunta rápida aqui, se é um erro de implementação / dados incorretos, não devo simplesmente travar, em vez de tentar corrigir os erros deles?
KidCode
4
@KidCode Sempre que você tentar se recuperar, "conserte os erros deles", precisará fazer duas coisas, retornar a um estado conhecido e não perder informações valiosas silenciosamente. Seguir essa regra nesse caso levanta a questão: uma lista de linhas zero é uma entrada valiosa?
28416 Candied_orange
7
Discordo totalmente desta resposta. Se uma função recebe entrada inválida, significa que, por definição, há um erro no programa. A abordagem correta é lançar uma exceção em entradas inválidas, para que você descubra o problema e corrija o erro. A abordagem que você descreve apenas oculta bugs, o que os torna mais insidiosos e mais difíceis de rastrear. A codificação defensiva significa que você não confia automaticamente na entrada, mas a valida, mas isso não significa que você deve fazer suposições aleatórias para "corrigir" entradas inválidas ou inesperadas.
JacquesB
35

Eu acho que essa questão é basicamente a gosto. Sim, é uma boa idéia escrever código robusto, mas o código no seu exemplo é uma leve violação do princípio do KISS (como muitos desses códigos "à prova de futuro" serão).

Eu, pessoalmente, provavelmente não me incomodaria em tornar o código à prova de balas para o futuro. Não conheço o futuro e, como tal, qualquer código "à prova de balas" está fadado a falhar miseravelmente quando o futuro chegar de qualquer maneira.

Em vez disso, prefiro uma abordagem diferente: faça as suposições que você tornar explícitas usando a assert()macro ou recursos semelhantes. Dessa forma, quando o futuro chegar, ele lhe dirá exatamente onde suas suposições não se sustentam mais.

cmaster
fonte
4
Eu gosto do seu ponto de não saber o que o futuro reserva. No momento, tudo o que realmente estou fazendo é adivinhar o que poderia ser um problema e adivinhar novamente a solução.
KidCode
3
@ KidCode: Boa observação. Seu próprio pensamento aqui é realmente muito mais inteligente do que muitas das respostas aqui, incluindo a que você aceitou.
JacquesB
1
Eu gosto desta resposta. Mantenha o código mínimo, para que seja fácil para um futuro leitor entender por que são necessárias verificações. Se um futuro leitor vir verificações de coisas que parecem desnecessárias, poderá perder tempo tentando entender por que a verificação está lá. Um futuro humano pode estar modificando essa classe, em vez de outros que a usam. Além disso, não escreva código que você não pode depurar , o que acontecerá se você tentar lidar com casos que atualmente não podem acontecer. (A menos que você escrever testes de unidade que exercem caminhos de código que o programa principal não.)
Peter Cordes
23

Outro princípio que você pode querer pensar é a idéia de falhar rapidamente . A idéia é que, quando algo der errado em seu programa, você deseja interromper o programa imediatamente, pelo menos enquanto o estiver desenvolvendo antes de liberá-lo. Sob esse princípio, você deseja fazer muitas verificações para garantir que suas suposições sejam válidas, mas considere seriamente que seu programa pare de funcionar sempre que as suposições forem violadas.

Para colocá-lo com ousadia, se houver um pequeno erro no seu programa, você deseja travar completamente enquanto assiste!

Isso pode parecer contra-intuitivo, mas permite descobrir erros o mais rápido possível durante o desenvolvimento de rotina. Se você está escrevendo um pedaço de código e acha que está concluído, mas falha ao testá-lo, não há dúvida de que você ainda não terminou. Além disso, a maioria das linguagens de programação oferece excelentes ferramentas de depuração mais fáceis de usar quando o programa falha completamente, em vez de tentar fazer o melhor possível após um erro. O maior exemplo mais comum é que, se você travar o programa lançando uma exceção não tratada, a mensagem de exceção informará uma quantidade incrível de informações sobre o bug, incluindo qual linha de código falhou e o caminho através do código que o programa assumiu. seu caminho para essa linha de código (o rastreamento da pilha).

Para mais pensamentos, leia este pequeno ensaio: Não pregue seu programa na posição vertical


Isso é relevante para você, porque é possível que, às vezes, as verificações que você está escrevendo estejam lá porque você deseja que seu programa continue em execução mesmo depois que algo der errado. Por exemplo, considere esta breve implementação da sequência de Fibonacci:

// Calculates the nth Fibonacci number
int fibonacci(int n) {
    int a = 0;
    int b = 1;

    for(int i = 0; i < n; i++) {
        int temp = b;
        b = a + b;
        a = temp;
    }

    return b;
}

Isso funciona, mas e se alguém passar um número negativo para sua função? Não vai funcionar então! Portanto, você deseja adicionar uma verificação para garantir que a função seja chamada com uma entrada não negativa.

Pode ser tentador escrever a função assim:

// Calculates the nth Fibonacci number
int fibonacci(int n) {
    int a = 0;
    int b = 1;

    // Make sure the input is nonnegative
    if(n < 0) {
        n = 1; // Replace the negative input with an input that will work
    }

    for(int i = 0; i < n; i++) {
        int temp = b;
        b = a + b;
        a = temp;
    }

    return b;
}

No entanto, se você fizer isso, mais tarde, acidentalmente, chamar sua função de Fibonacci com uma entrada negativa, você nunca perceberá! Pior, seu programa provavelmente continuará em execução, mas começará a produzir resultados sem sentido, sem fornecer pistas sobre onde algo deu errado. Esses são os tipos mais difíceis de corrigir.

Em vez disso, é melhor escrever um cheque como este:

// Calculates the nth Fibonacci number
int fibonacci(int n) {
    int a = 0;
    int b = 1;

    // Make sure the input is nonnegative
    if(n < 0) {
        throw new ArgumentException("Can't have negative inputs to Fibonacci");
    }

    for(int i = 0; i < n; i++) {
        int temp = b;
        b = a + b;
        a = temp;
    }

    return b;
}

Agora, se você chamar acidentalmente a função Fibonacci com uma entrada negativa, seu programa será interrompido imediatamente e informará que algo está errado. Além disso, fornecendo um rastreamento de pilha, o programa informará qual parte do seu programa tentou executar a função Fibonacci incorretamente, fornecendo um excelente ponto de partida para depurar o que está errado.

Kevin
fonte
1
O c # não possui um tipo específico de exceção para indicar argumentos inválidos ou fora do intervalo?
JDługosz 28/03
@ JDługosz Sim! C # possui uma ArgumentException e Java possui uma IllegalArgumentException .
28716 Kevin
A questão estava usando c #. Aqui está o C ++ (link para o resumo) para detalhes.
JDługosz
3
"Falhar rápido para o estado seguro mais próximo" é mais razoável. Se você fizer seu aplicativo travar quando algo inesperado acontecer, você corre o risco de perder os dados do usuário. Locais em que os dados do usuário estão em risco são ótimos pontos para o tratamento "penúltimo" de exceções (sempre há casos em que você não pode fazer nada além de levantar as mãos - a falha final). Somente fazer o travamento na depuração é apenas abrir outra lata de worms - você precisa testar o que está implantando para o usuário de qualquer maneira e agora está gastando metade do seu tempo de teste em uma versão que o usuário nunca verá.
Luaan 29/03/16
2
@Luaan: essa não é a responsabilidade da função de exemplo.
Whatsisname
11

Você deve adicionar código redundante? Não.

Mas, o que você descreve não é um código redundante .

O que você descreve é ​​programar defensivamente contra o código de chamada que viola as pré-condições de sua função. Se você faz isso ou simplesmente deixa para os usuários a leitura da documentação e evita essas violações, é totalmente subjetivo.

Pessoalmente, acredito muito nessa metodologia, mas, como em tudo, é preciso ter cuidado. Tomemos, por exemplo, C ++ 's std::vector::operator[]. Deixando de lado a implementação do modo de depuração do VS por um momento, essa função não executa verificação de limites. Se você solicitar um elemento que não existe, o resultado será indefinido. Cabe ao usuário fornecer um índice vetorial válido. Isso é bastante deliberado: você pode "ativar" a verificação de limites adicionando-a no local da chamada, mas se a operator[]implementação fosse executada, não seria possível "desativar". Como uma função de nível bastante baixo, isso faz sentido.

Mas se você estivesse escrevendo uma AddEmployee(string name)função para alguma interface de nível superior, seria de esperar que essa função gerasse pelo menos uma exceção se você fornecesse um vazio name, além de essa pré-condição ser documentada imediatamente acima da declaração da função. Você pode não estar fornecendo entrada não autorizada do usuário para essa função hoje, mas torná-la "segura" dessa maneira significa que qualquer violação de pré-condição que aparecer no futuro pode ser facilmente diagnosticada, em vez de potencialmente desencadear uma cadeia de dominós de difícil acesso. detectar erros. Isso não é redundância: é diligência.

Se eu tivesse que criar uma regra geral (embora, como regra geral, tente evitá-las), diria que uma função satisfaz qualquer um dos seguintes:

  • vive em uma linguagem de nível superior (por exemplo, JavaScript em vez de C)
  • fica em um limite de interface
  • não é crítico para o desempenho
  • aceita diretamente a entrada do usuário

... pode se beneficiar da programação defensiva. Em outros casos, você ainda pode gravar os assertíons disparados durante o teste, mas estão desabilitados nas compilações de versão, para aumentar ainda mais sua capacidade de encontrar bugs.

Este tópico é mais explorado na Wikipedia ( https://en.wikipedia.org/wiki/Defensive_programming ).

Raças de leveza em órbita
fonte
9

Dois dos dez mandamentos de programação são relevantes aqui:

  • Você não deve assumir que a entrada está correta

  • Não criarás código para uso futuro

Aqui, verificar nulo não é "criar código para uso futuro". Criar código para uso futuro é algo como adicionar interfaces, porque você acha que elas podem ser úteis "algum dia". Em outras palavras, o mandamento é não adicionar camadas de abstração, a menos que sejam necessárias no momento.

A verificação de nulo não tem nada a ver com o uso futuro. Tem a ver com o mandamento nº 1: não assuma que a entrada estará correta. Nunca assuma que sua função receberá algum subconjunto de entrada. Uma função deve responder de uma maneira lógica, não importa o quão falsa e bagunçada as entradas sejam.

Tyler Durden
fonte
5
Onde estão esses mandamentos de programação. Você tem um link? Estou curioso, porque trabalhei em vários programas que assinam o segundo desses mandamentos, e vários que não o fizeram. Invariavelmente, aqueles que assinaram o mandamento Thou shall not make code for future useentraram em problemas de manutenção mais cedo , apesar da lógica intuitiva do mandamento. Descobri, na codificação da vida real, que o mandamento só é eficaz no código em que você controla a lista de recursos e os prazos, além de garantir que não precise de um código com aparência futura para alcançá-los ... ou seja, nunca.
Cort Ammon 28/03
1
Provável trivialmente: se é possível estimar a probabilidade de uso futuro e o valor projetado do código "uso futuro", e o produto desses dois for maior que o custo de adicionar o código "uso futuro", é estatisticamente ideal adicione o código Penso que o mandamento aparece em situações em que os desenvolvedores (ou gerentes) são forçados a admitir que suas habilidades de estimativa não são tão confiáveis ​​quanto gostariam, então, como medida defensiva, eles simplesmente optam por não estimar tarefas futuras.
Cort Ammon 28/03
2
@CortAmmon Não há lugar para mandamentos religiosos na programação - as "melhores práticas" só fazem sentido no contexto, e aprender uma "melhor prática" sem o raciocínio torna você incapaz de se adaptar. Achei o YAGNI muito útil, mas isso não significa que não penso em lugares onde a adição de pontos de extensão mais tarde será caro - apenas significa que não preciso pensar nos casos simples com antecedência. Obviamente, isso também muda com o tempo, à medida que mais e mais premissas se apoderam do seu código, aumentando efetivamente a interface do seu código - é um ato de equilíbrio.
Luaan 29/03
2
@CortAmmon Seu caso "trivialmente comprovável" ignora dois custos muito importantes - o custo da própria estimativa e os custos de manutenção do ponto de extensão (possivelmente desnecessário). É aí que as pessoas obtêm estimativas extremamente não confiáveis ​​- subestimando as estimativas. Para um recurso muito simples, basta pensar por alguns segundos, mas é bem provável que você encontre toda uma lata de worms que se segue ao recurso "simples" inicial. A comunicação é a chave - à medida que as coisas aumentam, você precisa conversar com seu líder / cliente.
Luaan 29/03/16
1
@Luaan Eu estava tentando argumentar a seu favor, não há lugar para mandamentos religiosos na programação. Desde que exista um caso de negócios em que os custos de estimativa e manutenção do ponto de extensão sejam suficientemente limitados, há um caso em que o referido "mandamento" é questionável. Da minha experiência com o código, a questão de deixar esses pontos de extensão ou não nunca se encaixou muito bem em um único mandamento de uma maneira ou de outra.
Cort Ammon
7

A definição de 'código redundante' e 'YAGNI' geralmente depende de quanto tempo você olha para o futuro.

Se você tiver um problema, tenderá a escrever código futuro de forma a evitar esse problema. Outro programador que não enfrentou esse problema específico pode considerar um exagero redundante no seu código.

Minha sugestão é acompanhar quanto tempo você gasta em 'coisas que ainda não erraram', se as cargas forem carregadas e os colegas compartilharem recursos mais rapidamente do que você, depois diminua-o.

No entanto, se você é como eu, espero que você tenha digitado tudo 'por padrão' e realmente não demorou mais.

Ewan
fonte
6

É uma boa idéia documentar quaisquer suposições sobre parâmetros. E é uma boa ideia verificar se o código do seu cliente não viola essas suposições. Eu faria isso:

/** ...
*   Precondition: rows is null or nonempty
*/
public static CalendarRow AssignAppointmentToRow(Appointment app, List<CalendarRow> rows)
{
    Assert( rows==null || rows.Count > 0 )
    //1. Is rows equal to null? - This will be the case if this is the first appointment.
    if (rows == null) {
        rows = new List<CalendarRow> ();
        rows.Add (new CalendarRow (0));
        rows [0].Appointments.Add (app);
    }

    //blah...
}

[Supondo que seja C #, o Assert pode não ser a melhor ferramenta para o trabalho, pois não é compilado no código liberado. Mas isso é um debate para outro dia.]

Por que isso é melhor do que o que você escreveu? Seu código faz sentido se, em um futuro em que seu cliente for alterado, quando o cliente passar em uma lista vazia, a coisa certa a fazer será adicionar uma primeira linha e adicionar o aplicativo aos seus compromissos. Mas como você sabe que será esse o caso? É melhor fazer menos suposições agora sobre o futuro.

Theodore Norvell
fonte
5

Estime o custo de adicionar esse código agora . Será relativamente barato, porque tudo está fresco em sua mente, então você poderá fazer isso rapidamente. É necessário adicionar testes de unidade - não há nada pior do que usar algum método um ano depois, ele não funciona e você descobre que ele foi quebrado desde o início e nunca funcionou.

Estime o custo de adicionar esse código quando necessário. Vai ser mais caro, porque você precisa voltar ao código, lembrar de tudo, e é muito mais difícil.

Estime a probabilidade de que o código adicional seja realmente necessário. Então faça as contas.

Por outro lado, código cheio de suposições "X nunca acontecerá" é horrível para depuração. Se algo não funciona como pretendido, significa um erro estúpido ou uma suposição errada. O seu "X nunca acontecerá" é uma suposição e, na presença de um bug, é suspeito. O que força o próximo desenvolvedor a perder tempo com isso. Geralmente é melhor não confiar em tais suposições.

gnasher729
fonte
4
No seu primeiro parágrafo, você esqueceu de mencionar o custo de manutenção desse código ao longo do tempo, quando o recurso realmente necessário é mutuamente exclusivo do recurso que foi adicionado desnecessariamente. . .
Ruakh 28/03
Você também precisa estimar o custo dos bugs que podem surgir no programa porque você não falha na entrada inválida. Mas você não pode estimar o custo dos bugs, pois, por definição, eles são inesperados. Então o conjunto "faça as contas" desmorona.
JacquesB
3

A questão principal aqui é "o que acontecerá se você fizer / não"?

Como outros já apontaram, esse tipo de programação defensiva é bom, mas também é ocasionalmente perigoso.

Por exemplo, se você fornecer um valor padrão, estará mantendo o programa atualizado. Mas o programa agora pode não estar fazendo o que você deseja. Por exemplo, se ele gravar essa matriz em branco em um arquivo, agora você pode ter transformado seu bug de "falhas porque forneci nulo por acidente" para "limpa as linhas do calendário porque forneci nulo por acidente". (por exemplo, se você começar a remover coisas que não aparecem na lista da parte em que se lê "// blá")

A chave para mim é nunca corromper dados . Deixe-me repetir isso; NUNCA. CORROMPIDO. DADOS. Se o seu programa der uma exceção, você recebe um relatório de bug que pode corrigir; se gravar dados ruins em um arquivo que será posteriormente, você precisará semear o solo com sal.

Todas as suas decisões "desnecessárias" devem ser tomadas com essa premissa em mente.

Deworde
fonte
2

O que você está lidando aqui é basicamente uma interface. Adicionando o comportamento "quando a entrada é null, inicialize a entrada", você estendeu efetivamente a interface do método - agora, em vez de sempre operar em uma lista válida, você "corrigiu" a entrada. Se essa é a parte oficial ou não oficial da interface, você pode apostar que alguém (provavelmente você) vai usar esse comportamento.

As interfaces devem ser mantidas simples e devem ser relativamente estáveis ​​- especialmente em algo como um public staticmétodo. Você tem um pouco de liberdade nos métodos privados, especialmente nos métodos de instância privada. Ao estender implicitamente a interface, você tornou seu código mais complexo na prática. Agora, imagine que você realmente não deseja usar esse caminho de código - para evitá-lo. Agora você tem um pouco de código não testado que finge fazer parte do comportamento do método. E posso dizer agora que provavelmente há um erro: quando você passa uma lista, essa lista é alterada pelo método. No entanto, se não o fizer, crie um locallista e jogue fora depois. Esse é o tipo de comportamento inconsistente que fará você chorar em meio ano, enquanto tenta rastrear um bug obscuro.

Em geral, a programação defensiva é uma coisa bastante útil. Mas os caminhos de código para as verificações defensivas devem ser testados, como qualquer outro código. Em um caso como esse, eles estão complicando seu código sem motivo, e eu optaria por uma alternativa como essa:

if (rows == null) throw new ArgumentNullException(nameof(rows));

Você não deseja uma entrada onde rowsseja nulo e deseja tornar o erro óbvio para todos os seus chamadores o mais rápido possível .

Há muitos valores que você precisa analisar ao desenvolver software. Até a robustez em si é uma qualidade muito complexa - por exemplo, eu não consideraria seu cheque defensivo mais robusto do que lançar uma exceção. As exceções são bastante úteis para oferecer a você um local seguro para tentar novamente a partir de um local seguro - os problemas de corrupção de dados geralmente são muito mais difíceis de rastrear do que reconhecer um problema logo no início e manipulá-lo com segurança. No final, eles apenas tendem a lhe dar uma ilusão de robustez e, um mês depois, você percebe que um décimo dos seus compromissos se foi, porque você nunca notou que uma lista diferente foi atualizada. Ai.

Certifique-se de distinguir entre os dois. A programação defensiva é uma técnica útil para detectar erros em um local onde eles são os mais relevantes, ajudando significativamente seus esforços de depuração e com um bom tratamento de exceções, evitando a "corrupção furtiva". Falhe cedo, falhe rápido. Por outro lado, o que você está fazendo é mais como "ocultação de erros" - você está manipulando entradas e fazendo suposições sobre o que o chamador quis dizer. Isso é muito importante para o código voltado ao usuário (por exemplo, verificação ortográfica), mas você deve ser cauteloso ao ver isso com o código voltado para o desenvolvedor.

O principal problema é que, qualquer que seja a abstração que você fizer, ela vazará ("Eu queria escrever mais do que isso! Verificador estúpido de correção ortográfica!"), E o código para lidar com todos os casos e correções especiais ainda será o código precisa manter e entender e codificar que você precisa testar. Compare o esforço de garantir que uma lista não nula seja aprovada com a correção do bug que você tem um ano depois na produção - não é uma boa opção. Em um mundo ideal, você deseja que todo método funcione exclusivamente com sua própria entrada, retornando um resultado e modificando nenhum estado global. Claro, no mundo real, você encontrará muitos casos em que isso não éa solução mais simples e clara (por exemplo, ao salvar um arquivo), mas acho que manter os métodos "puros" quando não há motivo para ler ou manipular o estado global facilita o raciocínio do código. Também tende a fornecer pontos mais naturais para dividir seus métodos :)

Isso não significa que tudo que é inesperado deve causar uma falha no aplicativo, pelo contrário. Se você usar bem as exceções, elas naturalmente formarão pontos seguros de tratamento de erros, onde você poderá restaurar o estado estável do aplicativo e permitir que o usuário continue o que está fazendo (idealmente, evitando qualquer perda de dados para o usuário). Nesses pontos de manipulação, você verá oportunidades para corrigir os problemas ("Nenhum número de pedido 2212 encontrado. Você quis dizer 2212b?") Ou fornecer ao usuário o controle ("Erro ao conectar-se ao banco de dados. Tente novamente?"). Mesmo quando essa opção não estiver disponível, pelo menos, você terá a chance de que nada seja corrompido - comecei a apreciar o código que usa usinge try... finallymuito mais do que try...catch, oferece muitas oportunidades para manter os invariantes, mesmo em condições excepcionais.

Os usuários não devem perder seus dados e trabalho. Isso ainda precisa ser equilibrado com os custos de desenvolvimento, etc., mas é uma orientação geral muito boa (se os usuários decidirem comprar seu software ou não - o software interno geralmente não tem esse luxo). Até o travamento de todo o aplicativo se torna muito menos problemático se o usuário puder simplesmente reiniciar e voltar ao que estava fazendo. Isso é realmente robusto - o Word salva seu trabalho o tempo todo sem danificar o documento em disco e oferece uma opçãorestaurar essas alterações após reiniciar o Word após uma falha. É melhor do que um erro não em primeiro lugar? Provavelmente não - embora não se esqueça que o trabalho gasto na captura de um bug raro pode ser melhor gasto em todos os lugares. Mas é muito melhor do que as alternativas - por exemplo, um documento corrompido no disco, todo o trabalho desde a última vez que foi salvo, documento substituído automaticamente por alterações antes da falha, que eram apenas Ctrl + A e Delete.

Luaan
fonte
1

Vou responder isso com base na sua suposição de que um código robusto beneficiará você "daqui a alguns anos". Se os benefícios a longo prazo forem sua meta, eu priorizaria o design e a manutenção, em vez da robustez.

A troca entre design e robustez é tempo e foco. A maioria dos desenvolvedores prefere ter um conjunto de códigos bem projetados, mesmo que isso signifique passar por alguns pontos problemáticos e fazer alguma condição adicional ou tratamento de erros. Após alguns anos de uso, os locais que você realmente precisa provavelmente foram identificados pelos usuários.

Supondo que o design tenha a mesma qualidade, menos código é mais fácil de manter. Isso não significa que estamos em melhor situação se você deixar problemas conhecidos por alguns anos, mas adicionar coisas que você sabe que não precisa dificulta. Todos analisamos o código legado e descobrimos partes desnecessárias. Você precisa ter um código de alteração de confiança de alto nível que funcione há anos.

Portanto, se você acha que seu aplicativo foi projetado da melhor maneira possível, é fácil de manter e não possui bugs, encontre algo melhor para fazer do que adicionar o código que você não precisa. É o mínimo que você pode fazer por respeito a todos os outros desenvolvedores que trabalham longas horas em recursos inúteis.

JeffO
fonte
1

Não, você não deveria. E você está realmente respondendo sua própria pergunta quando afirma que essa maneira de codificar pode ocultar bugs . Isso não tornará o código mais robusto - ao contrário, tornará mais propenso a erros e tornará a depuração mais difícil.

Você declara suas expectativas atuais sobre o rowsargumento: ou é nulo ou tem pelo menos um item. Portanto, a pergunta é: É uma boa idéia escrever o código para lidar adicionalmente com o terceiro caso inesperado, onde não rowshá itens?

A resposta é não. Você sempre deve lançar uma exceção no caso de entrada inesperada. Considere o seguinte: Se algumas outras partes do código quebram a expectativa (ou seja, o contrato) do seu método, isso significa que há um erro . Se houver um erro, você deseja conhecê-lo o mais cedo possível para poder corrigi-lo, e uma exceção o ajudará a fazer isso.

O que o código atualmente faz é adivinhar como se recuperar de um bug que pode ou não existir no código. Mas mesmo se houver um bug, você não pode saber como se recuperar totalmente dele. Erros por definição têm consequências desconhecidas. Talvez algum código de inicialização não tenha sido executado conforme o esperado, podendo ter muitas outras consequências além da linha que está faltando.

Portanto, seu código deve ficar assim:

public static CalendarRow AssignAppointmentToRow(Appointment app, List<CalendarRow> rows)
{
    if (rows != null && rows.Count == 0) throw new ArgumentException("Rows is empty."); 

    //1. Is rows equal to null? - This will be the case if this is the first appointment.
    if (rows == null) {
        rows = new List<CalendarRow> ();
        rows.Add (new CalendarRow (0));
        rows [0].Appointments.Add (app);
    }

    //blah...
}

Nota: Existem alguns casos específicos em que faz sentido "adivinhar" como lidar com entradas inválidas em vez de apenas lançar uma exceção. Por exemplo, se você lida com entrada externa, não tem controle. Os navegadores da Web são um exemplo infame, porque tentam lidar com qualquer tipo de entrada incorreta e inválida. Mas isso só faz sentido com entrada externa, não com chamadas de outras partes do programa.


Edit: Algumas outras respostas afirmam que você está fazendo programação defensiva . Discordo. Programação defensiva significa que você não confia automaticamente na entrada para ser válida. Portanto, a validação de parâmetros (como acima) é uma técnica de programação defensiva, mas isso não significa que você deva alterar entradas inesperadas ou inválidas ao adivinhar. A abordagem defensiva robusta é validar a entrada e, em seguida, lançar uma exceção em caso de entrada inesperada ou inválida.

JacquesB
fonte
1

Devo adicionar código redundante agora, caso seja necessário no futuro?

Você não deve adicionar código redundante a qualquer momento.

Você não deve adicionar código necessário apenas no futuro.

Você deve garantir que seu código se comporte bem, não importa o que aconteça.

A definição de "comporte-se bem" depende de suas necessidades. Uma técnica que gosto de usar são as exceções de "paranóia". Se tenho 100% de certeza de que um determinado caso nunca pode acontecer, continuo programando uma exceção, mas o faço de uma maneira que a) diga claramente a todos que nunca espero que isso aconteça eb) seja claramente exibido e registrado e portanto, não leva à corrupção crescente mais tarde.

Exemplo de pseudocódigo:

file = File.open(">", "bla")  or raise "Paranoia: cannot open file 'bla'"

file.write("xytz") or raise "Paranoia: disk full?"

file.close()  or raise "Paranoia: huh?!?!?"

Isso comunica claramente que eu tenho 100% de certeza de que sempre posso abrir, gravar ou fechar o arquivo, ou seja, não vou ao ponto de criar um tratamento de erro elaborado. Mas se (não: quando) não consigo abrir o arquivo, meu programa ainda falhará de maneira controlada.

Obviamente, a interface do usuário não exibirá essas mensagens para o usuário, elas serão registradas internamente juntamente com o rastreamento da pilha. Novamente, essas são exceções internas de "Paranoia", que apenas garantem que o código "pare" quando algo inesperado acontecer. Este exemplo é um pouco complicado, na prática, é claro, eu implementaria o tratamento real de erros ao abrir arquivos, pois isso acontece regularmente (nome de arquivo errado, pendrive USB somente leitura, o que for).

Um termo de pesquisa relacionado muito importante seria "falhar rápido", conforme observado em outras respostas e é muito útil para criar software robusto.

AnoE
fonte
-1

Há muitas respostas muito complicadas aqui. Você provavelmente fez essa pergunta porque não se sentiu bem com esse código, mas não sabia ao certo por que ou como corrigi-lo. Portanto, minha resposta é que o problema é muito provável na estrutura do código (como sempre).

Primeiro, o cabeçalho do método:

public static CalendarRow AssignAppointmentToRow(Appointment app, List<CalendarRow> rows)

Atribuir compromisso a que linha? Isso deve ficar claro imediatamente na lista de parâmetros. Sem qualquer conhecimento, eu esperaria que os parâmetros de método para se parecer com isto: (Appointment app, CalendarRow row).

Em seguida, as "verificações de entrada":

//1. Is rows equal to null? - This will be the case if this is the first appointment.
if (rows == null) {
    rows = new List<CalendarRow> ();
}

//2. Is rows empty? - This will be the case if this is the first appointment / some other unknown reason.
if(rows.Count == 0)
{
    rows.Add (new CalendarRow (0));
    rows [0].Appointments.Add (app);
}

Isso é besteira.

  1. check) O responsável pela chamada do método deve apenas garantir que não passe valores não inicializados dentro do método. É responsabilidade do programador (tentar) não ser estúpido.
  2. check) Se eu não levar em conta que a passagem rowspara o método provavelmente está errada (veja o comentário acima), não deve ser responsabilidade do método chamado AssignAppointmentToRowpara manipular linhas de outra maneira que não seja a atribuição do compromisso em algum lugar.

Mas todo o conceito de atribuir compromissos em algum lugar é estranho (a menos que isso faça parte da GUI do código). Seu código parece conter (ou pelo menos tenta) uma estrutura de dados explícita representando um calendário (que é List<CalendarRows><- que deve ser definido como Calendaralgum lugar se você quiser seguir esse caminho, então você passaria Calendar calendarpara o seu método). Se você seguir esse caminho, eu esperaria calendarque fosse preenchido com slots onde você coloca (atribui) os compromissos posteriormente (por exemplo, calendar[month][day] = appointmentseria o código apropriado). Mas você também pode optar por abandonar completamente a estrutura do calendário da lógica principal e apenas ter List<Appointment>onde os Appointmentobjetos contêm atributos.date. E então, se você precisar renderizar um calendário em algum lugar da GUI, poderá criar essa estrutura de 'calendário explícito' imediatamente antes da renderização.

Como não conheço os detalhes do seu aplicativo, talvez isso não seja aplicável a você, mas essas duas verificações (principalmente a segunda) me dizem que algo está errado com a separação de preocupações no seu código.

clima
fonte
-2

Por uma questão de simplicidade, vamos supor que, eventualmente, você precisará desse código em N dias (nem mais tarde nem mais cedo) ou não precisará.

Pseudo-código:

let C_now   = cost of implementing the piece of code now
let C_later = ... later
let C_maint = cost of maintaining the piece of code one day
              (note that it can be negative)
let P_need  = probability that you will need the code after N days

if C_now + C_maint * N < P_need*C_later then implement the code else omit it.

Fatores para C_maint:

  • Ele aprimora o código em geral, tornando-o mais auto-documentado, mais fácil de testar? Se sim, negativo C_maintesperado
  • Aumenta o código (portanto, mais difícil de ler, mais longo para compilar, implementar testes etc.)?
  • Há refatorações / reprojetos pendentes? Se sim, bata C_maint. Este caso requer uma fórmula mais complexa, com mais vezes variáveis ​​como N.

Uma coisa tão grande que apenas pesa o código e pode ser necessária apenas em 2 anos com baixa probabilidade deve ser deixada de lado, mas uma coisinha que também produz asserções úteis e 50% que serão necessárias em 3 meses, deve ser implementada .

Vi0
fonte
Você também precisa levar em consideração o custo dos erros que podem surgir no programa porque você não está rejeitando entradas inválidas. Então, como você estima o custo de possíveis erros difíceis de encontrar?
JacquesB