Eficiência de retorno prematuro em uma função

97

Essa é uma situação que encontro com frequência como um programador inexperiente e estou pensando sobre a qual, especialmente para um projeto meu ambicioso e que exige muita velocidade, estou tentando otimizar. Para as principais linguagens semelhantes a C (C, objC, C ++, Java, C #, etc) e seus compiladores usuais, essas duas funções serão executadas com a mesma eficiência? Existe alguma diferença no código compilado?

void foo1(bool flag)
{
    if (flag)
    {
        //Do stuff
        return;
    }

    //Do different stuff
}

void foo2(bool flag)
{
    if (flag)
    {
        //Do stuff
    }
    else
    {
        //Do different stuff
    }
}

Basicamente, existe um bônus / penalidade de eficiência direta ao breakiniciar ou returniniciar o processo antes do tempo? Como o stackframe está envolvido? Existem casos especiais otimizados? Existem fatores (como inlining ou o tamanho de "Do stuff") que podem afetar isso significativamente?

Eu sempre sou um defensor de legibilidade melhorada em vez de otimizações menores (vejo muito foo1 com validação de parâmetro), mas isso surge com tanta frequência que eu gostaria de deixar de lado todas as preocupações de uma vez por todas.

E estou ciente das armadilhas da otimização prematura ... ugh, essas são algumas memórias dolorosas.

EDIT: Aceitei uma resposta, mas a resposta de EJP explica de forma bastante sucinta porque o uso de a returné praticamente desprezível (em assembly, o returncria um 'branch' para o final da função, que é extremamente rápido. O branch altera o registrador do PC e também pode afetar o cache e o pipeline, que são bem minúsculos.) Para este caso em particular, isso literalmente não faz diferença, porque tanto o if/elsequanto o returncriam a mesma ramificação até o final da função.

Philip Guin
fonte
22
Não acho que esse tipo de coisa terá um impacto perceptível no desempenho. Basta escrever um pequeno teste e ver você mesmo. Imo, a primeira variante é melhor, pois você não obtém aninhamento desnecessário, o que melhora a legibilidade
SirVaulterScoff,
10
@SirVaulterScott, a menos que os dois casos sejam simétricos de alguma forma, caso em que você deseja realçar a simetria colocando-os no mesmo nível de recuo.
luqui
3
SirVaulterScoff: +1 para reduzir aninhamento desnecessário
fjdumont
11
Legibilidade >>> Micro otimizações. Faça da maneira que fizer mais sentido para o wetware que fará a manutenção. Em um nível de código de máquina, essas duas estruturas são idênticas quando alimentadas até mesmo em um compilador bastante burro. Um compilador otimizado apagará qualquer aparência de vantagem de velocidade entre os dois.
SplinterReality,
12
Não otimize seu projeto "intensivo em velocidade" preocupando-se com coisas assim. Crie o perfil de seu aplicativo para descobrir onde ele está realmente lento - se é realmente muito lento quando você termina de fazê-lo funcionar. Você quase certamente não consegue adivinhar o que está realmente retardando isso.
blueshift

Respostas:

92

Não há diferença alguma:

=====> cat test_return.cpp
extern void something();
extern void something2();

void test(bool b)
{
    if(b)
    {
        something();
    }
    else
        something2();
}
=====> cat test_return2.cpp
extern void something();
extern void something2();

void test(bool b)
{
    if(b)
    {
        something();
        return;
    }
    something2();
}
=====> rm -f test_return.s test_return2.s
=====> g++ -S test_return.cpp 
=====> g++ -S test_return2.cpp 
=====> diff test_return.s test_return2.s
=====> rm -f test_return.s test_return2.s
=====> clang++ -S test_return.cpp 
=====> clang++ -S test_return2.cpp 
=====> diff test_return.s test_return2.s
=====> 

O que significa nenhuma diferença no código gerado, mesmo sem otimização em dois compiladores

Dani
fonte
59
Ou melhor: existe pelo menos uma versão de um determinado compilador que gera o mesmo código para as duas versões.
UncleZeiv
11
@UncleZeiv - a maioria, senão todos, os compiladores traduzirão a fonte em um modelo de gráfico de fluxo de execução. É difícil imaginar uma implementação sã que fornecesse gráficos de fluxo significativamente diferentes para esses dois exemplos. A única diferença que você pode ver é que os dois diferentes fazer algo são trocados - e até mesmo isso pode ser desfeito em muitas implementações para otimizar a previsão de branch ou para algum outro problema em que a plataforma determina a ordem preferida.
Steve314,
6
@ Steve314, claro, eu estava apenas
criticando
@UncleZeiv: também testado no clang e o mesmo resultado
Dani,
Não entendo. Parece claro que something()sempre será executado. Na pergunta original, OP tem Do stuffe Do diffferent stuffdependendo da bandeira. Não estou certo de que o código gerado será o mesmo.
Luc M
65

A resposta curta é: nenhuma diferença. Faça um favor a si mesmo e pare de se preocupar com isso. O compilador de otimização quase sempre é mais inteligente do que você.

Concentre-se na legibilidade e manutenção.

Se você quiser ver o que acontece, construa-os com otimizações e observe a saída do assembler.

turno azul
fonte
8
@Philip: E faça um favor a todo mundo também e pare de se preocupar com isso. O código que você escreve será lido e mantido por outras pessoas também (e mesmo se você escrever que nunca será lido por outras pessoas, você ainda desenvolverá hábitos que influenciarão outro código que você escreve que será lido por outras pessoas). Sempre escreva o código para ser o mais fácil de entender possível.
hlovdal
8
Os otimizadores não são mais espertos do que você !!! Eles só são mais rápidos em decidir onde o impacto não importa muito. Onde realmente importa, você certamente com alguma experiência otimizará melhor do que o compilador.
johannes,
10
@johannes Deixe-me discordar. O compilador não mudará seu algoritmo para um melhor, mas faz um trabalho incrível em reordenar instruções para atingir a eficiência máxima do pipeline e outras coisas não tão triviais para loops (fissão, fusão, etc.) que mesmo um programador experiente não pode decidir o que é melhor a priori, a menos que ele tenha um conhecimento íntimo da arquitetura da CPU.
Fortran
3
@johannes - para esta pergunta, você pode assumir que sim. Além disso, em geral, você pode ocasionalmente ser capaz de otimizar melhor do que o compilador em alguns casos especiais, mas isso requer um bom conhecimento especializado atualmente - o caso normal é que o otimizador aplique a maioria das otimizações que você possa imaginar e o faça sistematicamente, não apenas em alguns casos especiais. WRT esta questão, o compilador provavelmente construirá precisamente o mesmo gráfico de fluxo de execução para ambos os formulários. Escolher um algoritmo melhor é um trabalho humano, mas a otimização no nível do código quase sempre é uma perda de tempo.
Steve314,
4
Eu concordo e discordo disso. Existem casos em que o compilador não pode saber que algo é equivalente a outra coisa. Você sabia que geralmente é muito mais rápido do x = <some number>que if(<would've changed>) x = <some number>ramos desnecessários podem realmente machucar. Por outro lado, a menos que isso esteja dentro do loop principal de uma operação extremamente intensiva, eu também não me preocuparia com isso.
user606723
28

Respostas interessantes: Embora eu concorde com todas elas (até agora), há possíveis conotações para essa questão que até agora foram completamente desconsideradas.

Se o exemplo simples acima for estendido com a alocação de recursos e, em seguida, a verificação de erros com uma possível liberação de recursos resultante, o quadro pode mudar.

Considere a abordagem ingênua que os iniciantes podem adotar:

int func(..some parameters...) {
  res_a a = allocate_resource_a();
  if (!a) {
    return 1;
  }
  res_b b = allocate_resource_b();
  if (!b) {
    free_resource_a(a);
    return 2;
  }
  res_c c = allocate_resource_c();
  if (!c) {
    free_resource_b(b);
    free_resource_a(a);
    return 3;
  }

  do_work();

  free_resource_c(c);
  free_resource_b(b);
  free_resource_a(a);

  return 0;
}

O que foi dito acima representaria uma versão extrema do estilo de retorno prematuro. Observe como o código se torna muito repetitivo e não pode ser mantido ao longo do tempo, quando sua complexidade aumenta. Hoje em dia, as pessoas podem usar o tratamento de exceções para capturá-los.

int func(..some parameters...) {
  res_a a;
  res_b b;
  res_c c;

  try {
    a = allocate_resource_a(); # throws ExceptionResA
    b = allocate_resource_b(); # throws ExceptionResB
    c = allocate_resource_c(); # throws ExceptionResC
    do_work();
  }  
  catch (ExceptionBase e) {
    # Could use type of e here to distinguish and
    # use different catch phrases here
    # class ExceptionBase must be base class of ExceptionResA/B/C
    if (c) free_resource_c(c);
    if (b) free_resource_b(b);
    if (a) free_resource_a(a);
    throw e
  }
  return 0;
}

Philip sugeriu, após olhar o exemplo goto abaixo, usar um switch / case break-less dentro do bloco catch acima. Pode-se alternar (typeof (e)) e, em seguida, falhar nas free_resourcex()chamadas, mas isso não é trivial e precisa de consideração de design . E lembre-se de que um switch / case sem quebras é exatamente como o goto com rótulos encadeados abaixo ...

Como Mark B apontou, em C ++ é considerado um bom estilo seguir o princípio de Aquisição de Recursos é Inicialização , RAII em resumo. A essência do conceito é usar a instanciação de objetos para adquirir recursos. Os recursos são então liberados automaticamente assim que os objetos saem do escopo e seus destruidores são chamados. Para recursos interdependentes, deve-se tomar cuidado especial para garantir a ordem correta de desalocação e projetar os tipos de objetos de forma que os dados necessários estejam disponíveis para todos os destruidores.

Ou em dias de pré-exceção pode fazer:

int func(..some parameters...) {
  res_a a = allocate_resource_a();
  res_b b = allocate_resource_b();
  res_c c = allocate_resource_c();
  if (a && b && c) {   
    do_work();
  }  
  if (c) free_resource_c(c);
  if (b) free_resource_b(b);
  if (a) free_resource_a(a);

  return 0;
}

Mas este exemplo simplificado tem várias desvantagens: Ele pode ser usado apenas se os recursos alocados não dependerem uns dos outros (por exemplo, não poderia ser usado para alocar memória, abrir um filehandle e, em seguida, ler dados do manipulador para a memória ) e não fornece códigos de erro individiais e distinguíveis como valores de retorno.

Para manter o código rápido (!), Compacto e facilmente legível e extensível, Linus Torvalds impôs um estilo diferente para o código do kernel que lida com recursos, mesmo usando o infame goto de uma forma que faz sentido :

int func(..some parameters...) {
  res_a a;
  res_b b;
  res_c c;

  a = allocate_resource_a() || goto error_a;
  b = allocate_resource_b() || goto error_b;
  c = allocate_resource_c() || goto error_c;

  do_work();

error_c:
  free_resource_c(c);
error_b:
  free_resource_b(b);
error_a:
  free_resource_a(a);

  return 0;
}

A essência da discussão nas listas de discussão do kernel é que a maioria dos recursos de linguagem que são "preferidos" sobre a instrução goto são gotos implícitos, como if / else enormes, semelhantes a árvore, manipuladores de exceção, instruções de loop / break / continue, etc. . E os goto's no exemplo acima são considerados ok, uma vez que estão saltando apenas uma pequena distância, têm rótulos claros e liberam o código de outras bagunças para acompanhar as condições de erro. Esta questão também foi discutida aqui no stackoverflow .

No entanto, o que está faltando no último exemplo é uma boa maneira de retornar um código de erro. Eu estava pensando em adicionar um result_code++após cada free_resource_x()chamada e retornar esse código, mas isso compensa alguns dos ganhos de velocidade do estilo de codificação acima. E é difícil retornar 0 em caso de sucesso. Talvez eu seja apenas sem imaginação ;-)

Então, sim, eu acho que há uma grande diferença na questão de codificar retornos prematuros ou não. Mas também acho que é aparente apenas em códigos mais complicados, que são mais difíceis ou impossíveis de reestruturar e otimizar para o compilador. O que geralmente acontece quando a alocação de recursos entra em ação.

cfi
fonte
1
Uau, muito interessante. Posso definitivamente avaliar a impossibilidade de manutenção dessa abordagem ingênua. No entanto, como o tratamento de exceções melhoraria nesse caso específico? Como um catchcontendo uma switchinstrução sem interrupção no código de erro?
Philip Guin
@Philip Adicionado exemplo básico de tratamento de exceções. Observe que apenas o goto tem uma possibilidade de fall-through. Sua opção proposta (typeof (e)) ajudaria, mas não é trivial e precisa de consideração de design . E lembre-se de que um switch / case sem interrupções é exatamente como o goto com etiquetas encadeadas ;-)
cfi
+1 esta é a resposta correta para C / C ++ (ou qualquer linguagem que requer liberação manual de memória). Pessoalmente, não gosto da versão com vários rótulos. Na minha empresa anterior, era sempre "goto fin" (era uma empresa francesa). Por fim, desalocaríamos qualquer memória, e esse era o único uso de goto que passaria na revisão do código.
Kip
1
Observe que em C ++ você não faria nenhuma dessas abordagens, mas usaria RAII para garantir que os recursos sejam limpos adequadamente.
Mark B
12

Mesmo que isso não seja exatamente uma resposta, um compilador de produção será muito melhor em otimizar do que você. Eu favoreceria a legibilidade e manutenção sobre esses tipos de otimizações.

Lou
fonte
9

Para ser mais específico sobre isso, o returnserá compilado em uma ramificação ao final do método, onde haverá uma RETinstrução ou o que quer que seja. Se você deixá-lo de fora, o final do bloco antes do elseserá compilado em uma ramificação até o final do elsebloco. Então você pode ver que neste caso específico não faz nenhuma diferença.

Marquês de Lorne
fonte
Peguei vocês. Na verdade, acho que isso responde minha pergunta de forma bastante sucinta; Acho que é literalmente apenas uma adição de registro, o que é bastante insignificante (a menos que talvez você esteja fazendo programação de sistemas, e mesmo assim ...) Vou dar uma menção honrosa a isso.
Philip Guin
@Philip qual adição de registro? Não há nenhuma instrução extra no caminho.
Marquês de Lorne
Bem, ambos teriam acréscimos registrados. Isso é tudo que um ramo de montagem é, não é? Uma adição ao contador do programa? Eu posso estar errado aqui.
Philip Guin
1
@Philip Não, uma ramificação de montagem é uma ramificação de montagem. Isso afeta o PC, é claro, mas poderia ser recarregado completamente e também tem efeitos colaterais no processador em relação ao pipeline, caches, etc.
Marquês de Lorne,
4

Se você realmente deseja saber se há uma diferença no código compilado para seu compilador e sistema em particular, você terá que compilar e examinar o assembly por conta própria.

No entanto, no grande esquema das coisas, é quase certo que o compilador pode otimizar melhor do que o seu ajuste fino e, mesmo que não possa, é muito improvável que realmente importe para o desempenho do seu programa.

Em vez disso, escreva o código da maneira mais clara para humanos lerem e manterem, e deixe o compilador fazer o que ele faz de melhor: gerar o melhor assembly possível a partir de sua fonte.

Mark B
fonte
4

Em seu exemplo, o retorno é perceptível. O que acontece com a pessoa que está depurando quando o retorno é uma ou duas páginas acima / abaixo, onde // coisas diferentes ocorrem? Muito mais difícil de encontrar / ver quando há mais código.

void foo1(bool flag)
{
    if (flag)
    {
        //Do stuff
        return;
    }

    //Do different stuff
}

void foo2(bool flag)
{
    if (flag)
    {
        //Do stuff
    }
    else
    {
        //Do different stuff
    }
}
PCPGMR
fonte
Obviamente, uma função não deve ter mais de uma (ou mesmo duas) páginas. Mas o aspecto de depuração ainda não foi abordado em nenhuma das outras respostas. Ponto alcançado!
cfi
3

Eu concordo totalmente com o blueshift: legibilidade e manutenção primeiro !. Mas se você estiver realmente preocupado (ou apenas quiser saber o que seu compilador está fazendo, o que definitivamente é uma boa ideia no longo prazo), você deve procurar por si mesmo.

Isso significará usar um descompilador ou examinar a saída do compilador de baixo nível (por exemplo, idioma do assembly). Em C # ou em qualquer linguagem .Net, as ferramentas documentadas aqui fornecerão o que você precisa.

Mas, como você mesmo observou, essa provavelmente é uma otimização prematura.

Sr. Putty
fonte
1

From Clean Code: A Handbook of Agile Software Craftsmanship

Argumentos de sinalização são feios. Passar um booleano em uma função é uma prática verdadeiramente terrível. Imediatamente complica a assinatura do método, proclamando em voz alta que essa função faz mais de uma coisa. Ele faz uma coisa se o sinalizador for verdadeiro e outro se o sinalizador for falso!

foo(true);

no código fará com que o leitor navegue até a função e perca tempo lendo foo (sinalizador booleano)

Uma base de código melhor estruturada oferecerá melhores oportunidades para otimizar o código.

Yuan
fonte
Estou usando isso apenas como exemplo. O que está sendo passado para a função pode ser um int, double, uma classe, o que você quiser, não é realmente o cerne do problema.
Philip Guin
A pergunta que você fez é sobre como fazer uma troca dentro de sua função, na maioria das vezes, é um cheiro de código. Isso pode ser alcançado de várias maneiras e o leitor não precisa ler toda a função, diga o que foo (28) significa?
Yuan
0

Uma escola de pensamento (não me lembro do egghead que o propôs no momento) é que todas as funções deveriam ter apenas um ponto de retorno de um ponto de vista estrutural para tornar o código mais fácil de ler e depurar. Isso, suponho, é mais para programar o debate religioso.

Um motivo técnico pelo qual você pode querer controlar quando e como uma função sai que quebra essa regra é quando você está codificando aplicativos em tempo real e deseja ter certeza de que todos os caminhos de controle através da função levam o mesmo número de ciclos de clock para serem concluídos.

MartyTPS
fonte
Uh, eu pensei que tinha a ver com limpeza (especialmente ao codificar em C).
Thomas Eding,
não, não importa onde você deixe um método, contanto que você retorne a pilha cai de volta (isso é tudo o que é "limpo").
MartyTPS de
-4

Estou feliz que você trouxe esta questão. Você deve sempre usar os ramos em um retorno antecipado. Por que parar aí? Junte todas as suas funções em uma, se puder (pelo menos o máximo que puder). Isso é possível se não houver recursão. No final, você terá uma função principal massiva, mas é isso que você precisa / deseja para esse tipo de coisa. Depois, renomeie seus identificadores para serem o mais curtos possível. Dessa forma, quando seu código é executado, menos tempo é gasto lendo nomes. Em seguida faça ...

Thomas Eding
fonte
3
Posso dizer que você está brincando, mas o mais assustador é que algumas pessoas podem levar o seu conselho a sério!
Daniel Pryden,
Concordo com Daniel. Por mais que eu ame o cinismo - ele não deve ser usado em documentação técnica, white papers e sites de perguntas e respostas como o SO.
cfi
1
-1 para uma resposta cínica, não necessariamente reconhecível por iniciantes.
Johan Bezem