Introdução


O capítulo 2 retoma diversos conceitos já vistos anteriormente, como tipos e conversões de tipos, variáveis, constantes, expressões e precedência de operadores. A diferença é que agora esses temas são explorados em um nível mais profundo, como se estivéssemos investigando os detalhes de como eles realmente funcionam.

Nesse post, vou focar nas partes que considero mais interessantes e específicas da leitura: alguns exercícios, certas sacadas de implementação e os operadores bitwise.

Conversão de tipos


Tipos em C não são só declarações — eles determinam como você interpreta dados e como você reconstrói valores a partir deles.

Para o computador, um número digitado pelo teclado não nasce como um número.

Quando escrevemos 123, o programa recebe três caracteres: '1', '2' e '3'. Transformar essa sequência em um único valor inteiro é responsabilidade do programa.

Os algoritmos atoi e lower, apresentados pelo K&R, mostram como conversões podem ser utilizadas em programas.

Exemplo: atoi (simplificado)

ASCII to integer (atoi) é um exemplo clássico na ciência da computação. Sinceramente, achei o algoritmo fascinante — a partir dele, uma sequência de ideias sobre conversão de dados começou a surgir na minha cabeça, mostrando como computadores são essencialmente máquinas de transformação de representações.

Embora o algoritmo seja simples, é importante reconhecer que a ideia por trás dele não é óbvia.

c
/* atoi: convert s to integer */
int atoi(char s[])
{
    int i, n;
    n = 0;
    for (i = 0; s[i] >= '0' && s[i] <= '9'; ++i)
        n = 10 * n + (s[i] - '0');
    return n;
}

O algoritmo repete a ideia do capítulo 1 de converter um valor da tabela ASCII para o inteiro correspondente subtraindo '0'. Ao mesmo tempo, ele traz uma sacada aritmética importante: o número não é convertido de uma vez, mas construído incrementalmente na base 10 a cada iteração. Por isso, a linha n = 10 * n + (s[i] - '0') é necessária.

Essa construção pode parecer estranha à primeira vista para quem ainda não está familiarizado com esse tipo de manipulação aritmética, especialmente envolvendo ASCII e strings. O que acontece é que várias camadas estão ocorrendo ao mesmo tempo: a leitura de caracteres, a conversão de cada dígito e a reconstrução do número final em base 10.

Exemplo: lower

O objetivo do exemplo não é mostrar que um caractere se torna um número, mas que, em C, caracteres podem ser tratados como valores inteiros durante expressões. Antes de realizar comparações e operações aritméticas, o compilador converte automaticamente os valores do tipo char para int, permitindo manipulá-los por meio de seus códigos ASCII.

c
/* lower: convert c to lower case; ASCII only */
int lower(int c)
{
    if (c >= 'A' && c <= 'Z')
        return c + 'a' - 'A';
    else
        return c;
}

A linha mais importante é: return c + 'a' - 'A';.

Antes de aplicar essa expressão, a função verifica se o caractere está entre 'A' e 'Z'. Isso garante que apenas letras maiúsculas sejam convertidas. Qualquer outro caractere é retornado sem alterações.

Na tabela ASCII, as letras maiúsculas e minúsculas ocupam posições diferentes. A letra 'A' possui o código 65, enquanto 'a' possui o código 97. A diferença entre elas é de 32 posições, e essa mesma diferença se repete para todas as letras do alfabeto.

text
'A' = 65    'a' = 97    → diferença = 32
'B' = 66    'b' = 98    → diferença = 32
...
'Z' = 90    'z' = 122   → diferença = 32

Como exemplo, suponha que a entrada seja a letra 'C', cujo código ASCII é 67:

text
67 + (97 - 65)
67 + 32
99

O código 99 corresponde ao caractere 'c'. É assim que a função converte uma letra maiúscula em sua equivalente minúscula utilizando apenas operações aritméticas sobre os códigos ASCII.

Exercício 2-3. htoi(s)...

Write a function htoi(s), which converts a string of hexadecimal digits (including an optional 0x or 0X) into its equivalent integer value. The allowable digits are 0 through 9, a through f, and A through F.

O exercício pede algo semelhante ao exemplo anterior, atoi, mas agora em vez de converter uma string decimal (ASCII numérico) para inteiro, a função deve converter uma string hexadecimal para seu valor inteiro correspondente.

A lógica é essencialmente a mesma, mas com uma camada extra de interpretação de base numérica. Para mim, esse exercício funciona quase como uma extensão natural do atoi, e passa a sensação de que estamos começando a entender como diferentes representações de dados podem ser convertidas entre si de forma sistemática.

Vale dizer que, embora a lógica seja semelhante à de atoi, esse exercício se mostrou mais desafiador, principalmente por conta das nuances que aparecem no meio do caminho — desde o reconhecimento do prefixo hexadecimal até a conversão de caracteres além de '0' à '9'.

Exemplo: se o input é 0x1A3 - o output deverá ser 419.

Solução

c
/*Exercise 2-3. Write a function htoi(s), which converts a string of hexadecimal digits
(including an optional 0x or 0X) into its equivalent integer value. The allowable digits are 0
through 9, a through f, and A through F.*/

#include <stdio.h>

int htoi(char s[]);

int main() {
    printf("%d\n", htoi("0x1A3"));   /* should print 419 */
    printf("%d\n", htoi("ff"));      /* should print 255 */
    printf("%d\n", htoi("0xFF"));    /* should print 255 */
    return 0;
}

int htoi(char s[]) {
    int i;
    int n = 0;

    if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) {
        i = 2;
    }
    else {
        i = 0;
    }

    for (; (s[i] >= '0' && s[i] <= '9') || (s[i] >= 'a' && s[i] <= 'f') || (s[i] >= 'A' && s[i] <= 'F'); i++) {
        if (s[i] >= '0' && s[i] <= '9') {
            n = n * 16 + (s[i] - '0');
        }

        else if (s[i] >= 'a' && s[i] <= 'f') {
            n = n * 16 + (s[i] - 'a' + 10);
        }

        else if (s[i] >= 'A' && s[i] <= 'F') {
            n = n * 16 + (s[i] - 'A' + 10);
        }
    }
    return n;
}

O que mais chamou a minha atenção neste exercício foi a linha (e sim, essa é uma única linha):

c
for (; (s[i] >= '0' && s[i] <= '9') ||
       (s[i] >= 'a' && s[i] <= 'f') ||
       (s[i] >= 'A' && s[i] <= 'F'); i++)

A partir dela, comecei a enxergar como expressões em C são mais poderosas do que parecem à primeira vista: elas podem ser combinadas para descrever condições bastante complexas de forma direta.

Provavelmente essa é a parte mais "carregada" da solução do exercício. O restante é basicamente uma extensão da ideia de atoi, ou o uso do mesmo truque de conversão entre valores ASCII e seus equivalentes numéricos, como na função lower.

Acima dela temos:

c
if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) {
    i = 2;
}
else {
    i = 0;
}

A ideia é simples, e atende uma condição do exercício:

including an optional 0x or 0X

Basicamente, estamos verificando se a string começa com o prefixo hexadecimal. Se os dois primeiros caracteres forem '0' e 'x' (ou 'X'), então o índice i é ajustado para 2, pulando esses caracteres. Caso contrário, começamos do índice 0.

As linhas finais n = n * 16 + (s[i] - '0'); e n = n * 16 + (s[i] - 'a' + 10); seguem a mesma lógica de atoi. A variável n é multiplicada por 16 porque estamos trabalhando com base hexadecimal, ou seja, uma base com 16 símbolos possíveis (0–9 e A–F).

Em seguida, o caractere atual é convertido para seu valor numérico. No caso de '0' a '9', isso é feito com (s[i] - '0'), que transforma o caractere ASCII no seu valor inteiro correspondente.

Para letras, a ideia é semelhante: (s[i] - 'a' + 10) transforma 'a' em 10, 'b' em 11, e assim por diante. Isso funciona porque subtraímos 'a', normalizando o intervalo para começar em 0, e depois somamos 10 para encaixar na representação hexadecimal.

Considerando o input exemplo 0x1A3, a construção do número ocorre da seguinte forma:

text
i = 2: s[2] = '1'  →  n = 16 * 0  + 1   =   1
i = 3: s[3] = 'A'  →  n = 16 * 1  + 10  =  26
i = 4: s[4] = '3'  →  n = 16 * 26 + 3   = 419

Operadores bitwise 💀


Quando li a seção de operadores bitwise no K&R, achei o conteúdo denso, compacto e distante da realidade do dia a dia da programação. Afinal, manipular dados bit a bit não é algo que a maioria dos programadores faz com frequência.

Resumindo bastante, existem seis operadores bitwise; eles operam sobre valores inteiros. Enquanto operadores como +, - e * trabalham com o valor numérico de um inteiro, os operadores bitwise trabalham com sua representação em binário, manipulando ou comparando cada bit individualmente.

& (AND) compara dois números bit a bit e mantém apenas os bits que são 1 em ambos. | (OR) compara bit a bit e define 1 onde pelo menos um dos bits é 1. ^ (XOR) define 1 apenas onde os bits são diferentes entre si. ~ (NOT) inverte todos os bits do número. << (shift left) desloca os bits para a esquerda, preenchendo com zeros à direita, equivalente a multiplicar por potências de 2. >> (shift right) desloca os bits para a direita, descartando bits finais, equivalente a dividir por potências de 2 (em muitos casos).

De todo o capítulo, essa foi, para mim, a parte mais difícil. Entender o comportamento de cada operador não é o maior obstáculo; o verdadeiro desafio é desenvolver a intuição necessária para pensar em soluções utilizando manipulação de bits. Isso exige familiaridade com representação binária, prática e, muitas vezes, bastante paciência e criatividade.

No livro, eles são apresentados de forma extremamente direta. O K&R parece assumir que o leitor já possui alguma familiaridade com operações em nível de bits, o que torna essa seção particularmente desafiadora para quem está tendo o primeiro contato com o assunto. Ao mesmo tempo, muitos programadores passam boa parte da carreira sem precisar utilizar operadores bitwise diretamente.

Depois de relembrar operações em binário, revisei o funcionamento de cada operador e observei como eles atuam sobre a representação binária dos números. Então me perguntei, com sinceridade: o que é possível fazer com isso? Para minha surpresa, descobri que esses operadores têm diversas aplicações importantes na computação.

Para começar os exemplos, o mais simples de entender é verificar se um número é par ou ímpar usando bitwise, ao invés do clássico operador de resto %. Números ímpares têm uma característica em comum quando representados em binário: o último bit é sempre 1. O objetivo aqui é justamente verificar se esse último bit é 1 ou 0.

O número 1 em binário é 00000001. Fazemos então uma operação AND (&) com o número x que queremos testar. Se o resultado for diferente de zero, o número é ímpar; se for zero, é par. Exemplo:

text
5  = 0101
1  = 0001
------
&  = 0001  → diferente de 0 → ímpar

Em protocolos de rede, por exemplo, um único byte pode carregar diversas informações ao mesmo tempo. Alguns bits podem indicar que um pacote é um ACK, outros podem representar SYN ou FIN. Imagine:

text
10010110
||||||||
|||||||└─ ACK
||||||└── SYN
|||||└── FIN
...

Nesse caso, os operadores bitwise permitem extrair ou modificar cada uma dessas informações individualmente. Isso é incrivelmente eficiente.

Por último, outro exemplo comum é o uso de flags: em vez de armazenar várias variáveis booleanas separadas, um único inteiro pode guardar dezenas de estados diferentes, onde cada bit representa uma configuração ligada ou desligada. Um exemplo de código protótipo de permissões no estilo Unix:

c
int perms = 0;                    /* 0000 nothing          */
perms = perms | READ;             /* 0001 add read         */
perms = perms | WRITE;            /* 0011 add write        */
/* ... later ... */
if (perms & EXECUTE) { ... }     /* check: false, 0000    */
if (perms & READ)    { ... }     /* check: true,  0001    */
/* ... later ... */
perms = perms & ~WRITE;           /* 0001 remove write     */

Abaixo, deixo dois pequenos party tricks envolvendo operadores bitwise que pedi ao Claude para gerar. Eles não representam aplicações reais, mas ajudam a mostrar como operações que, à primeira vista, parecem estranhas podem produzir resultados interessantes.

Party tricks

c
/* ===========================================================================
 * bitwise_tricks.c
 *
 * Two of the most fun/impressive "party tricks" you can pull off with
 * bitwise operators in C. Companion to K&R Chapter 2 (2.9, Bitwise Ops).
 *
 * Refresher:
 *   &  AND   -> 1 only if BOTH bits are 1     (clear / test bits)
 *   |  OR    -> 1 if EITHER bit is 1          (set bits)
 *   ^  XOR   -> 1 if bits DIFFER              (toggle bits)
 *   ~  NOT   -> flips every bit
 *   << / >>  -> shift left/right (multiply / divide by powers of 2)
 *
 * Both tricks below rely on one single property of XOR:
 *   x ^ x = 0          (a value XORed with itself cancels to zero)
 *   x ^ 0 = x           (XOR with zero changes nothing)
 *   XOR is commutative and associative (order doesn't matter)
 *
 * Compile:  cc -Wall -o bitwise_tricks bitwise_tricks.c
 * Run:      ./bitwise_tricks
 * ===========================================================================
 */

#include <stdio.h>

/* ===========================================================================
 * TRICK 1: Swap two variables with no temp variable, using XOR
 *
 * XOR is its own inverse:  a ^ b ^ b == a   and   a ^ b ^ a == b
 *
 *   a = a ^ b;   // a now holds "the difference" between original a and b
 *   b = a ^ b;   // b becomes (orig_a ^ orig_b) ^ orig_b = orig_a
 *   a = a ^ b;   // a becomes (orig_a ^ orig_b) ^ orig_a = orig_b
 *
 * Net effect: a and b swap, with zero extra memory.
 *
 * NOTE: fun to know, but use a temp variable in real code -- it's clearer,
 * and this trick FAILS if a and b are the same memory location (XOR-ing a
 * value with itself zeroes it out, so you'd lose the value entirely).
 * ===========================================================================
 */
void demo_xor_swap(void)
{
    int a = 7, b = 42;
    printf("Before swap: a = %d, b = %d\n", a, b);

    a = a ^ b;
    b = a ^ b;
    a = a ^ b;

    printf("After  swap: a = %d, b = %d\n", a, b);
}

/* ===========================================================================
 * TRICK 2: Find the ONE number that appears once in an array where every
 * other number appears exactly twice -- using only XOR, one pass, no
 * extra memory
 *
 * Recall: x ^ x = 0, x ^ 0 = x, and XOR is commutative/associative -- so
 * the order you XOR things in doesn't matter.
 *
 * If you XOR every element of the array together, every PAIR of
 * duplicates cancels itself out to 0 (since x ^ x = 0), and whatever
 * survives at the end is the lone, unpaired number.
 *
 * Example: {4, 1, 2, 1, 2}
 *   4 ^ 1 ^ 2 ^ 1 ^ 2
 *   = 4 ^ (1 ^ 1) ^ (2 ^ 2)     <- regroup, order doesn't matter
 *   = 4 ^ 0 ^ 0
 *   = 4
 * ===========================================================================
 */
void demo_find_unique(void)
{
    int arr[] = {4, 1, 2, 1, 2}; /* 4 is the only one without a pair */
    int n = sizeof(arr) / sizeof(arr[0]);
    int i, result = 0;

    for (i = 0; i < n; i++) {
        result ^= arr[i]; /* duplicates cancel out as we go */
    }

    printf("Array: {4, 1, 2, 1, 2} -> unique element = %d\n", result);
}

/* ===========================================================================
 * main -- runs each trick with a header, so the output reads like a
 * short guided demo.
 * ===========================================================================
 */
int main(void)
{
    printf("=== 1. XOR swap (no temp variable) ===\n");
    demo_xor_swap();
    printf("\n");

    printf("=== 2. Find the unique element via XOR ===\n");
    demo_find_unique();
    printf("\n");

    return 0;
}

Conclusão


Depois desse capítulo, comecei a enxergar números, caracteres e até bits como diferentes representações da mesma informação. Talvez essa seja a maior contribuição do K&R: mostrar que, antes de aprender novas ferramentas, é preciso entender como o computador enxerga os dados.

Confesso que a seção de bitwise foi particularmente mind blowing. Até então, um byte era só um número na minha cabeça. Descobrir que ele pode representar oito estados completamente independentes mudou bastante a forma como passei a enxergar os dados. O exemplo das permissões do Unix deixou isso muito claro: em vez de criar várias variáveis para guardar informações de verdadeiro ou falso, um único byte consegue armazenar todas elas ao mesmo tempo. Achei muito elegante perceber que, em programação de baixo nível, praticamente nenhum bit é desperdiçado. Cada um pode carregar uma informação diferente, e é justamente isso que torna esse tipo de representação tão inteligente e eficiente.