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?
