Introdução
Estudar a linguagem C pela primeira vez está sendo absurdamente divertido e enriquecedor. Além de ser uma linguagem que carrega décadas de história da ciência da computação, seu estudo revela conceitos fundamentais que continuam presentes em tecnologias modernas. Por esse motivo, estudá-la é um enorme prazer.
No post anterior C — Hello, world!, utilizando o simples programa Hello, world! em C como exemplo, explorei alguns mecanismos internos da linguagem — como o uso de bibliotecas padrão e os papeis do preprocessor, compiler e linker.
Já essa sequência de posts (divididas em duas partes), diz respeito somente aos conceitos abordados no Capítulo 1 do livro do K&R (The C Programming Language, 2nd Edition), portanto, não é discutido recursos mais avançados da linguagem C, como ponteiros, estruturas (structs) ou detalhes mais profundos sobre gerenciamento de memória. O foco do capítulo é apresentar os fundamentos da linguagem através de pequenos programas e exercícios.
Apesar da simplicidade dos exemplos, algumas partes costumam passar despercebidas para quem está começando — especialmente levando em conta que o livro apresenta vários conceitos de forma implícita.

Minhas impressões sobre tipagem e runtime
Foi uma surpresa descobrir que C é considerada uma linguagem fracamente tipada, enquanto Python é fortemente tipada. Acredito que minha surpresa venha do fato de que em C a linguagem costuma demandar que o programador seja explícito; entretanto, me surpreendeu perceber quantas conversões implícitas C permite fazer.
Exemplos:
int x = 65;
char c = x; // int silenciosamente se torna um char → 'A'
printf("%c", x); // imprime 'A', sem precisar de conversão
# necessário converter explicitamente
int("5") + 3 # output: 8
str(5) + "abc" # output: "5abc"
Existem exemplos muito mais interessantes e sofisticados de conversões implícitas em C, mas estes já são suficientes para ilustrar a ideia discutida neste post.
Um ponto interessante que percebi ao entender sobre o printf é que, em C, muitas informações de tipo existem apenas durante a compilação. Quando o programa está executando, um valor armazenado na memória não carrega necessariamente informações sobre o seu tipo. Para o processador, aquilo são apenas bits.
Por isso, ao escrever printf("%d %f", x, y);, a string de formatação não serve apenas para organizar a saída. Ela também informa ao printf como interpretar os argumentos recebidos. O %d indica que o próximo argumento deve ser tratado como um inteiro; o %f, como um número de ponto flutuante.
Runtime e ASCII
Ilustrando na prática, durante a execução de um programa em C, o valor 65 pode ser interpretado de diferentes formas dependendo do contexto: como o número inteiro 65, como o caractere 'A' na tabela ASCII, como o valor hexadecimal 0x41, entre outras possibilidades.
Já em linguagens que mantêm mais informações disponíveis em runtime, como Python, os objetos carregam metadados associados a eles. De forma simplificada, podemos imaginar algo parecido com:
objeto
├── valor: 65
└── tipo: int
Isso permite que funções genéricas descubram automaticamente como tratar um valor sem que o programador precise fornecer instruções extras, como acontece com o print().
Essa diferença ilustra dois modelos distintos de execução. Enquanto Python mantém mais informações disponíveis em runtime, C delega uma parcela maior desse trabalho para a fase de compilação, resultando em programas menores, mais simples e com menos abstrações entre o código e a máquina.
O design do printf revela uma característica importante de C: durante a execução, um valor não necessariamente "sabe" o que ele é. Por isso, cabe ao programador fornecer informações extras para que funções genéricas consigam interpretar corretamente os dados recebidos.
Stream de caracteres
Um dos primeiros conceitos fascinantes que o K&R aborda é o de streams de caracteres. A biblioteca padrão trata entrada e saída de texto como fluxos de caracteres, independentemente de onde esses dados vêm ou para onde vão.
A partir desse modelo simples, o livro mostra como é possível criar programas pequenos e úteis. Muitos exemplos são inspirados em ferramentas tradicionais do Unix, como contadores de linhas, palavras e caracteres.
A ideia central é que esses programas não precisam conhecer a origem dos dados. Eles simplesmente recebem uma sequência de caracteres, processam essa sequência e produzem uma saída. Essa abstração é uma das características que tornaram as ferramentas Unix tão poderosas: programas pequenos podem ser combinados para realizar tarefas maiores.
Baseado na minha leitura, o conceito do exemplo abaixo é um dos mais valiosos do capítulo 1:
#include <stdio.h>
/* copy input to output; 2nd version */
main()
{
int c;
while ((c = getchar()) != EOF)
putchar(c);
}
De primeira, fiquei surpreso com a quantidade de conceitos importantes escondidos nesse exemplo extremamente simples. Embora o código apenas copie a entrada para a saída, ele apresenta um padrão fundamental: consumir dados de um fluxo, processá-los enquanto chegam e produzir um resultado.
Muitos dos exercícios deste capítulo reutilizam essa mesma ideia de diferentes formas: em vez de apenas copiar caracteres, o programa passa a contar, filtrar, transformar ou analisar esses dados.
while ((c = getchar()) != EOF)
A linha while ((c = getchar()) != EOF) revela detalhes importantes sobre input em C.
O while cria um laço que continuará executando enquanto getchar() retornar um valor diferente de EOF. A função getchar() lê um caractere da entrada e retorna seu valor; quando não existem mais dados disponíveis, ela retorna um valor especial chamado EOF (End-of-File), indicando que o fluxo de entrada terminou.
É importante notar que EOF não é um caractere. Ele não está armazenado no final de um arquivo nem é um símbolo que o programa recebe. Ele é um valor especial usado pelas funções de entrada da biblioteca padrão para indicar que a leitura não pode continuar.
No terminal, uma forma comum de enviar essa sinalização é utilizando CTRL+D em sistemas Unix, que informa ao sistema que não há mais dados disponíveis na entrada.
Outro detalhe brilhante é que a atribuição acontece dentro da condição do while. Primeiro o programa lê um caractere e armazena em c; depois compara esse valor com EOF. Isso permite que a própria leitura controle a continuação do laço, evitando uma chamada extra de getchar() apenas para verificar se a entrada terminou.
E o int c?
Posso parecer repetitivo quando digo que me surpreendi, mas C realmente me surpreendeu bastante durante meus estudos.
Diante dos primeiros exemplos, me deparei com a variável c sendo declarada como int. A dúvida foi instantânea: se estamos lendo caracteres, por que não utilizar char?
A resposta introduz um conceito importante: em C, um caractere é representado internamente por um número. O tipo char é um tipo inteiro pequeno utilizado para armazenar esses valores, e convenções como a tabela ASCII definem uma relação entre números e caracteres. Exemplo na prática:
char c = 65;
printf("%c", c); // output: 'A'
printf("%d", c); // output: 65 em ASCII
A tabela ASCII é uma convenção criada para padronizar essa representação. Por exemplo, dentro dessa tabela, a letra 'A' corresponde ao número 65. Isso significa que, para o computador, a letra não é armazenada como uma entidade especial chamada "A"; ela é armazenada como uma sequência de bits que, seguindo a convenção ASCII, representa o valor numérico 65, associado ao símbolo 'A'.
A tabela ASCII original usa 128 valores possíveis (0 a 127) para representar todos os seus caracteres.
Somente esse fato não explica o motivo de int c, afinal, se char é representado como um número, por que getchar() precisa retornar int e não char?
A resposta começa a mostrar uma característica importante de C: valores não são apenas conceitos abstratos, eles possuem uma representação física na memória. O tamanho da variável importa, porque ele define quais valores ela consegue armazenar.
Um char normalmente ocupa 1 byte, enquanto um int ocupa mais espaço. Além disso, existe a diferença entre tipos signed e unsigned: tipos com sinal conseguem representar valores negativos e positivos, enquanto tipos sem sinal representam apenas valores positivos.
A chave para entender o char está aqui: ele precisa representar caracteres. No caso da tabela ASCII original, isso significa valores de 0 a 127. Entretanto, getchar() não retorna apenas caracteres; ele também precisa retornar um valor especial chamado EOF, que indica que não existem mais dados para ler.
Como EOF possui o valor -1, ele precisa ser diferente de todos os caracteres possíveis. Por isso, getchar() utiliza int, que possui espaço suficiente para representar todos os caracteres e também esse valor especial.
Exercício 1-9. Write a program to copy its input to its output, replacing each string of one or more blanks by a single blank.
Neste exercício o enunciado pede que o input seja copiado para o output, porém, substituindo espaços consecutivos por apenas um único espaço.
Exemplo:
Na prática, se o input for hello world from C — o output deverá ser hello world from C.
Solução
#include <stdio.h>
int main() {
int c, past_c;
past_c = 0;
for (; (c = getchar()) != EOF; ) {
if (c == ' ') {
if (past_c == ' ') {
continue;
}
}
putchar(c);
past_c = c;
}
return 0;
}
/* Versão equivalente mais compacta.
* A ideia é simples: imprima o caractere, exceto quando o caractere atual
* e o caractere anterior forem ambos espaços.
if (!(c == ' ' && past_c == ' ')) {
putchar(c);
}
*/
Conceitos aplicados neste exercício:
- Processamento de streams de caracteres.
- Contexto acumulado.
- Tomada de decisão baseada no caractere atual e em informações anteriores.
Esse exercício introduz uma ideia importante — o caractere atual nem sempre contém informação suficiente para que o programa tome uma decisão; às vezes é necessário preservar alguma informação sobre o que aconteceu anteriormente.
No próximo exemplo, essa mesma ideia aparece de forma mais geral através do conceito de máquina de estados.
Máquina de estados
O próximo exemplo poderoso mostrado pelo K&R é a introdução ao conceito de máquina de estados. No exemplo abaixo, temos um programa que conta linhas, palavras e caracteres recebidos através do input.
Portanto, se o input for hello world — o output deverá ser 1 2 12 (1 quebra de linha, 2 palavras, 12 caracteres). O total de caracteres é 12 porque a quebra de linha \n também faz parte da entrada. Ao pressionar Enter, o terminal envia esse caractere de controle para o programa, que também é contabilizado pelo contador.
#include <stdio.h>
#define IN 1 /* inside a word */
#define OUT 0 /* outside a word */
/* count lines, words, and characters in input */
main()
{
int c, nl, nw, nc, state;
state = OUT;
nl = nw = nc = 0;
while ((c = getchar()) != EOF) {
++nc;
if (c == '\n')
++nl;
if (c == ' ' || c == '\n' || c == '\t')
state = OUT;
else if (state == OUT) {
state = IN;
++nw;
}
}
printf("%d %d %d\n", nl, nw, nc);
}
Esse exemplo demonstra com clareza como programas podem utilizar máquinas de estados para manter uma informação sobre o contexto atual e tomar decisões futuras. O caractere recebido sozinho não é suficiente para determinar o comportamento do programa; é necessário saber em qual estado ele se encontra. Neste caso, o programa mantém dois estados possíveis: dentro de uma palavra (IN) ou fora de uma palavra (OUT). A partir dessa informação, ele consegue decidir quando uma nova palavra começou e incrementar o contador corretamente.
Exercício 1-12. Write a program that prints its input one word per line.
Aqui o enunciado pede algo simples: quebre a linha no fim de cada palavra encontrada.
Exemplo
Se o input for hello world — o output deverá ser:
hello
world
Este foi o primeiro exercício do livro em que realmente quebrei a cabeça. Meu primeiro instinto foi imprimir uma quebra de linha toda vez que encontrasse um espaço — e é exatamente isso que não funciona. O problema é que espaços podem aparecer consecutivos: dois espaços seguidos, uma tabulação depois de uma quebra de linha, e assim por diante. Se eu quebrasse a linha para cada separador, o programa imprimiria linhas em branco onde não deveria.
A parte que demorou para fazer sentido foi perceber que o programa precisava guardar uma informação de contexto: não apenas "qual caractere estou lendo agora?", mas "qual era o contexto atual do programa quando recebeu este caractere?". O estado existe justamente para isso. Quando um separador é encontrado enquanto o estado é IN, significa que uma palavra acabou — e somente nesse momento a quebra de linha deve acontecer. Separadores consecutivos são ignorados silenciosamente, porque o estado já é OUT.
A dificuldade não era detectar caracteres ou separadores, mas perceber que uma ação pode depender da relação entre o caractere atual e o estado em que o programa se encontrava.
Solução
#include <stdio.h>
#define IN 1
#define OUT 0
// Exercise 1-12. Write a program that prints its input one word per line.
int main() {
int c, state;
state = OUT;
while ((c = getchar()) != EOF) {
if (c != ' ' && c != '\n' && c != '\t') { // Se "c" não é um separador: encontramos um caractere de uma palavra
putchar(c);
state = IN;
}
if (c == ' ' || c == '\n' || c == '\t') { // Se "c" é um separador, verificamos se uma palavra terminou
if (state == IN) { // [!] O separador encerrou uma palavra que estava sendo lida?
printf("\n"); // Se sim, quebre a linha
state = OUT;
}
}
}
return 0;
}
Conceitos aplicados neste exercício:
- Máquina de estados (IN / OUT).
- Estado como representação do contexto atual do programa.
- Identificação de transições de estado.
- Contagem de palavras a partir de mudanças de contexto.
Provavelmente é exatamente essa mudança de mentalidade que o exercício propõe. Inicialmente parece que cada caractere deve gerar uma ação individual: se é uma letra, imprime; se é um espaço, quebra a linha. Entretanto, o espaço sozinho não significa que uma palavra terminou. Ele só representa o fim de uma palavra quando o programa sabe que estava dentro de uma palavra antes dele.
Essa é a ideia central do estado: guardar o contexto atual do programa enquanto ele processa uma sequência de dados. Quando um separador encerra uma palavra, o programa age.
Considerações finais
Ao terminar esta primeira parte do capítulo, fiquei com a impressão de que o K&R ensina muito mais do que parece. Os exemplos são pequenos, mas escondem conceitos que ajudam a entender como programas realmente funcionam.
Entender por que getchar() retorna um int, por que existe um EOF ou por que um programa precisa manter um estado não serve apenas para resolver exercícios. Serve para construir um modelo mental sobre quais informações o computador possui, quais ele não possui e como programas transformam dados em decisões.
Embora o capítulo seja uma introdução à linguagem C, a sensação é que ele está ensinando algo mais fundamental: uma forma de pensar sobre software.
O próximo post sobre o Capítulo 1 do K&R abordará conceitos e exercícios envolvendo arrays em C. Foi justamente nessa parte que comecei a perceber quantas coisas o livro considera óbvias e deixa implícitas, então ainda há bastante detalhe interessante para capturar.