Avaliação curto-circuito

Existem alguns conceitos de linguagens de programação úteis, mas que são tão simples que se tornam um “tabu”. Você não presenciará veteranos discutindo tais assuntos, pois não há nada há se discutir de tão simples que são, mas são conceitos úteis que deveriam ser disseminados para os novatos desse universo. Um desses conceitos é, dentro do pedaço de universo com o qual eu tenho mais contato, o conceito de avaliação curto-circuito.

Esse conceito se refere a propriedade que alguns operadores possuem. Tal propriedade garante uma ordem de avaliação e que, quando for desnecessário continuar a avaliação para descobrir o resultado, ela será interrompida. É chegado o momento… para um exemplo!

bool f(int x);
bool g(bool a, int b)
{
return a && f(b);
}

A saída da função g, do exemplo anterior, usa o operador lógico binário && (AND) e sua saída independe da propriedade de avaliação curto-circuito. A tabela a seguir resume as saídas possíveis:

a f(b) g(a,b)
false ? false
true ? ?

A partir da tabela anterior é possível perceber que, caso o valor de a seja false, não precisamos avaliar a expressão f(b). Avaliar, avaliar e avaliar é a ideia-chave para entender a avaliação curto-circuito. O que avaliar a expressão f(b) significa? Se a (sub-)expressão não possuir efeitos colaterais, não faz diferença. Que tal refletir sobre o comportamento de código a seguir?

struct Val
{
int answer;
};
int f(struct Val *val)
{
if (val && (val->answer != 42))
return val->answer;
return 42;
}

O código do exemplo anterior explora o fato do operador lógico binário && garantir avaliação curto-circuito. Na verdade, esse é um truque bem comum de encontrar em “códigos de verdade”. Graças a avaliação curto-circuito, é garantido que a sub-expressão val->answer != 42 não será avaliada caso val == NULL. Eu tenho bastante confiança de que você já entendeu a importância da avaliação curto-circuito nesse exemplo, mas para o caso de esse texto estar sendo lido por alguém que entrou muito recentemente no mundo da programação (ou no mundo da programação em C), vou explicar mais profundamente: a sub-expressão val->answer faz um desreferenciamento de ponteiro e, caso val == NULL, a expressão irá gerar um erro em tempo de execução (provavelmente a aplicação vai fechar imediatamente), mas isso não ocorrerá graças a avaliação curto-circuito.

Antes de explorar as consequências desse importante conceito no desempenho da aplicação, uma expansão do exemplo anterior para reforçar a ideia:

struct Val
{
int answer;
};
bool f(Val val)
{
// Só vai imprimir `val.answer` quando
// houver uma chamada a função f
std::cout << val.answer << std::endl;
return val.answer != 42;
}
int g(Val *val)
{
// Só vai chamar a função f se `val != NULL`
if (val && f(*val))
return val->answer;
return 42;
}

Desempenho

Essa seção é só um alerta para o caso de você, paranoico por desempenho, ter sido estimulado a preferir (ou o contrário) os operadores que garantem avaliação curto-circuito para atingir o máximo de desempenho.

A primeira observação que podemos fazer é que o caminho de execução no caso desses operadores será diferente, pois algumas vezes ele será mais curto devido a não-necessidade de avaliar algumas sub-expressões. Menos passos de execução significa que o trabalho é feito mais rápido, afinal isso é o básico de análise de algoritmos, certo? Errado! Só porque algo é teoricamente mais rápido, não quer dizer que na prática vai executar mais rápido, pois (1) a teoria que você usou pode não estar modelando todas as partes importantes do sistema ou (2) algumas instruções podem ter custos maiores (acesso a memória, por exemplo). Um fator que deve ser considerado é a predição de qual ramo será executado.

Eu acabo de fazer algo terrível. Eu introduzi outro conceito a um possível leitor maniaco por desempenho que sacrificará legibilidade de código por código possivelmente mais rápido. Caso isso tenha acontecido, sugiro que você cave mais fundo e pesquise pelas instruções cmov. São instruções que condicionalmente movem um valor e livram o programa da necessidade de realizar um pulo condicional. Isso significa que se você cegamente tentar otimizar o código removendo caminhos (e pulos de instruções), é possível que seu código execute mais lento que um código limpo e claro escrito por alguém que não está preocupado com desempenho.

Uma última observação é que o compilador (e não você!), quando possuir acesso a todos os códigos necessários, saberá qual a melhor decisão a tomar. Sempre use um profiler (coisa que eu deveria estar fazendo)!

OFF-TOPIC

Bom, já que se foi discutido sobre a (não) avaliação de expressões, vou citar uma proposta legal para o próximo padrão do C++ que permitiria eliminar a reavaliação de (mais algumas) sub-expressões comuns: http://isocpp.org/files/papers/n3744.pdf

Tags:,

Comentários (with MarkDown support)