Escolar Documentos
Profissional Documentos
Cultura Documentos
| Entendendo C
DESCRIÇÃO
Se você nunca viu C ou como um programa funciona de verdade no baixo nível,
hoje é sua chance de ver todo o básico de uma só vez!
Vou desde tipos primitivos, strings, arrays, stacks, heap, alocação de memória,
structs, até minimamente entender o que está por baixo do que você chama de
linguagem "orientada a objetos".
ERRATAS
em 00:06:50 falei errado o range de INT, o certo é de -128 a 127
em 00:06:06 eu falei certo e deixei a correção escrita errada. 64 bits, se você não precisar, desperdiça 7 bytes
em 00:40:54 eu falei que 255 bytes é 1/4 de 1 megabyte, mas é de 1 KILObyte.
em 00:45:41 eu coloquei hello no sizeof e ele pegou o sizeof do endereço e não do array.
em 00:38:40 eu não sei como deixei passar, mas quando falo de passar o string pras funções f1, f2 não está duplicando toda a string e sim a
referência pra ela. Se fossem valores primitivos como int sim, mas array só o endereço duplica mesmo.
em 00:57:55 quando dei copy e paste pro createPerson, eu esqueci de usar os argumentos pra fazer person.age = age por
exemplo e ficou hardcoded. Viram?? Por isso copy e paste é perigoso, especialmente meia noite depois de estar cansado de passar 3 dias editando
kkkkk
LINKS:
Integer (Wikipedia) (https://en.wikipedia.org/wiki/Integer_(computer_science))
Two’s Complement (Wikipedia) (https://en.wikipedia.org/wiki/Two%27s_complement)
How numbers are encoded in JavaScript (https://2ality.com/2012/04/number-encoding.html#:~:text=JavaScript%20numbers%20are%20all
%20floating,binary%20format%2C%20in%2064%20bits.)
FLOATING POINT VISUALLY EXPLAINED (https://fabiensanglard.net/floating_point_visually_explained/)
What Every Computer Scientist Should Know About Floating-Point Arithmetic (What Every Computer Scientist Should Know About Floating-Point
Arithmetic (oracle.com))
IEEE-754 Floating Point Converter (IEEE-754 Floating Point Converter (h-schmidt.net))
Number.MAX_SAFE_INTEGER (Number.MAX_SAFE_INTEGER - JavaScript | MDN (mozilla.org))
Signed Binary/Decimal Conversion Using the Two's Complement Representation (Signed Binary/Decimal Conversion (ubc.ca))
C - Pointer arithmetic (C - Pointer arithmetic - Tutorialspoint)
Why Discord is switching from Go to Rust (https://blog.discord.com/why-discord-is-switching-from-go-to-rust-a190bbca2b1f)
1
SCRIPT
Olá pessoal, Fabio Akita
Seguindo meus episódios com o tema de fita de bits que venho explicando desde
os episódios de introdução à computação até a história de Turing e Von
Neumann, no penúltimo episódio eu expliquei sobre a diferença entre arquivos
binários executáveis como os de formato ELF e arquivos texto e como tudo são
fitas de bits ou o que eu chamei de linguição de bits. E hoje quero tentar explicar
como podemos representar dados e manipular bits nessa linguiçona pra vocês
conseguirem enxergar um pouco melhor como linguagens de programação lidam
com dados.
Se você foi direto aprender uma linguagem de alto nível como Python ou
Javascript, sem ter aprendido linguagens de baixo nível, vai eternamente ter
dúvidas de "o que" exatamente a linguagem tá fazendo. Por que certos
comportamentos estranhos acontecem do nada. Eu aprendi programação no
começo dos anos 90 com linguagens de alto nível também, no caso foi Basic e
depois dBase. Eu só fui entender como as coisas funcionavam quando comecei a
estudar Ciências da Computação. Muita gente resolveu pular essa etapa e por isso
vai ficar travado muito em breve. No video de hoje eu quero tentar abrir as portas
pra coisas que você raramente vai encontrar num curso qualquer por aí.
(...)
String, em inglês é tipo um barbante, uma corda ou um fio. A forma mais simples
de String é uma cadeia de bytes terminada com nulo de C. Se a gente encodar o
"hello world" usando ASCII ou UTF-8, cada letra será representada por um
código de 8-bits que é 1 byte. Então serão 11 bytes de tamanho mais o nulo no
final. Mas se for em japonês "haroo waarudo", isso é uma String de 7 símbolos,
cada símbolo no alfabeto katakana vai ocupar 3-bytes, então serão 21 bytes de
tamanho. Como expliquei no episódio anterior, quantos bytes cada símbolo
ocupa depende da tabela de conversão entre glifos e caracteres. Esse é o
encoding.
Em algumas linguagens você vai ver duas palavras distintas, um byte e um char.
Se o char for ASCII de 8-bits, então um char é a mesma coisa que um byte na
prática, como é em C. Mas em C# ou Java cada char representa um caractere
UTF-16 que tem 16-bits ou 2 bytes, portanto é um double byte. Não se pode
achar que um char é sempre um pra um com um byte, só quando falamos de C.
Como hoje só vou falar de C, então considere um char sendo 1 byte.
À primeira vista parece uma boa idéia, porque assim toda vez que as CPUs
evoluem, bastaria recompilar o programa e ele automaticamente teria acesso a
mais memória. Mas na prática isso pode levar a diversos tipos de bugs
inesperados. Então quem decide o tamanho dos inteiros de C é o compilador.
Mas nas linguagens mais modernas como C# ou Rust a declaração fica mais
explícito qual tipo de inteiro você tá usando.
A razão de ter diferentes tipos de inteiros é a eficiência. Se você sabe que no seu
programinha não vai precisar de números maiores que duzentos e cinquenta
cinco, basta alocar um int8, ou 1 byte, e vai ser suficiente. Se você alocar um
int64 sem precisar, vai desperdiçar 7 bytes que não vão servir pra nada. Se for
uma variável só, não faz diferença, mas se for um array com milhares ou milhões
de elementos, você vai rapidamente desperdiçar megabytes ou até gigabytes à
toa.
Números inteiros ainda tem outra característica importante. Eles podem ser
signed ou unsigned, ou seja, ter sinal ou não ter sinal. Se declarar um inteiro
unsigned, sem sinal, ele pode ser usado pra números de 0 até hexa FF que é
duzentos e cinquenta e cinco. Mas se for signed pode ir de negativo 127 até
positivo 128. Quase a metade. Nesse caso o primeiro bit, que é chamado de bit
mais significativo, e costuma ficar à esquerda dependendo do endianness do
processador, é onde vai ficar o sinal. Se o primeiro bit for 0 então é um número
positivo. Se for 1, então é negativo.
Daria pra fazer um video inteiro só falando de inteiros negativos e porque eles
são interessantes, e isso é uma das coisas que recomendo pesquisarem a respeito
depois. Mas vamos dizer o seguinte. Pra subtrair dois números com sinal, como
15 e 5, o computador primeiro vai enxergar eles em binário. Em 8-bits, no caso
de 15 seria o binário 0000 1111. No caso de 5 seria 0000 0101. Você já aprendeu
que subtrair um número é o mesmo que somar com seu negativo. Então
poderíamos usar a máquina de somar que já mostrei como funciona no episódio
de introdução mais hardcore a computadores. Reveja se não lembra mais.
Da direita pra esquerda, vamos lá, 1 mais 1 dá 2 que é 1 e 0 então fica zero e sobe
um. Agora 1 mais 1 mais 1 é 3 que é 1 e 1, então fica 1 e sobe 1 de novo. 1 + 1 é
1 e 0 então fica zero e sobe um. De novo 1 mais 1 mais 1 é 3 então fica 1 e sobe
1. Agora tá fácil, 1 mais 1 fica zero e sobe 1. De novo, 1 mais 1 fica zero e sobe
1. Fica zero e sobe um. Fica zero e sobe um.
O último um que subiu a gente descarta. E o que fica no final é 0000 ou seja,
como o primeiro bit é zero, o número é positivo e depois 1010 que é 8 mais 2 que
dá 10. Exatamente 15 menos 5, como você já sabia. E com isso demonstramos
que é possível subtrair números inteiros com sinal usando o método de
complementos e reusando o mesmo circuito da máquina de somar binário.
11111 111
0000 1111
+1111 1011
---------
0
1
0
1
0
0
0
0
As CPUs são feitas de tal maneira pra tirar vantagem de ser base 2 em vez de
base 10 pra conseguir calcular de forma mais eficiente do que fazemos com
número decimais. Multiplicar e dividir é mais complicado e se tiverem interesse
estudem o algoritmo de multiplicação de Booth. Mas tudo isso foi só pra dizer
que existe diferença entre inteiros com sinal e sem sinal.
Se você tentar colocar um número de 128-bits num registrador de 64-bits vai dar
overflow, ou seja, transbordar. Daí esse número seria truncado e metade seria
perdido. Em vez disso, alocamos memória na RAM pra guardar esse número e, a
grosso modo, fazemos a soma com a primeira metade, depois com a segunda
metade e concatenamos o resultado. E sim, você pode fazer a soma de um
número grande como duas somas de números menores, basta carregar o carry bit
pra segunda soma.
Isso dito, falei um monte de nomes que podem parecer complicados mas só
precisa lembrar que são convenções de como representar números inteiros
decimais em formato binário, com limites que vão de 8 até 64-bits dependendo
da arquitetura nativa da CPU, ou mais se usarmos abstrações como BigInteger. E
com esses números podemos fazer praticamente tudo que precisamos. Aliás,
pense que a geração de microcomputadores de 8 e 16 bits foi quase inteira
baseada só em números inteiros. Todo joguinho de nintendinho até mega drive é
feito usando números inteiros.
A tela de um jogo tem 320 colunas por 240 linhas de resolução, que é um número
inteiro. Um sprite de nintendinho tem 8 por 8 pixels ou 8 por 16 pixels. Pra
mover um sprite de lugar basta ir somando de um em um pixel. Os sprites podem
escolher entre paletas de 4 cores dentre 54 disponíveis. Pontuação de jogos,
quantidade de vidas, número de fases, são todos números inteiros.
6
Foi principalmente com o advento de coisas como computação gráfica, animação
em 3D, que números fracionados começaram a fazer diferença no mundo
comercial. Claro, na computação científica já precisavam fazer cálculos
fracionados com muita precisão. Em previsões de meteorologia ou laboratórios.
Mas isso era em nichos mais isolados e restritos com acesso a hardware
especializado. Desde o começo do século XX já haviam formas de calcular
números fracionados. O Z1 de Konrad Zuse, que mencionei na história de Turing
e Von Neumann, já tinha esses conceitos. Inclusive seu Z4 foi o primeiro
computador comercial nos anos 1940 a ter suporte a números fracionados.
Precisamos entender pra que serviam esses co-processadores. Eu disse que até
agora estávamos indo bem com números inteiros. Os CPUs dos anos 80 eram
bons em processar números inteiros. Mas eventualmente a gente precisa lidar
com números fracionados, com decimais. Em particular, números irracionais, ou
seja, números que não podem ser representados por uma fração de números
inteiros. Raíz quadrada de 2, o número Pi e muito mais. O Pi é o inteiro 3, depois
tem uma vírgula e começa uma cadeia infinita de números um, quatro, um, cinco,
nove, dois, seis, cinco, três e assim vai sem padrão nenhum até o infinito.
Se um número com decimais pode ser representado por uma fração, podemos
usar números inteiros. Por exemplo, o número 0 vírgula 33333333 ad infinitum, é
o inteiro um dividido pelo inteiro três. Assim muitos números com casas
decimais infinitas podem ser representadas claramente com números inteiros
finitos. Mas o mesmo não acontece com números irracionais como Pi. E
computadores não tem memória infinita, então não temos como representar
perfeito. Daí precisamos de uma aproximação. E a palavra importante aqui é
7
“aproximação”, o que significa que ele não vai ser armazenado na memória como
você acha que deveria e sim algo perto disso.
Em países como Brasil as casas decimais são separadas por vírgula, mas nos
Estados Unidos e em outros lugares se usa ponto em vez de vírgula. Por isso
falamos em ponto flutuante e não vírgula flutuante. Você já deve ter ouvido falar
do tipo float. O ponto separa a parte inteira do número da parte decimal e num
computador fazemos esse ponto flutuar pra aumentar ou diminuir a precisão. Mas
isso não é a história toda.
O IEEE 754 define vários tipos de float. O mais conhecido hoje é o binary64 que
é um número de 64-bits ou 4 bytes. O primeiro bit é igual de um inteiro com
sinal, ele define o sinal. Depois vêm 11 bits que representam o número do
expoente e os demais 52 bits representam a mantissa. E o que é isso? É notação
científica, mas na base 2. Na base 10 pra representar um número como 15000
poderia ser 1.5 x 104 ou 1.5e4, e esse “e” é de “expoente”. 1.5 é a mantissa e 4 é o
expoente na base 10.
Um float é a mesma coisa, só que seria o primeiro bit de sinal pra positivo ou
negativo, daí 2 elevado ao expoente armazenado nos próximos 11 bits,
multiplicado pelo número representado nos 52-bits seguintes, que é a mantissa. O
número inteiro máximo que pode ser representado nesse formato é 2 elevado a 53
que dá o número 9 quadrilhões e alguma coisa, que em particular, se você é de
Javascript, é o que você já conhece como MAX_SAFE_INTEGER que, em
binário, é representado assim aqui do lado: zero pro sinal pra ser positivo, o
expoente só com o último bit flipado e a mantissa inteira de uns.
0
00000000001
1111111111111111111111111111111111111111111111111111
8
Da mesma forma, o que no Javascript você chama de MIN_SAFE_INTEGER
que é o menor número que se pode representar assim, é só flipar ou inverter todos
os bits do expoente e o primeiro bit significativo do sinal que temos o 9
quadrilhões e tanto negativo.
1
11111111110
0000000000000000000000000000000000000000000000
Mesma coisa se tentarmos converter 0.2 em binário, vai virar zero ponto dois,
zero zero sete vezes de novo, dois nove oito etc. E esse numero float em binário é
esse outro numerozão aqui do lado. E pra finalizar, 0.1 mais 0.2 deveria ser 0.3 se
fosse base 10, mas 0.3 em binário vai ser esse numerozão aqui do lado. Com o
erro de conversão na verdade vai ser zero ponto três zero zero seis vezes um um
nove dois etc. Sempre vai sobrar uma sujeirinha em vez de ser zero zero zero só
zero.
9
Quem já tentou fazer muitas contas com float já se deparou com isso. E pra ser
justo não é só em Javascript. Se você fizer a comparação de 0.1 mais 0.2 com 0.3
vai encontrar que é falso em Ruby, em Rust, em Python. Se não me engano, C# é
uma linguagem que se dá ao trabalho de ajustar esse erro de precisão e devolve
verdadeiro, mas no resto, que só faz a conversão direta pra binary64 do IEEE
754, vai ter esse problema nada intuitivo se você só pensar na base 10 e não na
base 2. Você faz 0.1 mais 0.2 e no final não é exatamente 0.3. E isso é pior no
Javascript por outra razão.
Em Javascript o único tipo numérico que existe é o float binary64. Não existem
inteiros primitivos como mostrei antes. Então, em vez do inteiro máximo de dois
elevado a 64, o máximo vai ser dois elevado a 53 como declarado no tal
MAX_SAFE_INTEGER. Que é um número grande, mas não é o máximo que
sua arquitetura suporta. E se você fizer conta com dinheiro, tipo desconto em
produtos, ou calcular saldo baseado num extrato de conta, não faça contas com
float, porque esse erro de precisão uma hora vai dar ruim. Pequenas sujeiras de
precisão, em volume grande de contas, uma hora vai afetar bastante. No mínimo
multiplique o valor por 100 pra trabalhar com centavos ou trunque depois de
duas casas pra não ter essa sujeira final.
Melhor ainda, justamente por conta de bugs de precisão como esse, existe outro
tipo de dado. Semelhante ao BigInteger que falei antes, a maioria das linguagens
tem um tipo chamado BigDecimal. Assim como o BigInteger, pra fazer contas
aritméticas, vai ser mais lento, porque vai precisar quebrar o numero em
segmentos, fazer aritmética das partes e depois dar um jeito de concatenar o
resultado, porque o numerozão não cabe nos registradores de 64-bits. No caso do
BigDecimal, mesma coisa, ele vai permitir mais precisão e cuidar pra evitar essa
sujeira de conversão.
10
Enfim, o float que a maioria das linguagens usa é o tal binary64, de 64-bits que
eu expliquei. Mas antigamente o que era chamado de float em C era a versão
binary32. E a versão 64-bits era chamada de double. Então se algum dia você ver
double, é o float de 64-bits. De qualquer forma, o importante era explicar que
existia essa forma de se representar números e existem outras, como pra
representar números complexos, mas não é importante pra hoje. Lógico, que
fique de lição de casa pra vocês depois.
Agora vamos voltar ao tal do Array. Array é um conjunto que contém elementos
do mesmo tipo, sequencialmente, um atrás do outro na fita de bits, como por
exemplo array de números int8 ou float. Quando associamos uma variável a um
array na realidade estamos apontando pro endereço do primeiro elemento desse
array. E toda vez que tentamos acessar uma posição específica dentro desse
array, basta pegar o endereço inicial e somar pelo tamanho do tipo do elemento
que ele contém.
Daí você pode ficar confuso, porque num Javascript e outras linguagens, se o
array inicial tem 100 elementos e eu quiser adicionar mais, basta acessar direto a
11
posição 101 e escrever lá. Por exemplo, vamos abrir o console de Javascript em
qualquer navegador e declarar um array chamado lista com umas cinco letras,
como nosso "hello". Se eu tentar acessar a posição 4, vamos pegar a última letra
"o", e é 4 porque todo array que se preza começa da posição 0. Se eu tentar pegar
a posição 5, vai vir undefined porque passei do fim do array. Mas eu posso
tranquilamente gravar uma nova letra como "exclamação" nessa posição e boom,
o array automaticamente “expande” pra comportar a nova letra, mas como isso é
possível se eu falei que arrays tem tamanho fixo?
Na maioria das linguagens mais alto nível que C, tem estratégias, do grego
estrategia. Se eu não disser o tamanho exato que quero, ela vai pré-alocar um
espaço maior do que preciso. Dessa forma, se precisar de mais elementos depois
do fim, tem espaço livre sobrando. E é uma estratégia quando você sabe que tem
mais RAM do que precisa. Mas, se eu realmente chegar ao fim do espaço que a
linguagem alocou pro meu array, o que ela vai fazer é alocar um novo array
maior, copiar elemento a elemento pro novo array e descartar o antigo.
Agora, tudo isso que eu vim falando até agora, array, string, ints ou floats, todos
ocupam algum lugar numa memória, seja RAM, ROM, SSDs ou qualquer outra
coisa. É tudo uma fita de bits, onde cada posição tem um endereço. Zero, um,
dois, e assim vai. Cada valor em uso pode ser encontrado nessa fita com um
12
endereço, é uma localização. O conceito importante aqui é entender que tudo tem
um endereço nessa fita, que pode ser qualquer tipo de memória. Pode parecer
complicado só porque endereços em máquinas de 64-bits podem ir até
numerozões do tamanho de exabytes. E pra piorar escrevemos esses endereços
como hexadecimais. Mas endereços são só isso: números.
Eu sei que tô andando bem rápido e já soltei bastante coisa até agora, mas se
segurem que agora que a coisa vai ficar interessante. Recomendo que depois
assistam tudo de novo com calma se isso é novo pra vocês, mas agora é uma boa
hora pra eu descer mais ainda na escovação de bits. Uma coisa que não é claro
pra iniciantes é onde ficam todas essas variáveis ao longo da execução de um
programa. Então vamos partir de um exemplo bem besta em C. E não se
preocupem se você nunca usou C, mesmo assim você deve ser capaz de
acompanhar.
O binário ELF tem um endereço específico onde fica a primeira instrução que vai
começar a executar. No caso é o hexa 0x1000. Esse endereço é gravado no
registrador chamado PC, que é o program counter ou contador de programa. É a
partir disso que o CPU vai executando a instrução que tá apontado nesse
contador. Uma vez executado o contador incrementa o endereço e aponta pra
próxima instrução na fita de bits.
Bem a grosso modo, só pra ficar mais fácil de explicar, é como se esses
endereços fossem os números da linha do código C no nosso editor de textos.
Claro que não é, porque o que tá no binário não é o código que digitamos e sim o
que o compilador traduziu pra linguagem de máquina, o linguição de bits. Mas
pra facilitar a explicação, pense que no tal endereço 0x1000 ele pula ou dá JUMP
pro endereço da função main do C e vai executando uma linha de cada vez dessa
função. Sempre que eu me referir a endereço de programa como sendo o número
da linha, é só uma metáfora pro endereço hexadecimal da instrução equivalente
na memória de verdade.
#include <stdio.h>
13
void f2(char hello[]) {
printf("from f2: %d\n", &hello);
printf("%s\n", hello);
}
void main() {
return;
}
15
programas de usuário, tipo seu navegador, o Spotify e tals, começa no início da
metade baixa, mais próxima dos endereços que começam com zero.
De qualquer forma, isso é a memória real. Mas como expliquei nos episódio de
gerenciamento de memória, um processo rodando enxerga memória virtual, que é
o sistema operacional mentindo pros nossos programas como se eles tivessem
acesso a toda a memória teórica de 64-bits. É o que chamamos de memória
virtual. Dentro desse espaço virtual, os endereços que começam do zero e vão
subindo é o que chamamos de memória dinâmica, ou "heap" que é pra onde vai a
maioria dos dados que seu programa carrega. Quando você carrega um arquivo
em memória, ou quando se conecta num servidor de API e puxa JSON, ou
quando pede dados no banco de dados, tudo vai pro heap. Heap em inglês é um
montão, um amontoado.
Repetindo, lá no topo, nos endereços que começam com bit 1, é onde fica o
segmento de memória reservada pra kernel, que é uma área exclusiva pra
endereços de coisas do sistema operacional, mas o próximo segmento é o que
chamamos de pilha ou "stack". Se eu não tô muito enganado, todo processo
começa com pelo menos 8 megabytes de memória alta reservada pra stack e ela
pode expandir decrementando endereços pra baixo. Pode consumir até uns 2
gigabytes.
A heap é a mesma coisa mas do outro lado, começa na memória baixa já com
pelo menos 1 megabyte reservado e se precisar de mais vai incrementando
endereços pra cima. Lembre-se que o stack de um nintendinho de 8-bits tinha
meros 255 bytes, que é do endereço hexa $0100 até $01FF.
Eu explico pra que serve essa stack no episódio da introdução mais hardcore a
computadores mas vamos resumir usando o exemplo do hello world que fiz
16
agora. Eu tenho 3 funções, a main, a f1 e a f2. Cada uma delas aloca espaço pra
duplicatas da string hello world, daí imprimem o endereço onde a variável foi
alocada e no final sai fora e retorna pra função que chamou ela. Aliás, em C, se
uma função tem como tipo de retorno um void que é tipo nada em C, então nem
precisa dar return, porque não estamos retornando nenhum valor. Vamos na
ordem.
Ao executar o binário ele pede pra kernel do sistema operacional alocar espaço
pro novo processo e inicializar seja lá o que ele precisa. A kernel vai usar o
gerenciador de memória e alocar o mínimo necessário pro programa rodar e
depois vai dando memória real pra ele à medida que for pedindo.
Depois ele pula pra primeira função que é a main. Agora cria a
variável hello que é um array de chars com 12 bytes de tamanho. Isso é alocado
na Stack. Agora eu chamo a função do C chamado printf. Não vou explicar
como o printf funciona, mas vamos aproveitar pra explicar a mecânica dessa tal
stack ou pilha. Toda chamada de função começa guardando o endereço atual de
execução, o que está no tal contador de programas. No nosso pseudo-exemplo de
endereços seria o número da linha do nosso código onde chama a função.
De volta ao main a próxima linha chama a nossa função f1. Mesma coisa, vamos
empilhar o endereço dessa posição na stack. Como eu disse antes, ao passar a
variável hello como argumento, estamos duplicando a variável, então vai
empilhar essa duplicata na stack também. Dentro de f1 vai chamar printf de
novo pra imprimir o endereço dessa duplicata, por isso temos valores diferentes
de endereços.
17
Já sabemos como isso funciona, então vamos pular os detalhes de printf pra não
ficar repetitivo. Pode assumir que empilhamos e desempilhamos printf. Em
seguida o f1 vai chamar a f2, e novamente o endereço dessa linha vai ser
empilhado na stack, a variável hello vai ser duplicada e empilhada na stack
também, e de novo vamos chamar printf. por fim vai ter um return.
A razão dessa explicação longa foi pra mostrar no nível mais básico o que
significa chamar funções e como variáveis são gerenciadas na stack. Toda vez
que você chama funções e passa argumentos pra ela, esse registro de execução
vai sendo empilhado na stack, que é uma pilha. Pilha é uma estrutura de dados
super importante e uma das mais fundamentais junto com Queues, que é inglês
pra filas. Elas são parecidas, mas não vou falar de queues hoje. E pilhas existem
dois tipos, FIFO e LIFO.
A pilha que estamos usando aqui é do tipo LIFO, ou seja, Last in, first out. Então
o último elemento que empilhamos é o primeiro que vai ser desempilhado. Esse é
mais um tema que você precisa estudar em detalhes, como é implementada e
como funciona em todos os casos. É uma das lições da matéria de Estrutura de
Dados e Algoritmos que qualquer faculdade ensina e mesmo se você for
autodidata deveria estudar.
De qualquer forma, você vai se lembrar que eu falei que essa stack costuma
começar com um tamanho de 8 megas e pode expandir até uns 2 gigas. Isso é
bastante pra programas de qualquer tamanho. Repetindo, um jogo de
Nintendinho inteiro, seja Super Mario, Zelda ou Final Fantasy conseguia se virar
com uma stack de meros 255 bytes, isso é um quarto de um mísero megabyte.
Estourar esse stack é sinal de má programação e acredite, não é difícil estourar a
stack. E quando ela estoura, seu programa crasheia.
O jeito mais fácil de estourar é via recursão. Recursão é uma função ficar
chamando ela mesma sem parar. Vamos dar um exemplo, podemos fazer uma
função chamada f3 como essa aqui do lado, e ela ficar chamando ela mesma.
Além disso, nessa função f3 vou ficar passando o argumento hello toda vez.
18
Cada chamada a f3 vai duplicar a string de hello world na stack. Daí vamos
imprimir o endereço que cada uma delas tá ocupando na stack.
19
Mas além da recursão temos outro problema nesse exemplo besta. Toda vez que
chamamos a função f3 recursiva, passamos a string de hello world e ela é
passada por valor, ou seja, é duplicada. Cada chamada recursiva que fazemos, no
mínimo, tá empilhando o endereço pra onde o return vai dar jump de volta, que é
um endereço de 64-bits ou 4 bytes, “mais” a duplicata de hello world que custa
12 bytes. Só isso são no mínimo 16 bytes a cada chamada.
Ou seja, dos 2 gigabytes de stack, talvez uns 3 quartos tão ocupados só com
duplicatas de hello world, que é um grande desperdício. Uma string idealmente
deveria ser imutável. Quando precisamos modificar a String ou concatenar mais
texto nela, aí sim precisaríamos fazer uma duplicata. Mas se a variável vai
permanecer sempre a mesma como uma constante, não precisaríamos ficar
duplicando toda vez que passamos pra outra função. Imagina se em vez de hello
world essa string fosse um texto enorme, uma ou mais páginas da Wikipedia,
ocupando 1 mega, ou talvez 1 giga. Em duas chamadas de função duplicando a
variável passando por valor ia acabar a stack. Então o que fazemos?
Vamos nos lembrar que eu falei que a stack é só um dos segmentos da memória
total. A memória virtual total de 64-bits caberia até 16 exabytes de dados. Meros
1 giga não é nada. Eu poderia duplicar um texto de 1 gigabyte trilhões de vezes
até acabar a memória toda. Mas no nosso caso o segmento da stack acaba em 2
gigabytes. Então como fazemos?
Agora vem a parte mais complicada. Vou tentar explicar pra vocês o temido
Pointer ou apontador. Parece difícil mas o básico é bem simples, na real. Em vez
de alocar espaço na stack pra guardar meus strings ou mesmo qualquer outro
array gigante, vamos alocar ela no heap. Já expliquei antes que o Heap é o
segmento da memória total que começa nos endereços mais baixos perto de zero
e cujos endereços vão incrementando.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void main() {
return;
}
Poderíamos passar direto o número 12, também. Mas o que o malloc vai fazer é
procurar um segmento contínuo de 12 bytes no heap e passar pra gente o
endereço que ele reservou no heap. E essa nova variável hello2 vai guardar esse
endereço, que é um número de 4 bytes. Então esse hello2 é um ponteiro porque
seu endereço aponta aonde vai estar o string na Heap. É só isso.
Aqui a coisa pode ficar mais cabeluda ainda, então prestem atenção. Com esse
endereço em mãos podemos usar outra função do C, o strcpy ou string copy,
passando como argumentos esse endereço e a variável hello antiga pra copiar o
hello world pra esse novo espaço na heap. Essa é a única duplicata que vamos ter
a partir de agora. A parte importante é entender que na variável hello2 nós não
temos a string hello world e sim o endereço pro primeiro byte na heap onde
vamos encontrar essa cópia do hello world.
Sendo o hello2 um ponteiro com um endereço e sabendo que cada char da string
tem exatamente um byte e sabendo que ela acaba quando encontramos o caracter
21
NULO, se fizermos outro ponteiro chamado hello3 que é hello2 mais 6, agora
vamos ter só a palavra World se dermos printf nela. Vamos compilar e rodar pra
ver. Olha como imprime na tela o Hello world do ponteiro hello2 e só world do
ponteiro hello3. Mas a gente não criou ou duplicou uma nova string, estamos
apontando pro mesmo espaço na memória só que 6 bytes mais pra frente.
Essa sintaxe ou notação de asterisco é pra indicar à linguagem que essa variável é
um ponteiro e queremos acessar o valor gravado no heap, nesse exato endereço.
Se não colocarmos o asterisco, vemos direto só o endereço. Se colocarmos o
asterismo ele trás o valor pra onde esse endereço tá apontando. Tanto as
variáveis hello2 quanto hello3 parecem representar um string diferente mas na
verdade são só endereços ou, mais corretamente, referências, ao mesmo string, só
apontando pra posições diferentes no mesmo string. Pra acessar o string em si,
usamos asterisco hello2 e asterisco hello3.
Quando fazemos aquela notação que tem em toda linguagem, com brackets ou
colchetes, com um número no meio, a linguagem tá traduzindo por baixo em uma
soma de endereço, como eu mostro aqui no código. É o endereço inicial, mais o
tamanho de cada elemento multiplicado pela posição. Vejam que estou repetindo
essa operação pra tentar deixar bem claro esse funcionamento.
Tem uma última estrutura de dados que eu quero explicar pra vocês hoje. Como
eu repeti de propósito várias vezes até agora, arrays são listas que tem sempre os
mesmos tipos de elementos, ou mais corretamente, que tem elementos de mesmo
tamanho. Se for um string, é um array onde cada elemento tem 1 byte, se for um
array de inteiros de 64-bits, todo elemento tem exatamente 4 bytes. E eu vim
repetindo a mesma coisa sobre elementos iguais por causa do seguinte: e se eu
quiser uma sequência onde cada elemento tem tamanhos diferentes?
Pra isso, em linguagens de alto nível tipo Python existe a tupla ou tuple, ou no
caso de C existe Struct. Em conceito não são a mesma coisa, mas pra hoje pense
que é. Vou explicar porque. Diferente de um array onde cada elemento em
sequência tem o mesmo tamanho, num struct ou tupla podemos declarar tipos
diferentes que vão ser concatenados um atrás do outro no linguição de bits.
22
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <inttypes.h>
struct Person {
char nome[10];
uint8_t age;
uint8_t height;
};
void main() {
struct Person person;
strcpy(person.nome, "Fabio");
person.age = 43;
person.height = 172;
printf("%x\n", &person);
return;
}
Por exemplo, podemos declarar uma Struct ou estrutura chamada Person, cujo
primeiro elemento vai ser um array de chars de 10 bytes, um nome curtinho. O
segundo elemento vai ser um int de 8 bits, ou 1 byte, chamado age, pra ser a
idade dessa pessoa. E finalmente o terceiro elemento vai ser a altura ou height,
que vai ser um inteiro de 8 bits também.
Essa estrutura define um tipo de dados novo, chamado Person, cujo tamanho
total sempre vai ser de 12 bytes. Agora eu posso criar uma variável que vai ser
esse tipo e o C vai preencher os valores que vamos passar na sequência correta na
fita de bits. Se eu fizer person.age igual a 43, person.height igual a 172
e person.name igual a "Fabio", a cadeia de bits vai ficar exatamente assim em
hexadecimal. Lembrando que cada dois dígitos em hexa representa 1 byte,
começamos com o hexa quatro seis que é a letra "F", daí 0x61 que é "a" daí
chega uma sequência de zeros porque "Fabio" tem menos de 10 bytes.
46 61 62 69 6f 00 00 00 00 00 2B AC
O penúltimo hexa 2B é a idade 43, e o último hexa AC é 172. A Struct meio que
serve como um molde, que encaixa exatamente nessa sequência de bits e quebra
os valores pra dentro dos campos que criamos. No caso de idade e altura, como
são inteiros de 8 bits sem sinal, sabemos que podemos ter idades de zero até
duzentos e cinquenta e cinco e alturas de no máximo duzentos e cinquenta e
cinco centímetros, e tirando alguma raríssima exceção, tipo se você for mais alto
que o Hulk, isso cobre qualquer pessoa do mundo.
23
Única coisa nesse trecho de código que talvez deixe você confuso é porque
podemos fazer person.age igual 43 mas não podemos fazer person.name igual
"Fabio" e em vez disso eu usei a função que copia strings de duas posições da
memória, que é essa strcpy. Só entenda que é assim que se faz em C, mas em
outras linguagens como Javascript você faz do jeito mais simples de só usar
"igual". Coisas desse tipo que linguagens de mais alto nível facilitam. É o que eu
chamaria de ergonomia da linguagem.
Mas com o que eu expliquei até agora, você conhece os principais tipos
numéricos primitivos como integers e floats, arrays e strings, e agora structs. Um
Tuple é como se fosse um Struct anônimo, sem nomes e etiquetas, parecido com
um array. Aliás, toda vez que você ver um Tuple num Python da vida, vai achar
que é a mesma coisa que um array. Uma Tupla é um conjunto imutável com
elementos de tipos diferentes, só isso. Um Array é um conjunto mutável de
elementos do mesmo tipo. Pra extrair os elementos de uma tupla, você precisa de
um molde que declara qual o tamanho de cada elemento, um molde que vai ser
parecido com um Struct.
Estamos chegando no final, e agora com tudo que aprendemos, só falta mais um
truque pra vocês começarem a entender melhor como os programas funcionam.
Vocês meio que já devem ter entendido que funções nada mais são que
sequências de instruções que ficam localizados em algum endereço na memória.
A CPU vai executando “linha a linha” e quem dita qual linha é o tal contador de
programas. Daí quando uma função chama outra, ela primeiro faz bookmark
desse contador na stack, e dá JUMP pro endereço dessa outra função.
Repetindo, é como se cada uma dessas linhas de código ficasse num endereço na
memória. Estamos simulando como se fossem as linhas do código no editor de
textos. Quando na função main lá embaixo eu chamo a função f1, e o compilador
do C gera o binário, ele vai substituir por algo semelhante a um jump ou
mesmo goto em linguagens mais antigas, pro endereço da linha da função f1. No
comentário é um pseudo-código de como seria se o C tivesse uma função
chamada jump.
E isso é uma das razões de porque precisamos de compiladores. Ia ser chato pra
caramba ter que ficar lembrando o endereço de cada função que precisamos. E aí
a gente resolve mexer na função, muda de lugar, e isso muda o endereço, daí tem
que mudar todo lugar que chama esse endereço na mão. Puta trampo. Em vez
disso usamos nomes que representam esses endereços, como nomes de função e
24
nomes de variáveis, e deixamos o compilador traduzir isso em endereços no
binário final.
Mas como não são necessários pra rodar o programa e sim pra debugar depois,
não é bom guardar tudo porque seria um desperdício de espaço. Se você já
mexeu com coisas como XML ou JSON, eles são grandes e desperdiçam bastante
espaço, primeiro porque representam tudo como strings e segundo porque levam
os metadados de tudo junto com os dados. Mas isso é um detalhe que vou
explorar em outro episódio. Aliás, é por isso que um Google inventou coisas
como Protobufs, se você estiver interessado em mais um assunto pra estudar.
A parte importante é que tudo tem endereços, não só variáveis mas funções
também. Em particular, em C, podemos pegar a referência de uma função e
passar como argumento pra outra função, da mesma forma como eu passei o
endereço da variável hello pro printf imprimir na tela, lembra? Por exemplo,
vamos reorganizar esse código de criar struct numa função nova
chamada createPerson onde passamos como argumentos o nome, idade, altura e
um último argumento que é um ponteiro pra uma função que não retorna nada,
então void mas tem como argumento um struct de Person.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <inttypes.h>
struct Person {
char nome[10];
uint8_t age;
25
uint8_t height;
};
(*function_pointer)(person);
}
void main() {
createPerson("Fabio", 43, 172, &printPerson);
createPerson("Fabio", 43, 172, &printPerson2);
return;
}
Ela só sabe que vai receber uma referência que pode ser pra qualquer outra
função. Se a createPerson chamasse direto a printPerson ela precisaria ter sido
26
declarada antes. Mas vamos ver a seguir como eu passo a referência dessa função
pra createPerson.
Isso de uma função ser passada como referência pra outra função é o que você já
deve ter ouvido falar como um callback. É uma função que vai ser chamada
"call" de volta "back". Qualquer um que tenha se interessado por programação
funcional sabe que uma característica importante é ter funções que recebem
outras funções como argumento. A função createPerson é o que em funcional o
povo chama de "High Order Function".
27
Na teoria, sim, você poderia escrever código C do jeito “funcional”, mas
ninguém faz isso porque não é prático. Mas com isso você pode imaginar como
C poderia ser usado pra criar uma linguagem funcional como um Lisp da vida. É
assim que a gente começa a manipular uma linguagem pra aceitar paradigmas
que ela não foi projetada pra fazer. E com o que aprendemos até agora eu já
posso mostrar mais um passo adiante.
Vamos entender o primeiro passo que leva C pra uma linguagem orientada a
objetos como C++ ou Objective-C. O que eu vou mostrar a seguir não é a
implementação da verdade, mas só um rascunho de como poderia ser. Quando eu
penso em orientação a objetos só penso em duas coisas: um ponteiro pra uma
struct e ponteiros pra funções cujo primeiro argumento, o que você aprendeu
num Python ou Javascript como self ou this, sendo um ponteiro de volta pra
mesma struct.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
28
#include <inttypes.h>
#define Class struct
Class Person {
char nome[10];
uint8_t age;
uint8_t height;
void(*show)(Class Person *);
};
self->show = &person_print;
return self;
}
void main() {
Class Person *person2 =
(Class Person *) newPerson("Fabio", 43, 172);
person2->show(person2);
return;
}
Agora vamos usar isso na função main. Primeiro vamos armazenar o ponteiro que
a createPerson vai criar numa variável que é um ponteiro, um asterisco person.
E passamos os valores que queremos como argumento a esse construtor. E pra
imprimir esses valores na tela podemos chamar direto o método deste “objeto”. E
pra quem não sabe, “método” é o nome que damos a funções associadas a um
objeto. O método show já foi configurado no construtor pra ser um ponteiro
pra printPerson.
Se você aguentou até aqui, espero que tenha conseguido minimamente visualizar
o que são os diferentes tipos de dados, as diferenças entre inteiros e floats, arrays,
tuples e structs. Como dados são alocados na stack que é a pilha e como a
execução de um programa com funções que chamam outras funções vai sendo
empilhado e desempilhado da stack. Como tudo tem endereços ou referências,
incluindo funções.
Só que garbage collector não faz milagres. Ele sempre vai reservar mais memória
do que realmente precisa e sempre vai causar pausas na execução do programa
pra fazer essa limpeza de tempos em tempos. Mesmo se você tiver memória
sobrando pra desperdiçar, essas pausas pra limpeza sempre vão ser um problema.
E é o que linguagens como Swift ou Rust ou C tentam evitar. Por isso são
melhores pra fazer coisas como sistemas operacionais, drivers ou coisas de mais
baixo nível que vão ser eficientes no uso de recursos e evitar pausas de
manutenção o máximo possível. O mesmo não se pode dizer de Java, C# ou Go.
Você só escreve a cola em Python, que é uma sintaxe mais simples, pra mexer
com funções mais complicadas que estão escritas em C ou C++. Por exemplo, em
machine learning, Python não é importante porque as funções de um Tensorflow
são todas escritas em C++. Se você usar SciPy pra computação científica, de
novo as funções são todas em C++ ou Fortran. Nenhuma linguagem cola é rápida
o suficiente pra essas coisas. São boas como cola, pra consumir essas coisas mais
fácil.
Elas sempre vão ser só a cola pra sistemas feitos em C++ ou C. Por isso eu
recomendo que você saiba no mínimo uma linguagem de mais baixo nível como
C ou Rust. E junto uma linguagem cola pra ter produtividade como Javascript ou
Python. Quem só sabe linguagens "cola" sempre vai achar que o que acontece no
baixo nível é "mágica".
31
E com o episódio de hoje eu espero ter conseguido quebrar um pouco dessa
"mágica" e fazer vocês enxergarem suas linguagens favoritas com outros olhos.
Claro, tudo que eu falei rápido em um episódio é o que se ensina em no mínimo
um semestre em ciências da computação, talvez um ano ou até mais.
Links:
Integer (Wikipedia) (https://en.wikipedia.org/wiki/Integer_(computer_science))
Two’s Complement (Wikipedia) (https://en.wikipedia.org/wiki/Two%27s_complement)
How numbers are encoded in JavaScript (https://2ality.com/2012/04/number-encoding.html#:~:text=JavaScript%20numbers%20are%20all
%20floating,binary%20format%2C%20in%2064%20bits.)
FLOATING POINT VISUALLY EXPLAINED (https://fabiensanglard.net/floating_point_visually_explained/)
What Every Computer Scientist Should Know About Floating-Point Arithmetic (What Every Computer Scientist Should Know About Floating-Point
Arithmetic (oracle.com))
IEEE-754 Floating Point Converter (IEEE-754 Floating Point Converter (h-schmidt.net))
Number.MAX_SAFE_INTEGER (Number.MAX_SAFE_INTEGER - JavaScript | MDN (mozilla.org))
Signed Binary/Decimal Conversion Using the Two's Complement Representation (Signed Binary/Decimal Conversion (ubc.ca))
C - Pointer arithmetic (C - Pointer arithmetic - Tutorialspoint)
Why Discord is switching from Go to Rust (https://blog.discord.com/why-discord-is-switching-from-go-to-rust-a190bbca2b1f)
tags: akitando gcc estrutura de dados linguagem c
32