Para programadores C++, performance é algo muito importante! Qualquer pessoa com experiência razoável desenvolvendo sistemas performáticos sabe que o “segredo do sucesso” está no uso racional de recursos, sobretudo da memória. Por isso, C++ dá controle total desse aspecto ao programador.
Grandes poderes, entretanto, demandam grandes responsabilidades. No passado, C++, embora poderosa, não facilitava a escrita de programas, ao mesmo tempo, seguros e performáticos. Entretanto, tudo mudou desde o surgimento e a popularização de alternativas como smart pointers.
Nesse apêndice, mostro algumas opções disponíveis para que o programador determine o comportamento de um programa com relação a memória.
Heap ou Stack? Sempre uma escolha do programador
Em C++, cabe ao programador decidir se valores, incluindo instâncias de classes, serão alocados na stack ou na heap.
O que é a Stack?
Pilhas são regiões de memória onde os dados são adicionados ou removidos de maneira LIFO (last-in-first-out).
Cada thread em um processo em execução, tem uma região reservada de memória chamada de stack. Quando uma função é executada, ela pode adicionar alguns de seus “dados locais ao topo da pilha; quando a função sai, ela é responsável por remover esses dados da pilha.
Um objeto alocado na stack é “destruído” e removido da memória automaticamente, sempre que o escopo da stack onde está alocado é encerrado.
#include <iostream> #include <memory> class Fraction { private: int _numerator; int _denominator; public: Fraction(int numerator, int denominator) : _numerator(numerator), _denominator(denominator) { std::cout << "Custom ctor invoked." << std::endl; } ~Fraction() { std::cout << "Fraction instance destructed." << std::endl; } }; int main() { auto fa = Fraction(2, 3); // Fraction std::cout << "Goodbye, cruel!" << std::endl; return 0; } // OUTPUT: // Custom ctor executed. // Goodbye, cruel! // Fraction instance destructed.
Tradicionalmente, a alocação de objetos na heap acontecia mediante a utilização do operador new
. Objetos alocados na heap dessa maneira deverão ser “destruídos” e desalocados através da utilização do operador delete
.
O que é a Heap?
Heap é o nome do espaço de memória utilizado por um programa para alocação de dados dinamicamente, geralmente com espaço determinado em tempo de execução.
int main() { auto fa = new Fraction(2, 3); // Fraction* std::cout << "Goodbye, cruel!" << std::endl; delete fa; return 0; } // OUTPUT: // Custom ctor executed. // Goodbye, cruel! // Fraction instance destructed.
Modernamente, entretanto, recomenda-se a utilização de smart pointers.
int main() { auto fa = new std::make_unique<Fraction>(2, 3); // unique_ptr<Fraction> std::cout << "Goodbye, cruel!" << std::endl; return 0; } // OUTPUT: // Custom ctor executed. // Goodbye, cruel! // Fraction instance destructed.
A famosa “insegurança” na gestão de memória frequentemente associada a C++ tem relação direta com a utilização dos operadores new
e delete
. Hoje em dia, essa prática é considerada um anti-pattern.
Implicações em alocar objetos na Stack
Objetos alocados na stack são acessados de maneira mais rápida e são desalocados automaticamente e de maneira determinista, sempre que um contexto é encerrado. Entretanto, é importante que se considere que a stack tem tamanho limitado e não é apropriada para objetos com tamanhos variáveis determinados em tempo de execução.
Por padrão, sempre uma variável aponta para um valor presente na stack uma cópia é realizada.
int main() { auto fa = Fraction(2, 3); auto fb = fa; std::cout << "Goodbye, cruel!" << std::endl; return 0; } // OUTPUT: // Custom ctor executed. // Goodbye, cruel! // Fraction instance destructed. // Fraction instance destructed.
No código acima, o construtor fornecido é chamado apenas uma vez. Entretanto, o destrutor é chamado duas vezes. Na prática, quando a atribuição para fb
acontece, um construtor especial (de cópia) é executado copiando dados. O programador tem liberdade para implementar o construtor de cópia se desejar.
#include <iostream> class Fraction { private: int _numerator; int _denominator; public: Fraction(int numerator, int denominator) : _numerator(numerator), _denominator(denominator) { std::cout << "Custom ctor executed." << std::endl; } // COPY CONSTRUCTOR Fraction(const Fraction& other) : _numerator(other._numerator), _denominator(other._denominator) { std::cout << "Copy ctor executed." << std::endl; } int get_numerator() const { return _numerator; } int get_denominator() const { return _denominator; } ~Fraction() { std::cout << "Fraction instance destructed." << std::endl; } }; int main() { auto fa = Fraction(2, 3); // Fraction auto fb = fa; std::cout << "Goodbye, cruel!" << std::endl; return 0; } // OUTPUT: // Custom ctor executed. // Copy ctor executed. // Goodbye, cruel! // Fraction instance destructed. // Fraction instance destructed.
Uma alternativas para impedir cópias desnecessárias é utilizando referências.
int do_something(const Fraction& f) { std::cout << f.get_numerator() << std::endl; } int main() { auto fa = Fraction(2, 3); // Fraction auto fb = &fa; // Fraction* - no copy do_something(fa); // passing by reference - no copy std::cout << "Goodbye, cruel!" << std::endl; return 0; }
Implicações em alocar objetos na Heap
Diferente do que é dito com frequência, a utilização da heap não é a única (nem a melhor) alternativa para impedir a cópia (e multiplicação de instâncias) de dados, tão característica quando objetos são alocados na stack.
A alocação dinâmica de memória deve ser usada sempre que o volume de memória demandado não for possível de determinar durante o tempo de compilação. Por exemplo, o tamanho de um array que armazenará dados de acordo com entradas de dados de usuários.
Modernamente, alocação de memória dinâmica em C++ acontece através de smart pointers , implementação de uma variação de uma técnica popular conhecida como RAII.
RAII
RAII – resource acquisition is initialization é uma técnica onde a ideia básica é representar o recurso que se deseja controlar em um objeto local (na stack) de forma que o destrutor deste objeto fique responsável por, eventualmente, liberar tal recurso se possível.
Há duas implementações principais de smart pointers no C++: unique_ptr
e shared_ptr
.
unique_ptr
é uma alternativa ultra-leve para quando um objeto, alocado dinamicamente, tiver apenas um único “consumidor”. Na prática, ele dispensa que programadores se preocupem em realizar a “desalocação” de objetos manualmente, impedindo a ocorrência de leaks. Trata-se de uma implementação econômica que desautoriza cópias.
shared_ptr
, por outro lado, é destinado para cenários onde diversos “consumidores” têm interesse em um mesmo objeto e compartilham a responsabilidade pela “desalocação”. Apenas quando todas as instâncias do smart pointer forem descartadas, então, o objeto controlado é descartado. Trata-se de uma opção segura, inclusive para código concorrente (multi-thread)
Tanto shared_ptr
quanto unique_ptr
foram projetados para serem passados “por valor”.
#include <iostream> #include <memory> class A { private: int _value {}; public: void set_value(int newValue) { _value = newValue; } int get_value() const { return _value; } A() { std::cout << "A::A()" << std::endl; } ~A() { std::cout << "A::~A()" << std::endl; } void say_value() { std::cout << "hello " << _value << "!" << std::endl; } }; void do_something(std::shared_ptr<A> a) { a->set_value(10); } int main() { auto a = std::make_shared<A>(); do_something(a); a->say_value(); } // OUTPUT: // A::A() // hello 10! // A::~A()
No código acima, a instância alocada em main
é destruída e desalocada automaticamente quando o programa é encerrado.
int main() { auto a = std::make_unique<A>(); auto b = a; // fail to copy. }
Já o código acima, não compila porque o smart pointer unique_ptr
foi implementado de forma a não permitir cópias. Para fazer “transferência de ownership” deve-se recorrer a movimentação explícita (utilizando std::move
).
int main() { auto a = std::make_unique(); auto b = std::move(a); assert(!a); assert(b); }
Inseguro? Onde?
“Legacy code” often differs from its suggested alternative by actually working and scaling.
Bjarne Stroustrup |
Código legado C++ pode ser difícil de manter, principalmente pela dificuldade de gerenciar o ciclo de vida de objetos utilizando os operadores new
e delete
. Mas, com o advento dos smart pointers isso parece ser coisa do passado.
Discorda?