26 de mar. de 2011

Ponteiros em C - Tutorial

Há muito tempo venho pensando em escrever sobre ponteiros. Assim como eu, creio que muitos estudantes iniciantes tiveram sérios problemas para entender esse assunto que, no fim das contas, depois de aprendido, é até que bem fácil. Já vi até bastantes livros sobre o assunto e tutoriais na internet e tudo é muito parecido, sinceramente: falam sobre uma variável de ponteiro, e aí falam sobre o endereço de memória e sobre o que tá guardado no endereço de memória, e sobre aquele asteriscozinho, e depois sobre passar por valor e por referência e blá blá blá . Quando tive as aulas disso no meu primeiro semestre simplesmente ignorei a existência deles, crente de que não me seriam úteis. Estava enganado e, no fim das contas, tive de aprender a coisa na marra.

É do meu interesse que esse post se torne algo como um bom lugar onde as pessoas possam procurar sobre como usar ponteiros em C. Assim, faço questão de pedir que o leitor comente, ao término - ou não necessariamente ao término - da leitura, sobre o que achou, me indicando erros (para que eu possa corrigir) e me sugerindo alguma coisa que ache relevante ao trabalho.

Introdução

Como o assunto é complicado, pretendo ser o que já ouvi muitos chamarem de prolixo. Devo falar, falar e falar, tentando, assim, ser bem claro em minhas definições. Na primeira parte, utilizarei uma analogia para definir bem o conceito em si, não me apegando necessariamente à linguagem C. Mesmo assim, conhecimento prévio das outras estruturas da linguagem é muitíssimo bem vindo, já que não será nem um pouco abordado aqui. No segundo capítulo, elucidarei alguns assuntos que considero de suma importância antes de começar a falar de ponteiros. Assim, discorrerei um pouco sobre os vários usos do operador * na linguagem C e farei algumas considerações sobre ponteiros do C e as referências existentes em C++. Em geral, os usuários da linguagem C já devem estar acostumados com os assuntos tratados na segunda parte; mesmo assim, ainda considero interessante a sua leitura, para que entendam a maneira como pretendo prosseguir pensando ao abordar os temas posteriores.

Na terceira parte, deverei discorrer realmente sobre o uso dos ponteiros em C, destacando suas utilidades, dando exemplos e, principalmente, salientando as várias possibilidades de usos do operador *. Minha sugestão é que o leitor leia esse e os subsequentes capítulos testando trechos de código próprios, além daqueles que estarão disponíveis no próprio post. Por fim, no quarto e último capítulo, sugerirei alguns lugares onde o leitor poderá procurar por mais informações, além de fazer algumas considerações finais.

Capítulo 1

O conceito de ponteiro é nem um pouco intuitivo. Poderíamos dizer que "um ponteiro é uma variável que aponta para um endereço de memória", ou seja, que guarda um endereço de memória (frequentemente, de outra variável). Mesmo assim, concordemos que essa definição, apesar de bonita, só serve pra inglês ver. Assim, tive a idéia (eu duvido que alguém ainda não tenha pensado nisso, mas depois de uma boa procura no google concluí que, se alguém pôs em prática, ainda não ficou conhecido) de usar uma analogia mais intuitiva sobre o mundo real e "o mundo dos ponteiros".

Começaremos a nossa analogia conhecendo a Joana e o Mário. Naturalmente, a Joana é mais baixinha que o Mário, já que normalmente as mulheres são mais baixinhas (estou errado?).
Figura 1.1
Continuando a nossa história, esses nossos dois novos amigos, no mundo real, assumem uma outra forma: eles são endereços de memória. Como tais, eles só podem carregar uma informação de cada vez junto com eles.

No nossa história, o Mário está com o número de telefone da Joana, a saber, 2345678. Já a Joana, está com o número da casa da mãe do Mário: 41.

Figura 1.2
Ao longo da nossa história, isso vai ficar mais claro, mas vale lembrar (como o leitor até mesmo já deve ter percebido) que as coisas no mundo dos ponteiros funcionam de um modo diferente daquele percebido por nós no mundo real. Assim, um exemplo bem característico é o fato de que pessoas no mundo dos ponteiros nunca vivem juntas, ou seja: em cada casa, só cabe uma pessoa. Além disso, às pessoas às vezes são atribuídos chapéus, arbitrariamente, os quais elas são obrigadas a ficar usando durante um tempo indeterminado. Outra coisa[!!!]: nem todo mundo tem casa! Aliás, pouquíssimas pessoas têm. E às vezes essas casas passam de mão em mão, rapidamente.

Voltando a falar de ponteiros e fazendo um paralelo do seu mundo para o nosso, temos agora informações suficientes para definir o que eles são. Sem muita enrolação, os ponteiros são as casas. Elas guardam exatamente uma pessoa (um endereço de memória), tendo ela um chapéu ou não.

Quando um programador, no mundo real, declara uma variável qualquer (um int, por exemplo), ele aloca alguns endereços de memória para uso no seu programa. Isso significa, no mundo dos ponteiros, que agora aquelas pessoas têm chapéu, até que o programa acabe ou que a variável seja, de alguma forma, desalocada.

Acalme-se, jovem programador: ainda temos muito texto pela frente. O importante agora é só saber exatamente aquilo que eu disse: ponteiros são casas, ou seja, variáveis que guardam endereços de memória. A figura 1.3 faz um resumo da nossa analogia:
Figura 1.3

 Capítulo 2

Aqui eu faço uma pausa: esqueçamos nossa história por um tempo. Antes de continuar explicando sobre ponteiros precisamos de um certo embasamento. Assim, divido o capítulo em duas partes: uma sobre referências em C++ e outra em que combinarei com o leitor alguns padrões de notação (e, sim, essa última é, acho, a parte mais importante do capítulo).

2.1 Sobre referências em C++
A linguagem C, na minha opinião, é "completa" no que trata de ponteiros. Com ela, podemos - o leitor não precisa entender do que estou falando nesse primeiro momento - referenciar (usando o &) e desreferenciar (usando o *). Com o "advento" do C++, inventaram as "referências" (não confundir com o "referenciar" e o "desreferenciar" da frase anterior), que tem uma semântica parecida com a dos ponteiros, mas que não é completa por si só, precisando ainda dos ponteiros para se basear. Além disso, as ditas referências "mudam" a semântica do operador "&", diminuindo a sua ortogonalidade.

Assim, me abstenho de fazer qualquer consideração sobre o que são e como funcionam as referências nos capítulos seguintes, me limitando a dizer que elas simplesmente não existem na linguagem C. Dessa forma, pessoas com dúvidas sobre protótipos de funções (i.e., a declaração da função) utilizando, em qualquer parte, um &, devem procurar outro tutorial na internet sobre o assunto.


2.2 O operador *
Muitas vezes, o grande motivo pelo qual o programador iniciante se confunde com ponteiros não tem nada a ver com o ponteiro em si, mas com o operador *. No início do capítulo 3, eu ensinarei como o são declarados ponteiros em C. Aqui, farei um pequeno resumo.

Para declarar um ponteiro, basta seguir o padrão "<tipo>* <nome da variável>;". Isso também serve para, ao escrever uma nova função, especificar que você quer que haja um parâmetro que seja um ponteiro. O código a seguir exemplifica o que quero dizer:


Sabendo que o * é um operador (unário, diga-se de passagem), ele não é influenciado por espaços (bem como qualquer operador). Um exemplo disso seria o operador de soma: tanto faz escrever 2+3, 2+ 3 ou 2 + 3, ou qualquer combinação diferente (inclusive com um número maior de espaços). Assim, frequentemente vemos pessoas jogando o operador * para todos os lados, o que normalmente confunde o programador inexperiente:


Nos trechos acima, parece trivial enxergar que o tipo daquelas variáveis é ponteiro. Mas e quando temos uma função que recebe um ponteiro e manipula esse ponteiro dentro de si? Mais adiante veremos um exemplo, mas antes preciso discutir sobre a utilidade de um irmão gêmeo do operador *, o operador de desreferência. No capítulo 3, o veremos com mais detalhes, mas aqui faço um resumo sobre ele.

Agora que você já sabe para que servem os ponteiros (fazer referência a endereços de memória) e já viu qe eles têm um tipo (ou seja, referenciam endereços de memória alocados com o propósito de guardar uma informação daquele tipo), você já deve ter percebido que eles não seriam muito úteis se não tivéssemos um mecanismo para acessar aqueles endereços, certo? O C disponibiliza, para tanto, o operador * (sim, ele mesmo, mas usado num outro contexto). O exemplo abaixo deve deixar claro como usá-lo (ao menos tenta).


Você saberia me dizer o que cada uma das funções acima faz (por favor, sem ler os comentários)? Creio que alguns leitores desavisados me diriam que a primeira e a última função fazem exatamente a mesma coisa, o que não é verdade. Mesmo assim, creio que a ambiguidade deixa de existir quando o * fica perto do tipo, como na terceira função. Dessa forma, para que não haja esse tipo de problema, usarei o asterisco, daqui para a frente, sempre perto do tipo.

Vale reforçar aqui o seguinte: uma vez usado o asterisco, ele se torna parte do tipo. Dessa forma, faz sentido dizer que o tipo de uma variável é, por exemplo, um ponteiro de inteiro (int*). Isso é importante para a continuação do texto.


Capítulo 3

Voltando à nossa história (do Mário e da Joana), vamos definir ponteiros, sua sintaxe e seus vários usos. Como já vimos antes, no capítulo 1, ponteiros são as casas dos endereços de memória, ou, melhor, são as casas das pessoas no mundo dos ponteiros.

Como também já visto, casas são um bem extremamente difícil de se adquirir no mundo dos ponteiros, além de frequentemente passarem de mão em mão rapidamente (ponteiros, nos programas, estão frequentemente trocando o endereço para o qual estão apontando).

3.1 Declarando ponteiros
Para criar uma casa no mundo dos ponteiros (ponteiro em C), deve-se utilizar a seguinte sintaxe:
<tipo>* <nome_da_nova_variável>;

Essa sintaxe também serve para indicar que uma função pretende receber como parâmetro um ponteiro. O seguinte trecho de código (também utilizado no capítulo 2) exemplifica o caso:


Em C, quando uma variável é criada, ela não é inicializada com nenhum valor. No caso de ponteiros, é como se, ao terminar de construir uma casa, um endereço de memória vileiro já fosse lá e invadisse, tomando-a para si. Assim, a forma de expulsar o morador indesejável do lugar é atribuindo um endereço de memória "nulo" pra ela. Em C, a constante "NULL" (que é o mesmo que escrever o valor 0, mas aconselho fortemente que se use NULL em vez de 0) serve como tal endereço, e, no mundo dos ponteiros, ele definitivamente é o cara mais rico do mundo.

Como eu disse, o mundo dos ponteiros têm uma série de especificidades, muitas das quais ainda não foram numeradas (e que, ao longo do tempo, serão reveladas). Uma delas é o fato de que cada pessoa tem um número, relativo ao seu endereço de memória. Dessa forma, poderíamos dizer que o NULL é o 0, por exemplo. Da mesma forma, o Mário poderia ser, por exemplo, o 40.000, e a mãe do Mário, como já visto (veja a figura 1.2), seria o 41.

Como ponteiros são variáveis como qualquer outra, a atribuição é feita do mesmo jeito, através do sinal de "=". Além disso, ponteiros são números, como qualquer inteiro, o que implica que também podemos manipulá-los como manipularíamos inteiros, através de somas, multiplicações ou substrações, por exemplo (isso seria o mesmo que ficar mudando arbitrariamente o dono da casa).

O seguinte trecho de código mostra um exemplo desse tipo de manipulação. Eu crio um array de 20 inteiros e um ponteiro. Mando o ponteiro apontar para o primeiro endereço de memória desse array e percorro o array, sem fazer nada (acalme-se, jovem programador, logo faremos alguma coisa). Caso o leitor não entenda o "+4", eu explico: nesse código, estou supondo que o inteiro tenha o tamanho de 4 bytes. Como cada endereço de memória aponta para um byte diferente, tenho que pular 4 bytes para chegar no início de um novo inteiro. Mais para a frente, mostrarei uma outra forma mais interessante de pular exatamente o tamanho do inteiro (independente de qual seja esse tamanho).


Como eu disse, logo esse código fará alguma coisa, mas antes precisamos de um pouco mais de informações. Em primeiro lugar, quero apresentar ao leitor dois novos operadores: o irmão gêmeo do *, e o operador &.

3.2 Os operadores * e &
Suponha agora que você saiba em que casa mora atualmente a Joana (tenha um ponteiro dela) e que queira perguntar a ela que informação ela guarda. Obviamente, dependendo do tipo da informação (int, char, etc), você terá de perguntar não só a ela, mas também a algumas outras pessoas que têm numeração imediatamente posterior a ela (no caso de um, por exemplo, serão mais 3 pessoas, além dela). O operador de desreferência (*) tem como objetivo lhe permitir isso.

Assim, dado um ponteiro (uma casa), você poderá, utilizando o *, alcançar o endereço de memória que ele aponta (o morador da casa). Utilizaremos o trecho de código anterior para exemplificar o uso do *:


Você consegue me dizer o que o trecho de código acima faz? Ele pega o valor int do endereço de memória para o qual o ponteiro está apontando, e soma-o com a variável soma. É interessante compreender aqui a importância do tipo do ponteiro: o que aconteceria se o tipo do ponteiro que estamos utilizando fosse char, que utiliza somente 1 byte? E o que aconteceria se o ponteiro apontasse para uma struct que utilizasse, por exemplo, 8 bytes? Como eu disse antes, os ponteiros lêem endereços de memória, sem se preocupar com o que tem dentro deles. Assim, o ponteiro de char leria somente 1 byte (e não seria problema nenhum, no nosso caso) e o ponteiro do tipo da referida struct leria 8 bytes (o que seria um problema quando chegássemos na última iteração do for, já que haveria a possibilidade de lermos endereços não alocados para uso do nosso programa, i.e., pessoas sem chapéu).

Com o objetivo de introduzir o operador &, preciso contar mais uma coisa sobre o mundo dos ponteiros. Todas as pessoas, no mundo dos ponteiros, podem ter um número incontável de clones (fique claro, aqui, que ser clone é um propriedade reflexiva, o que significa que, se o Mário tem um clone, então o Mário é um clone desse clone, bem como o clone é clone do Mário). Assim, normalmente, nas chamadas de funções (no mundo deles, festas), os endereços de memória, preguiçosos, em vez de irem eles mesmos, mandam clones de si. Que forma melhor para convidar alguém para uma festa do que mandar um convite justamente para a casa da pessoa? É exatamente para isso que serve o operador &. Quando usamos o &, o operador de referência, pegamos o endereço de memória da variável à qual esse operador está sendo atribuída. O trecho de código a seguir tenta exemplificar o caso:

Apesar de por agora ele parecer não ter muita utilidade, o operador & terá um uso significativamente importante no futuro.

3.3 Sizeof()
Quando apresentei o operador *, escrevi um trecho de código que somava todos os elementos de um vetor de tamanho 20 e colocava a soma numa variável chamada soma. Naquele momento, para iterar entre os elementos do vetor, simplesmente supus que o tamanho de um inteiro era de 4 bytes. Assim, a cada iteração, para atualizar o lugar para onde o ponteiro (passar a casa - o ponteiro - de um dono para o outro - os endereços de memória) estava apontando, eu somava 4 ao valor do ponteiro.

O problema é que freqüentemente o tamanho de um int muda, dependendo do compilador usado, das configurações usadas na compilação e da arquitetura para a qual o programa foi compilado. Ao mesmo tempo, seria um problema se, a cada lugar onde eu fosse compilar o programa, eu tivesse que setar todos os tamanhos direitinho. Para resolver o problema, existe a função sizeof(), que retorna exatamente o tamanho do tipo passado como parâmetro. Ela será muito mais útil para outros fins, mais para a frente. Mesmo assim, o exemplo a trecho de código a seguir exemplifica o seu uso e mostra como aquele nosso outro exemplo poderia ser escrito de uma forma mais interessante:

3.4 Passagens de parâmetros
Antes de começar a falar sobre um assunto bastante novo, gostaria de fazer algumas considerações. Em primeiro lugar, caso o leitor seja um programador principiante, sugiro que tente brincar mais com os ponteiros, fazendo códigos próprios e testando os resultados. Sugiro que volte à seção 2.2 e tente reler o código, agora entendendo melhor o que foi dito lá. Em segundo lugar, gostaria de pedir que o leitor testasse cada um dos trechos de código que serão exibidos a seguir, já que podem acabar causando alguma confusão. O assunto, admita-se, é extremamente não intuitivo, apesar de não ser difícil.

Em terceiro lugar, quero fazer uma distinção entre variável e endereço de memória. Uma variável é uma abstração de um ou mais endereços de memória. Como eu já disse, no mundo dos ponteiros, isso equivale a dar chapéus para as pessoas que estão sendo "ocupadas" para uma variável. Mesmo assim, no texto a seguir, os dois conceitos meio que se confundem, no sentido de que "chamar uma variável para uma festa" é o mesmo que "chamar os endereços de memória de uma variável para uma festa", ou, melhor "chamar as pessoas com o chapéu relativo a uma variável para uma festa". Quando falo que uma variável é preguiçosa, quero dizer que "todas as pessoas com chapéus daquela variável são preguiçosas". Com isso dito, creio que podemos ir em frente.

Figura 3.1 - Achei que era interessante por essa figura aqui =D


Como eu disse em outro momento, as pessoas, no mundo dos ponteiros, têm um número indeterminado de clones. Além disso, lá, quando falamos em chamadas de função no mundo real, estamos falando de festas. Para exemplificar o caso, peguemos a seguinte função:

O programador iniciante, ao olhar para essa função, acharia natural que, após a chamada da função, a variável passada como parâmetro tivesse o seu valor incrementado de 1. O que acontece é que, como também já foi dito antes, as pessoas no mundo dos ponteiros são preguiçosas, e, a menos que sejam chamadas em específico elas mesmas, elas naturalmente mandam clones de si mesmas para as festas. Para o mundo real, isso significa que a linguagem C, ao receber uma chamada de função como aquela, copia o valor da variável passada como parâmetro para outro endereço de memória e, em vez de manipular a variável, manipula esse novo endereço de memória, mantendo a variável intacta.

Para exemplificar, utilizemos a nossa função e o seguinte trecho de código:


Para resolver o problema, precisamos de um jeito de chamar a variável certa (e não um clone dela) para a festa. Aliás, as pessoas no mundo dos ponteiros são muito bem educadas: elas SEMPRE vão, quando chamadas, elas mesmas, para as festas. Como eu já disse em outro momento, qual modo melhor para chamar alguém para a festa do que justamente entregar o convite em sua casa? O equivalente a isso, em C, seria modificar a função incrementa de forma que ela recebesse uma casa - um ponteiro - como parâmetro. Assim, na hora da chamada, passaríamos uma casa como parâmetro, fazendo com que a própria variável comparecesse à festa. Veremos como isso ocorreria num exemplo real:


O que houve aqui? Em primeiro lugar, quero avisar ao leitor que ponteiros também são variável, e, como tais, ocupam endereços de memória. Assim, obviamente, casas são formadas por pessoas (o que talvez torne o dono de uma casa um escravocrata, no mundo dos ponteiros). Como casas são pessoas, elas também têm clones. E como elas têm clones, elas também são preguiçosas e às vezes resolvem mandar seus clones no seu lugar nas festas.

Assim, realmente, quando a casa foi passada como parâmetro, ela, em vez de ir para a festa, mandou um clone seu em seu lugar. Dessa forma, se o clone fosse modificado - no caso, não ocorreu, mas ele poderia ter sido -, a casa original permaneceria intacta. Mesmo assim, o conteúdo da casa e do clone eram o mesmo: ambos apontavam para o mesmo endereço de memória. Assim, esse endereço de memória não tinha como fugir: ele FOI sim à festa, tendo sido modificado.

Esse tipo de passagem de parâmetro (mandando a casa da pessoa, em vez de a pessoa) é chamado de passagem de parâmetros por referência, e frequentemente causa problemas até mesmo a programadores um tanto experientes, visto que é muito fácil esquecer um asterisco aqui ou ali.

3.5 Alocando espaço
Um último assunto antes de terminar o capítulo 3: malloc(), free(), e acessos a endereços inválidos de memória.

O leitor deve lembrar que, lá em cima, eu falei sobre o NULL, o cara mais rico do mundo dos ponteiros. Feliz ou infelizmente, a sua fortuna normalmente dura pouco tempo: tão logo ele ganha uma casa, ele logo já perde para um outro alguém. Traduzindo, é uma boa prática de programação atribuir NULL a todos os ponteiros que não podem ser imediatamente inicializados. Mesmo assim, ninguém cria ponteiros se não for usar, a menos que o NULL tenha algum sentido útil no seu programa (e de vez em quando tem).

De qualquer forma, como eu disse, o NULL é um cara rico. Assim, como pessoa rica, pessoas comuns não têm acesso a ele. Dessa forma, se um programador, em seu programa, tentar acessar o endereço de memória NULL, através de um ponteiro, por exemplo (utilizando o operador *, no caso), o seu programa irá travar. Além disso, há uma certa probabilidade de que, se o programa tentar acessar endereços de memória sem chapéu (ou seja, não alocados devidamente para o programa), o programa também trave. Por isso, é altamente recomendado só acessar pessoas com chapéus, já que essas são as já conhecidas pelo seu programa.

O uso de vetores e variáveis torna dar chapéus para os programas uma tarefa significativamente fácil. Mesmo assim, tem vezes em que o programador simplesmente não sabe, por qualquer motivo, quantos chapéus deverá distribuir (ou seja, quantos endereços deverá alocar). Pode ser que o tamanho mude conforme a entrada do usuário, ou mesmo que ele queira alocar o dobro do espaço alocado toda vez que todo o espaço alocado tiver sido utilisado.

Para solucionar esse problema, existe a função malloc(). Ela retorna um ponteiro do tipo void com o espaço alocado. O tipo void* é um tipo especial, na linguagem C. Ele significa "nenhum tipo", ou "qualquer tipo". Assim, podemos atribuir a esse tipo ponteiros do tipo que quisermos. O trecho de código a seguir exemplifica o uso da função (ignore a parte do "free()" por um momento):


Infelizmente, a linguagem C++ (sim, aqui falo do C++) é meio chata com atribuições de tipos diferentes. Assim, para manter a compatibilidade entre as duas linguagens, quando utilizamos malloc(), é interessante colocar um typecast (um aviso de qual é o tipo que queremos que o malloc seja) à esquerda do uso da função. No trecho de código acima, esse typecast é feito através do (int*) à esquerda do malloc(). Como parâmetro, a referida função só exige um número, que significa o espaço em bytes que queremos assim.

No nosso caso, queremos alocar o espaço de 10 inteiros para o ponteiro "meu_ponteiro" (por isso aquela multiplicação dentro do malloc(). Isso não significa que ele guardará todo esse espaço em sua variável. Diferentemente, ao término da atribuição, o ponteiro terá guardado o endereço de memória do início da área alocada. Assim, daquele endereço até o fim da área alocada (no nosso caso, sizeof(int) * 10) todas as pessoas, no mundo dos ponteiros, terão chapéus.

Para desalocar esse espaço, devemos usar a função free(). Ela recebe um ponteiro como parâmetro e simplesmente desaloca aquela área (retira o chapéu das pessoas da área).

Capítulo 4

Finalmente, chegamos ao final do nosso estudo sobre ponteiros. Esse capítulo só trás algumas considerações finais sobre o meu texto, me identifica e mais alguns detalhes.


Todas as imagens do texto foram feitas utilizando o KolourPaint, no Ubuntu. Por esse motivo, os textos nas imagens ficaram sem acento - tentei por acento, não consegui de primeira, desisti. Além disso, tentei e consegui usar o "syntaxHighlight 1.5.1". Depois de conseguir, percebi que realmente o Pastebin tinha um sistema de embedding muitíssimo bom (além de me permitir usar tabulações no código) e passei a usá-lo.

Esse post teve inspiração em várias coisas que andei vendo e pensando nos últimos tempos, mas destaco especialmente a série de aulas de C que o Fialho tem postado em seu blog, Lines of Code, de vez em quando. Além disso, não deixo nenhuma bibliografia utilizada, já que, realmente, não usei nenhuma.

Aviso especialmente que nenhum trecho de código postado foi testado, o que significa que, sim, PODE HAVER ERROS. Dessa forma, peço que o leitor me ajude com correções e sugestões, no caso de querer contribuir.


------------------------
Era isso... se tiverem algo mais com o que contribuir, peço que postem comentários. E, humildemente, peço que, se forem repostar isso em algum lugar (estou meio orgulhosinho do meu texto, e acho que ele ficou bom mesmo, né?), ao menos mantenham o link original de onde foi retirado =D

R$

Nenhum comentário:

Postar um comentário