Variáveis não inicializadas em C++ – nunca faça isso!

Inicialização de Variáveis em C++ - Parte 2

Bem-vindo de volta à sequência de artigos sobre variáveis. Como esta é uma continuação da parte 1, saltarei a introdução e retomarei de onde paramos da última vez: falávamos de “inicialização de variáveis em C++, escopos de nomes e da palavra-chave const.” (contudo, para evitar que o artigo ficasse grande demais e cansativo, decidi deixar a palavra-chave const para a parte 3).

Canal CppModerno – Variáveis em C++ – parte 2 – Inicialização de variáveis

Inicialização de variáveis em C++ – o que isso signifca?

Na parte 1 desta série, tratei da diferença entre declaração e definição de variáveis – vimos que na maioria dos casos esses termos são sinônimos. Todavia, há ainda um termo que é importante e muitas vezes utilizado como sinônimo dos dois anteriores, ainda que não o seja: inicialização. O que é, então, a inicialização de variáveis?

Inicializar uma variável significa atribuir-lhe um valor durante sua definição. Simples, não?

// Exemplos de inicialização de variáveis

// As três formas de inicialização a seguir
// são equivalentes - =, ( ) e { }
int diaDoMes = 23;
double notaNaProva(8.7);
std::string siteFavoritoDeProgramacao{"C++ Moderno"};

Agora sabemos que as variáveis podem ser declaradas, definidas ou inicializadas. Recapitulemos, então, as possibilidades associadas à criação de variáveis em C++:

  1. Uma variável pode ser declarada sem ser definida nem inicializada (seu nome é apenas reservado no programa, sem que haja alocação de memória para a variável em questão de acordo com o seu tipo);
  2. Para ser definida, uma variável precisa ser antes declarada (o que normalmente acontece em uma única instrução de código – a memória é alocada e o nome é reservado);
  3. Para ser inicializada, uma variável precisa antes ser definida (o que implica que ela também foi declarada).

Veja a seguir exemplos de cada um dos três casos (o primeiro deles é especial, pois usa a palavra-chave extern que não abordei ainda em um artigo dedicado, e ele não acontece frequentemente nas aplicações reais), com um exemplo adicional ao fim mostrando a palavra-chave extern utilizada sem efeito algum – a inicialização de uma variável força o programa a definí-la, para que a memória correspondente seja alocada e o valor utilizado na inicialização possa-lhe ser armazenado.

// 1) variavelApenasDeclarada é declarada, mas não definida (cenário pouco comum)
extern int variavelApenasDeclarada;

// 2) variavelDeclaradaEDefinida é, na mesma instrução, declarada e definida
std::string variavelDeclaradaEDefinida;

// 3) variavelInicializada é definida e inicializada pelo usuário
double variavelInicializada = 10.0;

/* 4) A inicialização da variável flagExternInicializada se sobrepõe ao "pedido"
* de apenas declará-la feito com a palavra-chave extern, pois para inicializar
* uma variável é preciso antes definí-la. */
extern bool flagExternInicializada = true;

Agora que vimos o que é a inicialização de variáveis em C++, eu preciso confessar que há uma imprecisão no que falei há pouco: uma variável apenas definida mas não inicializada pelo usuário, pode ser inicializada com o valor padrão/por padrão (default initialized, em inglês) pelo compilador. No exemplo número 2 do código acima, por exemplo, se a definição da variável for feita no escopo global do programa, ela é inicializada com o valor padrão para strings, que é o string vazio; todavia, se ela for definida no escopo local de uma função, seu valor permanece não-inicializado e utilizá-la posteriormente (sem inicializá-la antes) resultará em um comportamento indefinido (o tão temido undefined behaviour). Antes de eu te mostrar um exemplo de variável não inicializada sendo utilizada, contudo, é preciso falar dos escopos de nomes, para que fique claro quando uma variável é inicializada por padrão ou não, e o que é um escopo global ou local.

Lampada amarela dos conceitos importantes acerca da inicialização de variáveis em C++ - inicialização padrão

A inicialização padrão armazena nas variáveis de tipos aritméticos o valor zero (bools são inicializados com o valor false), e strings com uma cadeia de caracteres vazia.

Icon by Freepik

Escopos de nomes

Em um programa em C++, todos os nomes são válidos apenas no escopo em que aparecem. A pergunta que surge, logo, é a seguinte: o que diabos é um escopo? Bom, confesso que esta pergunta não possui uma resposta simples (visto que o escopo é definido em função do nome), então utilizarei uma astúcia e tomarei emprestada a definição utilizada pelo C++ reference (site em inglês que funciona como uma espécie de documentação oficial da linguagem C++).


O escopo de um dado nome é uma porção de código do programa, possivelmente descontínua, no qual esse nome é visível.


Não entendeu muito bem? Tudo bem. Vejamos se um exemplo ajuda a esclarecer as coisas.

Meme do coelho snowball confuso acerca dos escopos de nomes na inicialização de variáveis em C++
#include <iostream>
#include <string>

void voceEntendeu() {
    std::string oQueAconteceu{"Entendi foi nada"};
    std::cout << oQueAconteceu << std::endl;
}

int main() {
    // Imprimiria "Entendi foi nada" no terminal
    // se a linha 15 não gerasse um erro de compilação
    voceEntendeu();
    // Erro de compilação - oQueAconteceu não foi declarada neste escopo.
    // Erro original - 'oQueAconteceu' was not declared in this scope
    oQueAconteceu = "Agora eu entendi!";
    std::cout << oQueAconteceu << std::endl;
    return 0;
}

Acima há um exemplo de declaração de variável em um escopo local: oQueAconteceu é definida como um string (uma cadeia de caracteres) e inicializada com o valor “Entendi foi nada”, mas apenas dentro da funçao voceEntendeu() – fora desta função o nome da variável é invisível, como se ela não tivesse sido declarada, pois ele está fora do seu escopo. Daí o erro de compilação: a função main( ) cria o seu próprio escopo que é independente daquele da função voceEntendeu( ), e portanto não “enxerga” a variável oQueAconteceu, que pertence ao escopo da outra função. Vejamos agora um exemplo de declaração de variável no escopo global do programa.

#include <iostream>
#include <string>

// Definição e inicialização da variável
// no escopo global do programa.
std::string resposta{"Acho que sim"};

void voceEntendeu() {
    std::cout << resposta << std::endl;
}

int main() {
    // Imprime "Acho que sim" no terminal
    voceEntendeu();
   
    resposta = "Agora eu entendi!";

    // Imprime "Agora eu entendi! no terminal
    std::cout << resposta << std::endl;
    return 0;
}

No exemplo anterior, a variável resposta é declarada no escopo global do programa, o que a torna acessível a todas as demais partes do código. Assim, ela pode ser acessada dentro da função voceEntendeu (linha 8) e dentro da função main, que até altera seu valor e o imprime na tela (linhas 15 e 18, respectivamente). Por via de regra, variáveis que não são declaradas dentro de um bloco de código (delimitado por chaves – { }), seja ele o corpo de uma função, o corpo de uma laço (for, while, do…while etc.), ou um namespace definido pelo usuário (falarei disso mais adiante neste artigo, e mais a fundo em um artigo dedicado ao assunto), por exemplo, estão no escopo global e são chamadas de variáveis globais. Àquelas outras variáveis, definidas dentro de blocos de código, dá-se o nome de variáveis locais.


Breve explicação dos namespaces

Um namespace (espaço de nomes, em tradução livre) é um mecanismo que permite isolar regiões de código para que não ocorra colisões de nomes (ou seja, para que um mesmo nome possa ser redefinido em múltiplas partes do programa se for necessário). O namespace “esconde” o que está dentro dele das outras partes do código que não “participam” do namespace, ou que não “informam” ao compilador que a variável ou função que estão tentando usar pertecem a um namespace qualquer.

Para acessar um nome de variável ou de função definido dentro de um namespace, é preciso preceder esse nome do nome do namespace seguido de dois-pontos duplos (por exemplo: std::cout ou std::stringstring e cout estão ambos dentro do namespace std). Outra forma de acessar um nome de dentro de um namespace é utilizar a instrução using namespace nome_do_namespace (veja a linha 5 do código abaixo).

Uma outra vantagem da utilização dos namespaces está no fato deles comunicarem ao usuário (ou cliente) do código em que partes do programa um dado nome é definido. Usando o std::cout, por exemplo, sabemos que o nome cout está definido na biblioteca padrão do C++, cujo namespace é o std (abreviação do inglês standard que significa padrão).

#include <iostream>
#include <string>

// Isso permite ao compilador "encontrar" o nome cout,
// que pertence ao namespace std, sem precedê-lo de std::
// Ainda assim, posso utilizar o std:: se quiser.
using namespace std;

namespace terra {
    std::string animal = "macaco";
}

namespace mar {
    std::string animal = "peixe";
}

int main() {
    // Código abaixo inválido - o nome animal existe dentro dos namespaces,
    // mas os namespaces o escondem do mundo exterior. Para acessar
    // o nome animal, é preciso precedê-lo do nome
    cout << "Qual é o animal? R.: " << animal << std::endl;

    // Código válido. Na linha 23 se informa ao compilador que o nome
    // animal está "escondido atrás" do namespace terra
    // Saída: Qual é o animal? R.: macaco 
    cout << "Qual é o animal? R.: " << terra::animal << std::endl;

    // Código válido. Análogo ao exemplo anterior
    // Saída: Qual é o animal? R.: peixe
    cout << "Qual é o animal? R.: " << mar::animal << std::endl;

    return 0;
}

Exemplo de utilização de diferentes escopos de nome

#include <iostream>
#include <string>

// 1) hamburguer definida como variável global
std::string hamburguer = "X-Galinha";

// 2) o namespace dieta redefine hamburguer localmente 
// conforme suas necessidades
namespace dieta {
    std::string hamburguer = "X-Galinha Light";
}

// 3) o namespace offSeason também redefine hamburguer localmente
namespace offSeason {
   std::string hamburguer = "X-Tudo com bacon duplo";
}

// 4) a função define sua própria versão local de hamburguer
// que se sobrepõe, no seu escopo, à versão global da variável.
void escolhaVegetariana() {
    std::string hamburguer = "X-Salada";
    std::cout << "Hamburguer vegetariano: " << hamburguer << std::endl;
}

// 5) a função main também define sua versão local de hamburguer,
// assim como o fez a função escolhaVegetariana
int main() {
    std::cout << "Hamburguer global: " << hamburguer << std::endl;
    escolhaVegetariana();
    std::string hamburguer = "Sanduíche do Osvaldo";
    std::cout << "Hamburguer durante a dieta: " << dieta::hamburguer << std::endl;
    std::cout << "Hamburguer na temporada de off: " << offSeason::hamburguer << std::endl;
    std::cout << "Hamburguer do bairro: " << hamburguer << std::endl;
}

Saída do programa:
Hamburguer global: X-Galinha
Hamburguer vegetariano: X-Salada
Hamburguer durante a dieta: X-Galinha Light
Hamburguer na temporada de off: X-Tudo com bacon duplo
Hamburguer do bairro: Sanduíche do Osvaldo

No exemplo acima, vemos uma definição global de variável (1), e redifinições locais da mesma variável feitas de modo diferentes (2, 3 e 4, 5). Tratemos de cada uma delas.

  1. A variável hamburguer de tipo string é declarada no escopo global e inicializada com o valor “X-Galinha”. Qualquer outra parte do código pode utilizar o nome hamburguer para acessar o valor “X-Galinhas”;
  2. hamburguer é redefinida dentro do escopo dieta criado pela palavra-chave namespace, e recebe o valor “X-Galinha Light”. Para acessar um nome definido dentro de um namespace, é preciso preceder esse nome do nome do namespace seguido de dois-pontos duplos (por exemplo: dieta::hamburguer);
  3. Um novo escopo é criado sob a forma do namespace offSeason, e dentro dele é redefinida novamente a variável hamburguer para receber o valor “X-Tudo com bacon duplo”;
  4. Dentro da função escolhaVegetariana, a variável hamburguer é redeclarada e recebe o valor “X-Salada”. É importante notar que a declaração local de uma variável “ofusca” a declaração global daquela mesma variável, de modo que o valor local será utilizado pelo resto da função a partir do ponto em que a variável local foi definida (antes dele, o valor global é utilizado);
  5. Por fim, a função main redefine também a variável hamburguer para que ela contenha o valor “Sanduíche do Osvaldo”, mas antes de fazê-lo ela imprime no terminal o valor da variável (linha 29), e o valor que observamos (veja a imagem com os resultados da execução do programa) é o valor da variável no escopo global (pois a declaração local de hamburguer ainda não aconteceu).

Agora que tratamos de como identificar variáveis locais e variáveis globais, e vimos o que são escopos de nomes e como eles são criados, voltemos à inicialização de variáveis.

De volta à inicialização de variáveis

Interrompi esta discussão no ponto em que falava sobre variáveis locais não inicializadas, ou seja, variáveis definidas em um escopo local sem que lhes tenham sido atribuídos valores pelo usuário durante a definição. Tais variáveis permanecem não inicializadas, e como mencionado antes, usá-las neste estado resulta em comportamentos indefinidos no programa. Um exemplo de um tal uso inadequado é mostrado a seguir.

#include <iostream>

void funcaoPesadeloParaDebugar() {
    // Variável definida mas não inicializada
    double terrorDoDebug;
    // Valor obtido:  3.18479e-314
    std::cout << "O valor de terrorDoDebug é: " << terrorDoDebug << std::endl;

    terrorDoDebug = 10.9;
    // Valor obtido: 10.9
    std::cout << "O novo valor de terrorDoDebug é: " << terrorDoDebug << std::endl;
}

// Variável global semProblemas inicializada pela inicialização padrão do compilador.
double semProblemas;

int main() {
    funcaoPesadeloParaDebugar();
    // Valor obtido: 0
    std::cout << "Valor de semProblemas com inicialização padrão: " << semProblemas << std::endl;
    return 0;
}

No código acima, o resultado da operação na linha 6 mostra que o valor armazenado na variável local terrorDoDebug é 3.18479e-314, ou seja, um valor completamente aleatório (pois ela não foi inicializada como deveria ter sido). Ainda em funcaoPesadeloParaDebugar (que tem esse nome porque causa comportamentos indefinidos, que por definição podem variar em cada execução do programa, tornando muito difícil detectar de onde vem o erro), a variável terrorDoDebug recebe, na linha 9, o valor 10.9; se ela estava não inicializada, agora ela foi inicializada, certo? Bem, na verdade, não.

O termo inicialização se refere unicamente ao ato de dar um valor à variável durante sua definição. Portanto, o que vemos na linha 9 possui outro nome: atribuição. A atribuição acontece quando modifica-se o valor de uma variável após a sua definição (ainda que o valor anterior seja um valor indefinido devido à falta de inicialização de uma variável local). Apesar de sutil, essa é uma diferença importante a se saber pela seguinte razão: algumas pessoas acreditam que a forma de inicialização int var = 10 é menos eficiente que a forma int var{10}, pois elas acreditam que na primeira forma há a criação de um objeto temporário adicional para armazenar o valor 10, e em seguida uma cópia deste objeto para a variável var – como acontece durante uma atribuição; todavia, para os tipos padrão, esse procedimento é otimizado e as duas formas de inicialização de variáveis em C++ são equivalentes.

De volta ao pesadelo. Sabemos agora, logo, que na linha 9 foi atribuído à variável terrorDoDebug o valor 10.9, e na execução do programa vê-se esse valor impresso no terminal. Por fim, como exemplo de inicialização padrão, é definida a variável semProblemas no escopo global do programa; seu valor é então impresso no terminal (linha 20) e o comportamento é aquele esperado: obtém-se o valor 0 – sem problemas.

Lampada amarela para representar conceitos importantes da inicialização de variáveis em C++
  • Inicialização acontece apenas ao se fornecer um valor para uma variável durante sua definição.
  • Atribuição acontece quando se dá um valor para uma variável após sua definição (ainda que a variável seja local e não tenha sido inicializada).
  • Uma boa prática de programação consiste em sempre inicializar variáveis locais.

Icon by Freepik


Próximos assuntos

Chegamos ao fim deste artigo acerca da inicialização de variáveis em C++. Como dito na introdução, deixarei a palavra-chave const para a parte 3. Ah, quase esqueci! Fique de olho: const se torna ainda mais útil quando utilizada em conjunto com os tipos compostos: ponteiros e referências, assunto da nossa parte 4. Até à próxima!

Gostou do artigo? Então inscreva-se na nossa newsletter para não perder nenhum dos nossos artigos 🙂

Foto de perfil de Emanoel

Sou apaixonado por tecnologia, literatura e também filosofia. O cultivo dessas paixões ao longo da minha trajetória me inspiraram a compartilhar aquilo que aprendo com os outros da maneira mais clara que eu possa. Sou formado em Engenharia Elétrica no Brasil, e também sou engenheiro formado na França. Trabalho atualmente como programador C++ em uma multinacional francesa, uma das maiores empresas de TI do mundo.

Leave a Reply

Your email address will not be published. Required fields are marked *