Executando verificação de segurança...
18

Ponteiros

Ponteiros é o motivo da dor de cabeça de todo estudante que está iniciando no mundo da programação, como também é algo fundamental para estruturas de dados como: Lista encadeada, lista duplamente encadeada, lista circular e árvores binárias. Apesar de em um primeiro momento parecer algo complexo na realidade seu conceito é bastante simples. Ponteiro nada mais é que um tipo especial de variável que armazena um endereço de memória, o endereço de memória, por sua vez, aponta ou referencia uma unidade de memória que armazena um valor.

Geralmente os professores explicam ponteiros usando a linguagem C, porém nesse artigo o foco será entender como ponteiros estão presentes em linguagens mais modernas como: Javascript e Python. Essas linguagens não possuem um tipo denominado ponteiro, porém existe os chamados objetos mutáveis que são arrays e objetos em Javascript ou listas e dicionários em Python.

Obeservação: Quando for mencionado objeto em Javascript ou dicionário em Python, a menção está sendo feito a uma coleção de pares chave-valor, como por exemplo:

{
  "foo": 1,
  "bar": 2
}

Endereço de memória

Pense no endereço de memória como o endereço da sua casa: ambos servem para localizar algo específico. Assim como existem inúmeros endereços que identificam ruas em qualquer país, também existem inúmeros endereços de memória em um computador. A quantidade de endereços disponíveis depende da arquitetura do processador. Atualmente, a maioria dos computadores utiliza uma arquitetura de 64 bits (x64), o que significa que o processador pode, teoricamente, acessar até 2⁶⁴ endereços de memória, resultando em um espaço de endereçamento total de 18 exabytes, porém, na prática o espaço de memória acessível é limitado por fatores como o hardware e sistema operacional.

Os endereços de memória são identificadores únicos para as unidades de memória. Cada unidade possui um endereço, permitindo armazenar, modificar, ler ou remover valores. Variáveis comuns contêm diretamente esses valores, enquanto ponteiros armazenam endereços de memória, permitindo o acesso indireto a esses valores. Em linguagens modernas, não existe o tipo ponteiro tampouco é possível utilizá-los de forma explicita.

Veja a ilustração a seguir:
image_1

A imagem acima ilustra os espaços na memória e os números os endereços, porém na realidade os endereços são representados por algo como 0x7ffee4b1d6a0. O tamanho de cada espaço ou unidade de memória depende da arquitetura do processador, em arquiteturas de 64 bits (x64) o tamanho de uma unidade de memória é de 1 byte (8 bits). Na imagem o espaço identificado com o índice "0" armazena o carácter "a", cada carácter utiliza 1 byte de memória, portanto a variável foo só ocupa uma unidade de memória.

Observação: Dizer que um carácter utiliza 1 byte de memória é verdade para algumas linguagens mas não para Javascript e Python, pois Javascript armazena caracteres em UTF-16, o que significa que cada caractere pode ocupar 2 bytes, enquanto Python suporta codificação Unicode o que faz com que o tamanho de um carácter seja maior que 1 byte.

Ponteiros e arrays

Array é uma estrutura de dados que armazenam uma coleção de valores, onde cada valor é identificado por um índice numérico, o tamanho não é predefinido e os valores que ele armazena podem ser constantemente modificados.

Veja a ilustração a seguir:
image_2

Talvez você esteja se perguntando qual é a relação da palavra "example" com o tópico de arrays. Bem, tem tudo a ver! Na computação, o tipo "string" não existe, na realidade o que existe é o tipo char ou carácter, uma string nada mais é do que uma coleção de caracteres ou, em termos técnicos, um array de caracteres. Em linguagens mais modernas, essa estrutura foi abstraída e transformada em um tipo próprio que é String em Javascript ou str em Python.

No entanto, algo interessante a ser notado na ilustração é que a seta aponta para o caractere "e". Isso ocorre porque variáveis não armazenam o array em si, mas sim a referência ao item inicial do array, ou seja, o que a variável realmente guarda é o endereço de memória do item localizado no índice 0. A partir dessa referência, é possível acessar os valores subsequentes do array, já que os elementos são armazenados em espaços de memória contíguos.

Mutabilidade

O conceito de mutabilidade está ligado à capacidade de modificar o conteúdo de uma variável sem precisar atribuir um novo valor a ela. Na linguagem C, é possível alterar o conteúdo de uma variável dentro de uma função, desde que ela tenha sido declarada em um escopo superior e que seu endereço de memória seja passado como parâmetro. Isso também se aplica a arrays, mas, no caso de arrays, não é necessário fornecer explicitamente o endereço de memória como parâmetro da função, pois, por padrão, passar um array como parâmetro para uma função significa passar a referência do primeiro elemento desse array.

Linguagens mais modernas, como JavaScript e Python, não possuem um tipo de ponteiro explícito como em C, mas é possível simular comportamentos semelhantes usando arrays ou listas, pois o comportamento de referência é o mesmo. Quando um array em Javascript ou uma lista em Python é passado para uma função, o que está sendo passado é a referência ao objeto (o endereço de memória do primeiro elemento). Portanto, as alterações feitas dentro da função refletem na variável declarada fora da função. Esse comportamento é conhecido como passagem por referência. Por outro lado, a passagem por valor ocorre quando objetos imutáveis, como números ou strings, são passados para funções, criando uma cópia do valor original.

O conceito de mutabilidade se aplica até mesmo a constantes. Em muitas linguagens, quando uma constante armazena um array ou lista, o que não pode ser alterado é a referência ao objeto, ou seja, a constante não pode apontar para outro array, mas o conteúdo do array em si pode ser modificado.

Hash Table

Uma tabela hash (hash table) é uma estrutura de dados que armazena pares de chave-valor e permite o acesso rápido aos valores a partir de suas chaves. De maneira resumida, uma tabela hash precisa de uma função de hash (hash function), que transforma as chaves em um valor numérico (chamado de hash) e um array, onde os valores serão armazenados. A função de hash recebe um valor fixo, como uma string ou um número e retorna um número que será o índice onde o valor será armazenado.

Os objetos em Javascript e os dicionários em Python são implementados usando hash table. Essas estruturas de dados também são mutáveis, portanto passar elas como parâmetro significa estar passando a referência do item inicial.

Conclusão

Ao longo deste artigo, foram explorados conceitos fundamentais da programação, como ponteiros, arrays, mutabilidade e tabelas hash, e como eles se manifestam em linguagens de programação modernas como JavaScript e Python. Embora essas linguagens não tenham ponteiros explícitos como em C, o comportamento de referência é simulado por meio de objetos mutáveis, como arrays, listas e dicionários. Compreender esses tópicos não apenas facilita o aprendizado de novas linguagens, mas também melhora a capacidade de resolver problemas de forma eficiente e escrever códigos com mais qualidade e robustez.

Referências

Carregando publicação patrocinada...
7

Só um adendo - e eu sei que o texto está fazendo simplificações para ficar mais didático, mas não tem jeito, eu sou chato e pedante, então vamos lá:

Essa questão do array ser um ponteiro para o primeiro elemento é verdade em C. Mas em JavaScript e Python (e em várias outras linguagens de mais alto nível), não é bem assim. Essas linguagens abstraem as estruturas de forma que, na prática, não necessariamente será um ponteiro direto para o array.

Por exemplo, vejamos a implementação mais comum do Python (que é escrita em C, e chamada de CPython). Neste caso, a lista é implementada como um struct:

typedef struct {
    PyObject_VAR_HEAD
    /* Vector of pointers to list elements.  list[0] is ob_item[0], etc. */
    PyObject **ob_item;

    /* ob_item contains space for 'allocated' elements.  The number
     * currently in use is ob_size.
     * Invariants:
     *     0 <= ob_size <= allocated
     *     len(list) == ob_size
     *     ob_item == NULL implies ob_size == allocated == 0
     * list.sort() temporarily sets allocated to -1 to detect mutations.
     *
     * Items must normally not be NULL, except during construction when
     * the list is not yet visible outside the function that builds it.
     */
    Py_ssize_t allocated;
} PyListObject;

Ou seja, é uma estrutura que contém o tamanho alocado e um array de ponteiros para os elementos da lista. Isso quer dizer que na verdade, por debaixo dos panos, não estamos passando um ponteiro para o array, e sim um ponteiro para o struct (que internamente manipulará o array de fato).

Mas isso é no nível mais interno da implementação. No código Python em si, tudo que vemos são variáveis, que no fundo são referências para objetos (e referência pode ser só o ponteiro/endereço, ou ainda alguma outra estrutura que abstrai esses detalhes). Até mesmo se fizermos x = 1, x será uma referência para o valor 1 (que é implementando como um objeto int). E claro que em outras linguagens pode ser diferente.

Quanto ao fato de ser mutável ou imutável, não tem relação direta com passagem por valor e referência. Vc pode ter tipos imutáveis que são passados por referência, só que aí não vai ser possível mudá-lo porque a imutabilidade está no tipo, não no fato de vc ter uma referência ao mesmo.


Passagem por valor ou por referência

Aliás, sobre passagem por valor e por referência, tem um detalhe bem sutil que muita gente confunde. E pra isso copiei os exemplos abaixo daqui.

Primeiro, este código em C:

void function2(int *param) {
    printf("I've received value %d\n", *param);
    (*param)++;
}

int main(void) {
    int variable = 111;
    function2(&variable);
    printf("variable %d\n", variable);
    return 0;
}

Saída:

I've received value 111
variable=112

Repare que o valor da variável foi alterado dentro da função, dando a impressão de que ela foi passada por referência. Mas na verdade, o que foi passado para a função foi um ponteiro para a variável (&variable, o endereço da variável). E esse endereço é passado por valor (é feita uma cópia do endereço, e a cópia que é usada na função).

O que acontece é que, dentro da função, o operador * desreferencia o ponteiro (pega o valor que está no endereço para o qual ele aponta) e aí o valor que está lá pode ser modificado. Mas isso não quer dizer que a variável foi passada por referência, e sim que o ponteiro/endereço para a variável foi passado por valor.

E como ele comprova isso? Com este código:

void function2(int *param) {
    printf("address param is pointing to %d\n", param);
    param = NULL;
}

int main(void) {
    int variable = 111;
    int *ptr = &variable;
    function2(ptr);
    printf("address ptr is pointing to %d\n", ptr);
    return 0;
}

A saída é:

address param is pointing to -1846583468
address ptr   is pointing to -1846583468

Se o ponteiro fosse passado por referência, então depois da função ele seria nulo, mas não é o caso. Isso porque a função recebe o ponteiro por valor (ela recebe uma cópia do endereço), e qualquer alteração feita neste ponteiro dentro da função não é refletida fora dela, por isso o ponteiro continua apontando para o mesmo endereço depois que a função é chamada. Então em C vc tem apenas a ilusão de passagem por referência (ou uma simulação, feita de maneira "esperta" com os ponteiros).

Aliás, se fizer assim, também dá o mesmo resultado:

function2(&variable);
printf("address ptr   is pointing to %d\n", &variable);

Pois a função recebe uma cópia do ponteiro/endereço, e a alteração feita dentro dela (param = NULL) não se reflete fora dela.


Algo similar ocorre em Java, JS, Pyhton, etc. A função a grosso modo recebe uma referência (outro nome bonito para o ponteiro ou endereço, mas pode ter mais detalhes de implementação que os abstraem), mas na verdade, ela recebe uma cópia desta referência (ou uma cópia do endereço). E como as operações são feitas no endereço, o resultado é o objeto original sendo modificado. Por exemplo, em Python:

def f(x):
    x.append(4)

lista = [1, 2, 3]
f(lista)
print(lista) # [1, 2, 3, 4]

A função recebe a referência à lista (uma "cópia do endereço" dela) e atua em cima desta cópia. O que acontece é que métodos são sempre chamados no objeto para o qual a referência aponta. E como foi feita uma "cópia do endereço", o método é chamado no mesmo endereço do objeto original, e por isso este é modificado.

Mas se fizer assim:

def f(x):
    x = [4, 5, 6]

lista = [1, 2, 3]
f(lista)
print(lista) # [1, 2, 3]

Aí a lista não é modificada. Isso porque a atribuição x = [4, 5, 6] foi feita na cópia, e não na referência original. Afinal, a função recebe uma cópia da referência, e uma atribuição cria um objeto novo (com um novo endereço, e portanto, outra referência). Mas como x é uma cópia, a atribuição a ele não altera a lista fora da função. Para mais detalhes, veja aqui.

E vale lembrar que a função sempre receberá uma "cópia do endereço", mesmo se o tipo for imutável. A imutabilidade não tem relação direta com o tipo de passagem, são caraterísticas independentes.


Então o que é passagem por referência?

Uma linguagem que tem passagem por referência de fato é C#:

public class Test
{
    public static void Main()
    {
        int[] array = { 1, 2, 3 };
        f(ref array);
        foreach (int n in array)
            Console.WriteLine(n);
    }
    public static void f(ref int[] array)
    {
        array = new[]{4, 5, 6};
    }
}

O código acima imprime 4 5 6, pois a função recebe a referência do array (indicado por ref), e não uma cópia. Por isso é possível atribuir outro array dentro da função, e esta mudança se reflete fora dela.

Se removermos os ref's do código, aí passa a ser passagem por valor normal: é feita uma cópia da referência, igual ao exemplo anterior com Python. E por isso a atribuição dentro da função é feita na cópia e isso não altera o array fora da função. Por isso, se remover os ref's, o código imprimirá 1 2 3.

Só pra dar outro exemplo, PHP também tem passagem por referência, bastando colocar & antes do parâmetro:

function f(&$var) { // & indica que $var é passada por referência
    $var = 1;
}

$x = 'oi';
f($x);
echo $x; // 1

Se removermos o & antes de $var, aí deixa de ser passagem por referência e o código imprime "oi".

1
1

De fato como você mencionou a intenção era ser simples e prático, mas isso não muda o fato de que o seu comentário agrega bastante. Parte da ideia do artigo era que outras pessoas também contribuissem, portanto, muito obrigado!

6

Só um pequeno apontamento: a quantidade de endereços que um processador pode endereçar não depende da largura do barramento interno, que é o referido quando se diz em arquitetura de 64-bits por exemplo, isso é definido pela largura do barramento de endereços, que pode ser tanto menor quanto maior. Por exemplo, o processador Intel 8086 que dá origem a família de arquiteturas x86, tinha o barramento interno de 16-bits, mas um barramento de endereços de 20-bits, sendo capaz de endereçar até 2^20 endereços.