Ponteiros e Gerenciamento de Memória
05/09/2021
São requisitos para essa aula o conhecimento de:
A memória principal do computador, também conhecida como Random Access Memory (RAM), é onde reside o programa e seus dados de execução. Cada posição da memória é denominada um endereço de memória.
Em C/C++, um tipo ponteiro é acompanhado de um asterisco (*), enquanto o operator & é utilizado para extrair o endereço de uma variável.
int x = 5; // armazena o inteiro 5 na variável x
int* px = &x; // armazena o endereço de x na variável px
char y = 'A'; // armazena o caractere 'A' na variável y
char* py = &y; // armazena o endereço de y na variável py
Abordamos ponteiros de uma maneira bastante simples e direta: ponteiros são apenas números (que indicam endereços de memória).
Portanto, não há o que temer! Sempre que encontrar um tipo ponteiro, imagine que ele é um número (e ele realmente é!).
int ppy = &y; // mas... o compilador pode (e deve) reclamar
Exploramos a situação dos strings, ou cadeias de caracteres, em C/C++. Como não existe um tipo primitivo, podemos utilizar vetores ou ponteiros de caracteres.
char texto1[] = "Primeiro texto";
const char* texto2 = "Segundo texto";
Em ambos exemplos acima, temos acesso a uma cadeia de caracteres nas variáveis texto1 e texto2. Embora exista uma diferença fundamental na estrutura das duas strings, ambas podem ser vistas como um ponteiro para caractere (observe que, em texto2, se trata de um ponteiro para informações imutáveis/constantes).
Naturalmente, o comando printf("'%s' e '%s'. %p %p", texto1, texto2, texto1, texto2);
irá apresentar na tela: 'Primeiro texto' e 'Segundo texto'.
, seguido dos endereços de memória (números!) para texto1 e texto2.
Para observar a diferença entre diversos tipos de alocações de memória, precisamos compreender como é organizada em C/C++.
Um programa em C/C++ armazena suas informações em segmentos de memória denominados: text, data, bss, heap e stack. No segmento text, as próprias instruções do código são armazenadas, seguidas dos dados inicializados (data) e dados não inicializados (bss). A seguir, o heap se expande dos menores para maiores endereços, enquanto a pilha (stack) cresce na direção oposta.
Considere o código compilado gcc main.c -o app
:
#include<stdio.h>
int main() {
return 0;
}
No Linux, size app
resulta em:
text data bss dec
1434 544 8 1986
Agora considere:
#include<stdio.h>
long global; // ou int64_t
long global2 = 65;
int main() {
char texto1[] = "Primeiro texto";
const char* texto2 = "Segundo texto";
long local = 66;
return 0;
}
No Linux, size app
resulta em:
text data bss dec
1701 608 16 2325
#include<stdio.h>
long global;
long global2 = 65;
int main() {
char texto1[] =
"Primeiro texto";
const char* texto2 =
"Segundo texto";
long local = 66;
return 0;
}
No Linux, readelf -x .data app
:
Hex deposito da seção '.data':
0x00004000 000000... .........@......
0x00004010 410000... A.......
No Linux, readelf -x .rodata app
:
Hex deposito da seção '.rodata':
0x00002000 010000... ....Segundo text
0x00002010 6f00 o.
No Linux, readelf -x .text app |
grep Primeiro
:
0x00001160 45f83... E.1.H.PrimeiroH.
Portanto, o armazenamento da variável pode ser feito em espaços (estáticos) distintos da memória (alguns de somente leitura):
#include<stdio.h>
long global;
long global2 = 65;
int main() {
char texto1[] = "Primeiro texto";
const char* texto2 = "Segundo texto";
char* texto3 = texto1; // OK
char* texto4 = (char*)texto2; // OK
[0] = 'Z'; // OK
texto3[0] = 'Z'; // ERRO
texto4return 0;
}
Os parâmetros são sempre copiados (em C) ao serem passados para uma função ou procedimento. Como passar tipos complexos (estruturas e vetores de muitos elementos) sem perder tempo?
Lembrando que a sintaxe do ponteiro simplesmente inclui um asterisco (*) após o tipo da variável. Exemplos: int* x; struct P* p1;
Um ponteiro simplesmente armazena o local (endereço) onde determinada variável está armazenada na memória (basicamente, um número). Então quando um ponteiro é passado como parâmetro, a cópia do ponteiro pode ser utilizada para encontrar na memória a estrutura desejada.
O tamanho do ponteiro varia de acordo com a arquitetura, mas para endereçar 64-bits, ele ocupa 8 bytes.
Em ponteiros para agregados, o operador de acesso (.) é substituído por uma seta (->). O operador &
toma o endereço da variável:
struct P {
int x;
char y;
};
void imprimir(struct P* p3, struct P p4) {
("%d %d\n", p3->x, p4.x);
printf->x = 10; p4.x = 10;
p3}
// ...
struct P p0 = {.x = 20, .y = 'Y'}; // cria variável 'p0'
struct P p1 = {.x = 20, .y = 'Y'}; // cria variável 'p1'
(&p0, p1); // resulta em '20 20'
imprimir("%d %d\n", p0.x, p1.x); // resulta em '10 20' printf
48 20 Y 24
---------------------------------------------------------
0 4 8 12 16 20 24 28 32 36 40 44 48 56 64
struct P {
int x;
char y;
};
// ...
struct P p0 = {.x = 20, .y = 'Y'};
struct P* pp1 = &p0;
struct P** ppp2 = &pp1;
("%p %p %p %p", &p0, pp1, &pp1, ppp2);
printf// imprime: 24 24 48 48
("%d %d %d %d %d\n", p0.x, (*pp1).x,
printf->x, (*ppp2)->x, (*(*ppp2)).x);
pp1// imprime: 20 20 20 20 20
Programas frequentemente necessitam de alocar mais memória para uso, o que é armazenado no heap através de malloc/new. Para usar malloc faça #include<stdlib.h>
.
// Aloca (C) o agregado P
struct P* vp = (struct P*)
(1*sizeof(struct P));
malloc// inicializa campos de P
->x = 10;
vp->y = 'Y';
vp// imprime x (valor 10)
("%d\n", vp->x);
printf// descarta a memória
(vp); free
// Aloca (C++) o agregado P
auto* vp = new P{
.x = 10,
.y = 'Y'
};
// imprime x (valor 10)
("%d\n", vp->x);
printf// descarta a memória
delete vp;
Retomamos a situação dos strings, agora com mais duas maneiras de alocar memória (e o utilitário strcpy
de #include<string.h>
):
char texto1[] = "Primeiro texto";
const char* texto2 = "Segundo texto";
char* texto3 = (char*) malloc(4*sizeof(char));
char* texto4 = new char[4];
(texto3, "Ola"); strcpy(texto4, "Ola");
strcpy("%s %s %p %p", texto3, texto4+1, texto3, texto4);
printf// imprime: Ola la (seguido de dois endereços)
(texto3); // quando usar malloc
freedelete[] texto4; // quando usar new[]
Utilizamos um char \0
a mais como delimitador de string (veja heap):
O l a \0
---------------------------------------------------------
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
O tipo de uma função em C é basicamente um ponteiro (endereço) da localização desta função na memória do computador.
// o tipo da função 'quadrado' é: int(*)(int)
int quadrado(int p) {
return p*p;
}
int(*quad)(int) = quadrado;
Este fato pode ser útil para receber funções como parâmetro, bem como armazenar funções anônimas em C++ (lambdas):
// armazena lambda no ponteiro de função 'quad'
int(*quad2)(int) = [](int p) { return p*p; };
("%d %d\n", quad(3), quad2(3)); // 9 9 printf
Em C, parâmetros de funções são sempre passados por cópia. Entretanto, podemos obter dois comportamentos distintos, chamados de: passagem por valor e passagem por referência.
Entendemos passagem por valor como uma passagem por cópia do conteúdo de uma variável.
int incrementa(int p) {
++;
preturn p;
}
int y = 10;
int x = incrementa(y); // x==11 y==10
10 11 11
---------------------------------------------------------
0 4 8 12 16 20 24 28 32 36 40 44 48 52 58
y x p
Em C, entendemos passagem por referência como uma passagem por cópia do endereço de uma variável.
int incrementa(int* p) {
(*p)++;
return *p;
}
int y = 10;
int x = incrementa(&y); // x==11 y==11
11 11 16
---------------------------------------------------------
0 4 8 12 16 20 24 28 32 36 40 44 48 52 58
y x p
Em C++ (exclusivamente), podemos criar uma referência lvalue
(chamada de alias) para uma variável existente. Basta incluir um &
após o tipo da variável. Com esse recurso, também é possível efetuar passagem por referência.
int incrementa(int& p) {
++;
preturn p;
}
int y = 10;
int x = incrementa(y); // x==11 y==11
11 11
---------------------------------------------------------
0 4 8 12 16 20 24 28 32 36 40 44 48 52 58
y x
p
Em C++ (após 2011), podemos ter passagem por movimento (move semantics) para um valor rvalue
(constante prvalue
ou variável xvalue
prestes a expirar). Basta incluir um &&
após o tipo da variável e utilizar std::move
(com #include<utility>
).
int incrementa(int&& p) {
++;
preturn p;
}
int x1 = incrementa(10); // x1==11. aceita prvalue
int y = 10;
int x = incrementa(std::move(y)); // x==11. y expira aqui
11 11 11
---------------------------------------------------------
0 4 8 12 16 20 24 28 32 36 40 44 48 52 58
y x x1
p
Quando estiver lidando com agregados grandes, é importante evitar cópias.
struct Gigante{
char muitos[9999];
};
void func1(struct Gigante g) {
// cópia do conteúdo de g (lento) - C/C++
}
void func2(struct Gigante* g) {
// cópia do endereço de g (rápido) - C/C++
}
void func3(struct Gigante& g) {
// referência/alias lvalue g (rápido) - C++
}
void func4(struct Gigante&& g) {
// referência rvalue g (rápido) - C++ (desde 2011)
}
Em C/C++, tipicamente chamamos de ponteiros nulos aqueles cujo valor é zero (ou utiliza-se a macro NULL
). Então, um padrão comum (e arriscado) é utilizar o ponteiro para retorno de funções (pois pode se perder/vazar).
typedef struct{int x; char y;} P;
* func(int z) {
Pif(z > 10) {
* p = new P; // quem irá desalocar?
P->x = 10; p->y = 'Y';
preturn p;
} else return 0; // ponteiro nulo
}
int main() {
* p = func(50);
Pif(p) { printf("%d", p->x;); delete p; } // 10
// ...
Em C++, é fácil evitar vazamentos de memória, desde que se utilize dois tipos de ponteiros: ponteiro único (std::unique_ptr
) ou compartilhado (std::shared_ptr
). O unique_ptr
garante que uma única referência exista, enquanto o shared_ptr
desaloca memória automaticamente desde que não existam referências circulares (necessitam #include<memory>
).
std::unique_ptr<P> func2(int z) {
if(z > 10) {
std::unique_ptr<P> p { new P };
->x = 10; p->y = 'Y';
preturn p;
} else return 0; // ponteiro único nulo
}
int main() {
std::unique_ptr<P> p = func2(50);
if(p) { printf("%d", p->x); } // 10
// ...
Em geral, é benéfico trabalhar com dados locais na pilha (stack frame) ao invés de alocar tudo dinamicamente no heap.
É recomendável retornar elementos por cópia eficientemente, explorando processos de return value optimization (RVO) ou named RVO (NRVO), devido ao copy ellision.
struct Gigante { char muitos[9999]; int x; };
struct Gigante f() {
struct Gigante g = {.x = 20};
return g;
}
// ...
int main() {
struct Gigante gg = f(); // um único agregado
("%d", gg.x); // com .x = 20
printf// ...
Em C++, é recomendável o uso de move semantics para evitar cópias e alocações desnecessárias no heap. Na revisão 2017 do C++ já é possível utilizar o placement new (útil para estruturas como std::optional
).
#include<new>
//...
= func(50);
OpcionalP op1 if(op1.existe) {
* p = (P*)op1.buf;
P("%d %d %c\n",
printf.existe, p->x, p->y);
op1}
= func(0);
OpcionalP op2 ("%d\n", op2.existe); printf
typedef struct{int x; char y;} P;
typedef struct{
unsigned char buf[sizeof(P)];
bool existe;
} OpcionalP;
(int z) {
OpcionalP func= {.existe = 0};
OpcionalP op if(z > 10) {
* p = new (op.buf) P;
P->x = 10; p->y = 'Y';
p.existe = true;
op} return op;
}
Fim do tópicos de ponteiros e gerenciamento de memória.
Além da bibliografia do curso, recomendamos (para esse tópico):
Em especial, agradeço aos colegas que elaboraram bons materiais, como o prof. Fabiano Oliveira (IME-UERJ), e o prof. Jayme Szwarcfiter cujos conceitos formam o cerne desses slides.
Estendo os agradecimentos aos demais colegas que colaboraram com a elaboração do material do curso de Pesquisa Operacional, que abriu caminho para verificação prática dessa tecnologia de slides.
Esse material de curso só é possível graças aos inúmeros projetos de código-aberto que são necessários a ele, incluindo:
Agradecimento especial a empresas que suportam projetos livres envolvidos nesse curso:
Esses slides foram escritos utilizando pandoc, segundo o tutorial ilectures:
Exceto expressamente mencionado (com as devidas ressalvas ao material cedido por colegas), a licença será Creative Commons.
Licença: CC-BY 4.0 2020
Igor Machado Coelho