De onde veio a noção de “apenas um retorno”?

1055

Costumo conversar com programadores que dizem " Não coloque várias declarações de retorno no mesmo método " . Quando peço que eles me digam os motivos, tudo o que recebo é " O padrão de codificação diz isso " ou " É confuso " . Quando eles me mostram soluções com uma única declaração de retorno, o código fica mais feio para mim. Por exemplo:

if (condition)
   return 42;
else
   return 97;

" Isso é feio, você precisa usar uma variável local! "

int result;
if (condition)
   result = 42;
else
   result = 97;
return result;

Como esse inchaço de 50% do código facilita a compreensão do programa? Pessoalmente, acho isso mais difícil, porque o espaço de estado acaba de aumentar por outra variável que poderia ser facilmente evitada.

Claro, normalmente eu apenas escreveria:

return (condition) ? 42 : 97;

Mas muitos programadores evitam o operador condicional e preferem a forma longa.

De onde veio essa noção de "apenas um retorno"? Existe uma razão histórica para a criação desta convenção?

fredoverflow
fonte
2
Isso está um pouco conectado à refatoração da Cláusula Guard. A Cláusula de Guarda stackoverflow.com/a/8493256/679340 adicionará retornos ao início de seus métodos. E isso torna o código muito mais limpo na minha opinião.
Piotr Perak
3
Veio da noção de programação estruturada. Alguns podem argumentar que ter apenas um retorno permite modificar facilmente o código para fazer algo antes de retornar ou depurar facilmente.
Martinkunev
3
Eu acho que o exemplo é um caso bastante simples, onde eu não teria uma opinião forte de uma maneira ou de outra. o ideal de entrada única e saída única é mais para nos afastar de situações malucas, como 15 declarações de retorno e mais dois ramos que não retornam!
Mendota
2
Esse é um dos piores artigos que eu já li. Parece que o autor passa mais tempo fantasiando sobre a pureza de sua OOP do que realmente descobrir como conseguir alguma coisa. As árvores de expressão e avaliação têm valor, mas não quando você pode simplesmente escrever uma função normal.
DeadMG 2/01
3
Você deve remover a condição completamente. A resposta é 42.
cambunctious

Respostas:

1119

"Entrada única, saída única" foi escrita quando a maior parte da programação era feita em linguagem assembly, FORTRAN ou COBOL. Foi amplamente mal interpretado, porque as linguagens modernas não suportam as práticas contra as quais Dijkstra estava advertindo.

"Entrada única" significava "não cria pontos de entrada alternativos para funções". Na linguagem assembly, é claro, é possível inserir uma função em qualquer instrução. FORTRAN suportou várias entradas para funções com a ENTRYinstrução:

      SUBROUTINE S(X, Y)
      R = SQRT(X*X + Y*Y)
C ALTERNATE ENTRY USED WHEN R IS ALREADY KNOWN
      ENTRY S2(R)
      ...
      RETURN
      END

C USAGE
      CALL S(3,4)
C ALTERNATE USAGE
      CALL S2(5)

"Saída única" significava que uma função deveria retornar apenas para um local: a instrução imediatamente após a chamada. Isso não significava que uma função deveria retornar apenas de um lugar. Quando a Programação Estruturada foi escrita, era prática comum para uma função indicar um erro retornando para um local alternativo. FORTRAN apoiou isso através de "retorno alternativo":

C SUBROUTINE WITH ALTERNATE RETURN.  THE '*' IS A PLACE HOLDER FOR THE ERROR RETURN
      SUBROUTINE QSOLVE(A, B, C, X1, X2, *)
      DISCR = B*B - 4*A*C
C NO SOLUTIONS, RETURN TO ERROR HANDLING LOCATION
      IF DISCR .LT. 0 RETURN 1
      SD = SQRT(DISCR)
      DENOM = 2*A
      X1 = (-B + SD) / DENOM
      X2 = (-B - SD) / DENOM
      RETURN
      END

C USE OF ALTERNATE RETURN
      CALL QSOLVE(1, 0, 1, X1, X2, *99)
C SOLUTION FOUND
      ...
C QSOLVE RETURNS HERE IF NO SOLUTIONS
99    PRINT 'NO SOLUTIONS'

Ambas as técnicas eram altamente propensas a erros. O uso de entradas alternativas geralmente deixa algumas variáveis ​​não inicializadas. O uso de retornos alternativos apresentava todos os problemas de uma instrução GOTO, com a complicação adicional de que a condição de ramificação não estava adjacente à ramificação, mas em algum lugar da sub-rotina.

kevin cline
fonte
38
E não se esqueça do código do espaguete . Não era desconhecido que as sub-rotinas saíssem usando um GOTO em vez de um retorno, deixando os parâmetros de chamada da função e o endereço de retorno na pilha. A saída única foi promovida como uma maneira de canalizar pelo menos todos os caminhos de código para uma instrução RETURN.
TMN
2
@ TMN: nos primeiros dias, a maioria das máquinas não tinha uma pilha de hardware. A recursão geralmente não era suportada. Argumentos de sub-rotina e endereço de retorno foram armazenados em locais fixos adjacentes ao código de sub-rotina. O retorno foi apenas um goto indireto.
kevin Cline
5
@ Kevin: Sim, mas de acordo com você, isso nem significa mais o que foi inventado. (Na verdade, tenho quase certeza de que Fred pediu a preferência pela interpretação atual de "Saída Única".) Além disso, C já constexistia antes de muitos dos usuários nascerem, portanto, não há mais necessidade de constantes maiúsculas. mesmo em C. Mas Java preservados todos os maus hábitos antigos C .
SBI
3
Então, as exceções violam essa interpretação da saída única? (Ou seu primo mais primitivo, setjmp/longjmp?)
Mason Wheeler
2
Embora a operação tenha perguntado sobre a interpretação atual do retorno único, essa resposta é a que tem as raízes mais históricas. Não há sentido em usar um único retorno como regra , a menos que você queira que seu idioma corresponda à grandiosidade do VB (não do .NET). Lembre-se de usar também a lógica booleana de curto-circuito.
acelent
912

Essa noção de entrada única, saída única (SESE) vem de idiomas com gerenciamento explícito de recursos , como C e assembly. Em C, códigos como este vazarão recursos:

void f()
{
  resource res = acquire_resource();  // think malloc()
  if( f1(res) )
    return; // leaks res
  f2(res);
  release_resource(res);  // think free()
}

Nesses idiomas, você basicamente tem três opções:

  • Replicar o código de limpeza.
    Ugh. Redundância é sempre ruim.

  • Use a gotopara pular para o código de limpeza.
    Isso requer que o código de limpeza seja a última coisa na função. (E é por isso que alguns argumentam que gototem seu lugar. E de fato - em C.)

  • Introduzir uma variável local e manipular o fluxo de controle através disso.
    A desvantagem é que o fluxo de controle manipulados através de sintaxe (pense break, return, if, while) é muito mais fácil de seguir do que o fluxo de controle manipulado através do estado de variáveis (porque essas variáveis não têm estado quando você olha para o algoritmo).

Na montagem, é ainda mais estranho, porque você pode pular para qualquer endereço de uma função ao chamá-la, o que efetivamente significa que você tem um número quase ilimitado de pontos de entrada para qualquer função. (Às vezes, isso é útil. Esses thunks são uma técnica comum para os compiladores implementarem o thisajuste do ponteiro necessário para chamar virtualfunções em cenários de herança múltipla em C ++.)

Quando você precisa gerenciar recursos manualmente, explorar as opções de entrada ou saída de uma função em qualquer lugar leva a um código mais complexo e, portanto, a erros. Portanto, surgiu uma escola de pensamento que propagou o SESE, a fim de obter um código mais limpo e menos erros.


No entanto, quando um idioma apresenta exceções, (quase) qualquer função pode ser encerrada prematuramente (quase) a qualquer momento, portanto, você precisa prever o retorno prematuro de qualquer maneira. (Eu acho que finallyé usado principalmente para isso em Java e using(ao implementar IDisposable, finallycaso contrário) em C #; o C ++ emprega RAII .) Depois de fazer isso, você não pode deixar de se limpar devido a uma returndeclaração anterior, então o que provavelmente é o argumento mais forte a favor do SESE desapareceu.

Isso deixa legibilidade. Obviamente, uma função 200 LoC com meia dúzia de returninstruções espalhadas aleatoriamente sobre ele não é um bom estilo de programação e não cria código legível. Mas essa função também não seria fácil de entender sem esses retornos prematuros.

Nos idiomas em que os recursos não são ou não devem ser gerenciados manualmente, há pouco ou nenhum valor em aderir à antiga convenção SESE. OTOH, como argumentei acima, o SESE geralmente torna o código mais complexo . É um dinossauro que (exceto C) não se encaixa bem na maioria das línguas atuais. Em vez de ajudar a compreensibilidade do código, ele dificulta.


Por que os programadores Java mantêm isso? Eu não sei, mas do meu POV (externo), o Java pegou muitas convenções de C (onde elas fazem sentido) e as aplicou ao seu mundo OO (onde são inúteis ou totalmente ruins), onde agora se apega a eles, não importa quais sejam os custos. (Como a convenção para definir todas as suas variáveis ​​no início do escopo.)

Os programadores aderem a todos os tipos de notações estranhas por razões irracionais. (Declarações estruturais profundamente aninhadas - "pontas de flechas" - eram, em idiomas como Pascal, uma vez vistas como um belo código.) A aplicação de raciocínio lógico puro a isso parece falhar em convencer a maioria deles a se desviar de seus modos estabelecidos. A melhor maneira de mudar esses hábitos é provavelmente ensinando-os desde cedo a fazer o que é melhor, não o que é convencional. Você, sendo professor de programação, tem na sua mão.:)

sbi
fonte
52
Direito. Em Java, o código de limpeza pertence a finallycláusulas em que é executado independentemente de returns ou exceções anteriores.
dan04
15
@ dan04 no Java 7, você nem precisa finallymais do tempo.
R. Martinho Fernandes
93
@ Steven: Claro que você pode demonstrar isso! De fato, você pode mostrar código complexo e complicado com qualquer recurso que também possa ser mostrado para tornar o código mais simples e fácil de entender. Tudo pode ser abusado. O objetivo é escrever código para que seja mais fácil de entender , e quando isso envolve jogar o SESE pela janela, assim seja, e condenar os velhos hábitos que se aplicavam a diferentes idiomas. Mas eu não hesitaria em controlar a execução por variáveis, se eu achasse que isso tornava o código mais fácil de ler. Só que não me lembro de ter visto esse código em quase duas décadas.
SBI
21
@Karl: De fato, é uma falha grave de linguagens de GC como Java que elas evitam que você precise limpar um recurso, mas falha com todos os outros. (C ++ resolve esse problema para todos os recursos usando RAII ). Mas eu não estava mesmo falando de apenas memória (eu só colocar malloc()e free()em um comentário, por exemplo), eu estava falando sobre os recursos em geral. Eu também não estava sugerindo que a GC resolveria esses problemas. (Eu mencionei C ++, que não possui GC pronto para uso.) Pelo que entendi, em Java finallyé usado para resolver esse problema.
SBI
10
@sbi: Mais importante para uma função (procedimento, método etc.) do que não ter mais do que uma página é a função de ter um contrato claramente definido; se não está fazendo algo claro porque foi cortado para satisfazer uma restrição arbitrária de comprimento, isso é Ruim. Programar significa jogar forças diferentes, às vezes conflitantes, umas contra as outras.
Donal Fellows
81

Por um lado, as instruções de retorno único facilitam o log, bem como as formas de depuração que dependem do log. Lembro-me de muitas vezes que tive que reduzir a função em retorno único apenas para imprimir o valor de retorno em um único ponto.

  int function() {
     if (bidi) { print("return 1"); return 1; }
     for (int i = 0; i < n; i++) {
       if (vidi) { print("return 2"); return 2;}
     }
     print("return 3");
     return 3;
  }

Por outro lado, você pode refatorar isso nessas function()chamadas _function()e registrar o resultado.

perreal
fonte
31
Eu também acrescentaria que isso facilita a depuração, porque você só precisa definir um ponto de interrupção para capturar todas as saídas * da função. Eu acredito que alguns IDEs permitem que você coloque um ponto de interrupção na chave estreita da função para fazer a mesma coisa. (* a menos que você chame exit)
Skizz
3
Por um motivo semelhante, também facilita estender (adicionar a) a função, pois sua nova funcionalidade não precisa ser inserida antes de cada retorno. Digamos que você precise atualizar um log com o resultado da chamada de função, por exemplo.
JeffSahol
63
Honestamente, se eu estivesse mantendo esse código, preferiria ter uma definição sensata _function(), com returns nos locais apropriados, e um invólucro nomeado function()que lida com o log externo, do que ter uma única function()com lógica contorcida para fazer com que todos os retornos se ajustassem em uma única saída -point apenas para que eu possa inserir uma declaração adicional antes desse ponto.
Ruakh
11
Em alguns depuradores (MSVS), você pode colocar o ponto de interrupção na última chave de fechamento
Abyx
6
printing! = depuração. Isso não é argumento.
Piotr Perak
53

"Entrada única, saída única" originou-se com a revolução da programação estruturada do início dos anos 1970, iniciada pela carta de Edsger W. Dijkstra ao editor " Declaração GOTO considerada prejudicial ". Os conceitos por trás da programação estruturada foram detalhados no livro clássico "Structured Programming" de Ole Johan-Dahl, Edsger W. Dijkstra e Charles Anthony Richard Hoare.

A "declaração GOTO considerada prejudicial" é leitura obrigatória, ainda hoje. A "Programação Estruturada" é datada, mas ainda muito, muito gratificante, e deve estar no topo da lista de "Devemos Ler" de qualquer desenvolvedor, muito acima de qualquer coisa, por exemplo, de Steve McConnell. (A seção de Dahl expõe os conceitos básicos de classes no Simula 67, que são a base técnica para classes em C ++ e toda a programação orientada a objetos.)

John R. Strohm
fonte
6
O artigo foi escrito alguns dias antes de C, quando os GOTO eram muito usados. Eles não são o inimigo, mas esta resposta está definitivamente correta. Uma declaração de retorno que não está no final de uma função é efetivamente um goto.
usar o seguinte comando
31
O artigo também foi escrito nos dias em que gotopodia literalmente ir a qualquer lugar , como em algum ponto aleatório de outra função, ignorando qualquer noção de procedimentos, funções, pilha de chamadas, etc. Nenhuma linguagem sadia permite isso hoje em dia goto. C's setjmp/ longjmpé o único caso semi-excepcional que conheço, e mesmo isso requer cooperação de ambos os lados. (Semi-irônico que eu usei a palavra "excepcional" lá, considerando que as exceções fazem quase a mesma coisa ...) Basicamente, o artigo desencoraja uma prática que há muito está morta.
cHao 9/11/11
5
A partir do último parágrafo da "Declaração Goto considerada prejudicial": "em [2] Guiseppe Jacopini parece ter provado o supérfluo (lógico) da declaração go. O exercício de traduzir um diagrama de fluxo arbitrário mais ou menos mecanicamente em um salto menos um, no entanto, não é recomendado . Portanto, não se pode esperar que o diagrama de fluxo resultante seja mais transparente que o original. "
hugomg 9/11/11
10
O que isso tem a ver com a questão? Sim, o trabalho de Dijkstra acabou levando às línguas SESE, e daí? O trabalho de Babbage também. E talvez você deva reler o artigo se achar que ele diz algo sobre ter vários pontos de saída em uma função. Porque não.
jalf
10
@ John, você parece estar tentando responder à pergunta sem realmente responder. É uma boa lista de leitura, mas você não citou nem parafraseou nada para justificar sua afirmação de que este ensaio e livro têm algo a dizer sobre a preocupação do solicitante. De fato, fora dos comentários, você não disse nada substancial sobre a questão. Considere expandir esta resposta.
Shog9
35

Sempre é fácil vincular o Fowler.

Um dos principais exemplos contrários ao SESE são as cláusulas de guarda:

Substituir condicional aninhado por cláusulas de guarda

Use cláusulas de guarda para todos os casos especiais

double getPayAmount() {
    double result;
    if (_isDead) result = deadAmount();
    else {
        if (_isSeparated) result = separatedAmount();
        else {
            if (_isRetired) result = retiredAmount();
            else result = normalPayAmount();
        };
    }
return result;
};  

                                                                                                         http://www.refactoring.com/catalog/arrow.gif

double getPayAmount() {
    if (_isDead) return deadAmount();
    if (_isSeparated) return separatedAmount();
    if (_isRetired) return retiredAmount();
    return normalPayAmount();
};  

Para obter mais informações, consulte a página 250 de Refatoração ...

Pieter B
fonte
11
Outro exemplo ruim: poderia ser facilmente corrigido com else-ifs.
Jack
1
Seu exemplo não é justo. Que tal: double getPayAmount () {double ret = normalPayAmount (); if (_isDead) ret = deadAmount (); if (_isSeparated) ret = selectedAmount (); if (_isRetired) ret = retiredAmount (); return ret; };
Charbel
6
@Charbel Isso não é a mesma coisa. Se _isSeparatede _isRetiredpodem ambos ser verdadeiros (e por que isso não seria possível?), Você retorna a quantia errada.
hvd 22/01
2
@Konchog " condicionais aninhados irá proporcionar um melhor tempo de execução do que as cláusulas de guarda " Esta majorly precisa de uma citação. Tenho minhas dúvidas de que isso seja realmente confiável. Nesse caso, por exemplo, como os retornos iniciais diferem do curto-circuito lógico em termos de código gerado? Mesmo que isso importasse, não consigo imaginar um caso em que a diferença fosse mais do que uma lasca infinitesimal. Então, você está aplicando a otimização prematura, tornando o código menos legível, apenas para satisfazer algum ponto teórico não comprovado sobre o que você acha que leva a um código um pouco mais rápido. Não fazemos isso aqui #
underscore_d
1
@underscore_d, você está certo. depende muito do compilador, mas pode ocupar mais espaço. Observe os dois pseudo-assemblies e é fácil ver por que as cláusulas de guarda vêm de linguagens de alto nível. Teste "A" (1); branch_fail end; teste (2); branch_fail end; teste (3); branch_fail end; {CODE} end: retorno; Teste "B" (1); branch_good next1; Retorna; next1: teste (2); branch_good next2; Retorna; next2: teste (3); branch_good next3; Retorna; next3: {CODE} return;
21918 Konchog
11

Eu escrevi um post sobre este tópico há um tempo.

A conclusão é que essa regra vem da era dos idiomas que não têm coleta de lixo ou manipulação de exceção. Não há estudo formal que mostre que essa regra leva a um melhor código nas linguagens modernas. Fique à vontade para ignorá-lo sempre que isso levar a um código mais curto ou mais legível. O pessoal do Java que insiste nisso é cego e inquestionável, seguindo uma regra desatualizada e sem sentido.

Esta pergunta também foi feita no Stackoverflow

Anthony
fonte
Ei, não consigo mais acessar esse link. Você tem uma versão hospedada em algum lugar ainda acessível?
precisa saber é o seguinte
Oi, QPT, bom local. Trouxe o post do blog e atualizei o URL acima. Deve ligar agora!
Anthony
Mas há mais do que isso. É muito mais fácil gerenciar o tempo de execução preciso usando o SESE. Condicionais aninhados geralmente podem ser refatorados com uma opção de qualquer maneira. Não se trata apenas de saber se existe ou não um valor de retorno.
precisa saber é o seguinte
Se você pretende afirmar que não há um estudo formal para apoiá-lo, convém vincular a um que vá contra.
Mehrdad
Mehrdad, se houver um estudo formal para apoiá-lo, mostre-o. Isso é tudo. Insistir em evidências contra está mudando o ônus da prova.
Anthony
7

Um retorno facilita a refatoração. Tente executar o "método de extração" no corpo interno de um loop for que contenha retorno, interrupção ou continuação. Isso falhará quando você interromper seu fluxo de controle.

O ponto é: acho que ninguém está fingindo escrever um código perfeito. Portanto, o código está regularmente sob refatoração para ser "aprimorado" e estendido. Portanto, meu objetivo seria manter meu código o mais amigável possível para a refatoração.

Muitas vezes, enfrento o problema de reformular completamente as funções se elas contiverem interruptores de fluxo de controle e se desejar adicionar apenas pouca funcionalidade. Isso é muito suscetível a erros, pois você altera todo o fluxo de controle em vez de introduzir novos caminhos para aninhamentos isolados. Se você tiver apenas um retorno único no final ou se usar guardas para sair de um loop, é claro que terá mais aninhamento e mais código. Mas você obtém recursos de refatoração suportados pelo compilador e pelo IDE.

oopexpert
fonte
O mesmo se aplica às variáveis. Quais são a alternativa ao uso de construções de controle-fluxo como retorno antecipado.
Deduplicator
As variáveis ​​geralmente não impedem que você divida seu código em partes de uma maneira que o fluxo de controle existente seja preservado. Tente "extrair método". Os IDEs só podem executar refatorações de preservação de fluxo de controle, pois não conseguem derivar semântica do que você escreveu.
Oopexpert 17/10/19
5

Considere o fato de que várias declarações de retorno são equivalentes a ter GOTOs em uma única declaração de retorno. Este é o mesmo caso com instruções de quebra. Assim, alguns, como eu, os consideram GOTO para todos os efeitos.

No entanto, eu não considero esses tipos de GOTO prejudiciais e não hesitarei em usar um GOTO real no meu código se encontrar uma boa razão para isso.

Minha regra geral é que os GOTO são apenas para controle de fluxo. Eles nunca devem ser usados ​​para loop, e você nunca deve GOTO 'para cima' ou 'para trás'. (que é como as quebras / devoluções funcionam)

Como outros já mencionaram, é necessário ler a declaração GOTO considerada prejudicial.
No entanto, lembre-se de que isso foi escrito em 1970, quando as GOTOs eram muito usadas. Nem todo GOTO é prejudicial e eu não desencorajaria o uso deles, desde que você não os use em vez de construções normais, mas, no caso estranho, o uso de construções normais seria altamente inconveniente.

Acho que usá-los em casos de erro em que você precisa escapar de uma área devido a uma falha que nunca deve ocorrer em casos normais úteis às vezes. Mas você também deve considerar colocar esse código em uma função separada para poder retornar mais cedo, em vez de usar um GOTO ... mas às vezes isso também é inconveniente.

usuário606723
fonte
6
Todas as construções estruturadas que substituem o gotos são implementadas em termos de goto. Por exemplo, loops, "se" e "caso". Isso não os torna ruins - de fato, o oposto. Além disso, são "intenções e propósitos".
Anthony
Toque, mas isso não difere do meu ponto ... Isso apenas torna minha explicação um pouco errada. Ah bem.
user606723
O GOTO deve estar sempre bem, desde que (1) o alvo esteja dentro do mesmo método ou função e (2) a direção seja encaminhada no código (pule algum código) e (3) o alvo não esteja dentro de outra estrutura aninhada (por exemplo, GOTO do meio do caso-caso para o meio do caso-caso). Se você seguir essas regras, todos os usos indevidos do GOTO terão um cheiro muito forte, tanto visual quanto logicamente.
Mikko Rantalainen
3

Complexidade ciclomática

Eu já vi o SonarCube usar várias declarações de retorno para determinar a complexidade ciclomática. Portanto, quanto mais as declarações de retorno, maior a complexidade ciclomática

Alteração de tipo de retorno

Retornos múltiplos significam que precisamos mudar em vários locais da função quando decidimos alterar nosso tipo de retorno.

Saída múltipla

É mais difícil depurar, pois a lógica precisa ser cuidadosamente estudada em conjunto com as instruções condicionais para entender o que causou o valor retornado.

Solução Refatorada

A solução para várias instruções de retorno é substituí-las pelo polimorfismo e ter um único retorno após a resolução do objeto de implementação necessário.

Classificador
fonte
3
Mover de vários retornos para definir o valor de retorno em vários locais não elimina a complexidade ciclomática, apenas unifica o local de saída. Todos os problemas que a complexidade ciclomática pode indicar no contexto dado permanecem. "É mais difícil depurar, pois a lógica precisa ser cuidadosamente estudada em conjunto com as instruções condicionais para entender o que causou o valor retornado" Novamente, a lógica não muda ao unificar o retorno. Se você precisar estudar cuidadosamente o código para entender como ele funciona, ele precisará ser refatorado, ponto final.
WillD 29/03