Heap ou Stack? O programador decide!

I do not think that safety should be bought at the cost of complicating the expression of good solutions to real-life problems.
Bjarne Stroustrup

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&gt
#include <memory&gt

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?

Compartilhe este capítulo:

Compartilhe:

Comentários

Participe da construção deste capítulo deixando seu comentário:

Inscrever-se
Notify of
guest
0 Comentários
Feedbacks interativos
Ver todos os comentários

Elemar Júnior

Fundador e CEO da EximiaCo, atua como tech trusted advisor ajudando diversas empresas a gerar mais resultados através da tecnologia. 

Desenvolvendo gente que faz a diferença

reconhecida excelência da EximiaCo, em consultorias e assessorias, aplicada no desenvolvimento de competências através de publicações e capacitações abertas e in-company.

TECH

&

BIZ

-   Insights e provocações sobre tecnologia e negócios   -   

55 51 9 9942 0609  |  me@elemarjr.com

55 51 9 9942 0609  |  me@elemarjr.com

bullet-1.png

55 51 9 9942 0609   me@elemarjr.com

0
Quero saber a sua opinião, deixe seu comentáriox
()
x