Construindo Abstrações com Procedimentos.
Structure and Interpretation of Computer Programs, capítulo 1
← Voltar para a página inicialEste capítulo, em particular, me fez perceber lacunas no meu conhecimento de matemática, o que me levou a dedicar tempo para revisitá-la (utilizando o Khan Academy, por exemplo). Essa jornada extra foi fundamental para solidificar alguns entendimentos. Além do background matemático, é crucial abordar a leitura com calma e atenção. Afinal, trata-se de um livro técnico e denso que pressupõe uma base em lógica e, eventualmente, familiaridade com a linguagem utilizada.
Esse capítulo tem como objetivo introduzir os conceitos fundamentais e, utilizando a linguagem Scheme (um dialeto LISP), estabelecer a ideia central de um processo computacional. Ele nos mostra como programas podem ser vistos como conjuntos de regras que governam a forma como esses processos evoluem e manipulam dados.
Uma linguagem de programação poderosa serve não apenas para instruir o computador, mas também como estrutura para a organização de ideias sobre processos.
Os Elementos da Programação: Os Blocos de Construção
Depois de ter aquele momento de epifania (ou de dar de cara com a parede da matemática!) no começo do capítulo, o SICP, lá no tópico 1.1, começa a mostrar as cartas: quais são as ferramentas básicas que qualquer linguagem de programação realmente poderosa te dá para você começar a construir o que quiser. É como um kit básico de sobrevivência do programador.
O livro diz que são três mecanismos essenciais, os quais permitem que ideias simples, tonem-se ideias complexas e compostas. Os mecanismos são:
- Expressões Primitivas
- Meios de Combinação
- Meios de Abstração
Vamos quebrar cada um desses:
Expressões Primitivas: Os Léptons da Computação
Pensa nos Léptons na física. Aquelas partículas que são a menor unidade que a gente conhece, que não dá para dividir mais e que, combinadas de jeitos diferentes, formam tudo que a gente vê e sente – elétrons, neutrinos, e daí para frente, construindo átomos, moléculas, a gente, as ideias… É o bloco de construção fundamental.
Na programação, a expressão primitiva tem essa mesma pegada. São os elementos mais básicos e “indivisíveis” da linguagem. No Scheme, isso pode ser:
-
Valores literais: Números (
5,3.14), textos ("Olá Mundo"), valores verdade/falso (#tpara verdadeiro,#fpara falso). São eles mesmos, diretos. -
Nomes (ou variáveis): Identificadores que a gente
usa para se referir a um valor guardado em algum lugar. Tipo
pipara 3.14159.
O livro também meio que coloca os procedimentos básicos (as operações como soma, subtração, etc.) nesse balaio de primitivas iniciais, que você pode simplesmente usar. A gente “chama” esses de procedimentos primitivos.
A ideia é: com esses pedacinhos, você já tem a base. A matéria-prima.
Meios de Combinação: Juntando as Peças
Agora, ter só os léptons não faz um universo, né? Você precisa de um jeito de juntar eles. É aí que entram os meios de combinação. Eles são as regras ou a sintaxe da linguagem que te permitem pegar essas expressões primitivas (ou as coisas que você já combinou) e fazer estruturas maiores e mais complexas.
No Scheme, o jeito clássico de combinar é pela aplicação de um procedimento aos seus argumentos. Aquela notação com parênteses que a gente vê e acha estranho no começo:
(+ 2 3)
;; Combina o procedimento '+' com os números 2 e 3
Isso é uma combinação! Estamos “combinando” o procedimento de soma com os números 2 e 3. O resultado dessa combinação é 5.
A gente pode combinar combinações!
(* (+ 2 3) 4)
; Combina o procedimento '*' com o resultado de (+ 2 3) e o número 4
Aqui, a combinação (+ 2 3) é usada como parte de outra
combinação. É como formar moléculas a partir de átomos, e depois
estruturas maiores a partir dessas moléculas. Os meios de combinação
são essa “cola”, as regras de sintaxe que permitem construir
expressões complexas a partir de outras mais simples, definindo a
estrutura da sua computação.
Meios de Abstração: Dando Nomes às Coisas
Ter um monte de estruturas combinadas é legal, mas e se você quiser usar a mesma estrutura várias vezes sem ter que escrever tudo de novo? Ou se você quiser se referir a uma ideia complexa por um nome simples? É para isso que servem os meios de abstração.
O meio de abstração mais básico no SICP
1.1
é simplesmente dar um nome a alguma coisa. No
Scheme, a gente faz isso principalmente com o define:
(define tamanho 10)
; Dá o nome 'tamanho' para o valor 10
Agora, em vez de usar sempre 10, eu posso usar
tamanho. Eu criei uma abstração. Eu encapsulei
o valor 10 sob um nome que talvez diga algo sobre o que ele
representa.
A gente também usa define para dar nome a procedimentos
que a gente cria:
(define (quadrado x) (* x x))
; Dá o nome 'quadrado' para um procedimento que multiplica seu argumento por ele mesmo
Pronto! Criei uma abstração para a ideia de “elevar ao quadrado”.
Agora eu posso simplesmente usar
(quadrado 5) em vez de escrever (* 5 5).
E é aqui que entra a ideia da “Caixa Preta”. Uma vez que a gente define um procedimento e dá um nome para ele, a gente pode simplesmente usar esse nome sem ter que pensar nos detalhes de como ele faz o trabalho por dentro. É como uma caixa preta: você sabe o que entra (os argumentos) e o que sai (o resultado), mas não precisa se preocupar com a engrenagem interna. Essa capacidade de usar abstrações como caixas pretas é fundamental para gente conseguir construir sistemas grandes e complexos sem surtar!
A abstração é a chave para gerenciar a complexidade. É como dar nomes para descobertas científicas complexas (“Teoria da Relatividade”) ou para objetos do dia a dia (“cadeira”). Em vez de descrever tudo cada vez, a gente usa o nome abstrato.
A Mágica Acontece
Então, a grande sacada do 1.1 é essa tríade. A linguagem te dá os elementos simples (primitivas), te dá um jeito de combinar eles para fazer coisas mais complexas, e te dá um jeito de dar nome a essas coisas para você conseguir pensar e trabalhar com elas de forma mais fácil (abstração).
É a partir dessa base que o livro (e a ciência da computação!) constrói tudo o mais. A magia é justamente a capacidade de ir empilhando complexidade e, a cada passo, criar uma nova abstração pra gerenciar essa complexidade.
Elementos simples → Combinação → Abstração → Programas que fazem coisas incríveis (Mágica!).
Procedimentos e os Processos que Eles Geram
Depois de entender os blocos de construção básicos – primitivas, como juntar tudo (combinação) e como dar nome pras coisas (abstração) no 1.1 – o SICP joga a gente para outro nível no tópico 1.2. Sabe qual é? Parar de pensar só no código parado, na receita, e começar a visualizar o que acontece quando o código roda. Tipo, qual é a dinâmica que aquele procedimento cria? Que “processo” ele gera no computador?
Essa mudança de chave é vital! Não basta só saber escrever o procedimento para calcular alguma coisa; a gente precisa entender como a máquina executa isso, passo a passo. É aí que começamos a ver as diferentes “formas” de processos computacionais.
A Forma dos Processos: Linear e Iterativo
O livro explora isso a fundo com o cálculo do fatorial. A gente
aprende a escrever a função fatorial de duas jeitos
diferentes no Scheme:
-
Procedimento Recursivo que Gera um Processo Recursivo (Linear): Pensa na definição matemática do fatorial: n! = n * (n-1)!. Note que n! chama n, n vezes, isso é recursão: chamar a si mesma durante sua execução, dividindo um problema em subproblemas menores até atingir um caso base (condição de parada). Nosso procedimento em Scheme copia essa ideia, olhe:
(define (fatorial n) (if (= n 1) 1 (* n (fatorial (- n 1)))))Quando você roda isso, a máquina precisa ‘lembrar’ das multiplicações que ficaram pendentes. Ela vai empilhando as tarefas:
(* 5 (fatorial 4)), depois(* 4 (fatorial 3))e assim por diante, até chegar no(fatorial 1). Aí, ela começa a ‘desempilhar’ e fazer as contas. Esse é um processo recursivo – ele cresce e precisa de mais memória (espaço) para lembrar das operações pendentes, na proporção do tamanho da entrada (linearmente, por isso recursivo linear). -
Procedimento Iterativo que Gera um Processo Iterativo (Linear): Essa é a versão que talvez a gente não pense logo de cara, mas que é super eficiente. A gente usa variáveis para manter o estado atual da computação – tipo, qual o produto acumulado até agora e qual o próximo número a multiplicar. No Scheme, a gente faz isso de um jeito esperto, geralmente com uma função auxiliar que se chama recursivamente, mas de um jeito que o interpretador otimiza pra virar um loop (chamada de cauda recursiva). Fica algo assim:
(define (fatorial n) (define (fat-iter produto contador max-contador) (if (> contador max-contador) produto (fat-iter (* produto contador) (+ contador 1) max-contador))) (fat-iter 1 1 n))Aqui, a máquina não precisa ficar lembrando de operações futuras. Em cada passo, ela só precisa saber o produto atual e o contador. O estado do processo é definido por um número fixo de variáveis (produto e contador). Isso é um processo iterativo. Ele usa uma quantidade constante de memória (espaço), independente do tamanho da entrada. Pense nisso como um loop tradicional em outras linguagens.
A grande lição aqui é: a forma do procedimento (como você escreve o código) não é necessariamente a forma do processo (como a máquina executa). O SICP usa o fatorial para deixar isso cristalino.
Recursão em Árvore: Quando o Processo se Ramifica
Saindo do linear, a gente topa com a
recursão em árvore. O exemplo clássico é o cálculo
dos números de Fibonacci. Para calcular fib(n), você
precisa de fib(n-1) e fib(n-2). Cada
cálculo de fib ‘ramifica’ em dois outros, criando uma
árvore de computações.
(define (fib n)
(cond ((= n 0) 0)
((= n 1) 1)
(else (+ (fib (- n 1))
(fib (- n 2))))))
Visualizar esse processo é ver a árvore se abrindo. É conceitualmente simples de escrever, seguindo a definição, mas computacionalmente pode ser um pesadelo, recalculando os mesmos valores várias e várias vezes. O problema de contar o número de maneiras de dar troco num valor é outro exemplo de processo que, escrito de forma natural, gera uma recursão em árvore.
A moral da história da recursão em árvore é: cuidado com a ineficiência! Às vezes, a forma mais “natural” de escrever a função não gera o processo mais eficiente.
A Ordem do Caos (ou da Eficiência): Ordens de Crescimento
Para gente não ficar só na intuição sobre se um processo é eficiente
ou não, o SICP introduz a ideia de
ordens de crescimento (usando a notação Theta, Θ).
É uma forma de medir, de maneira grosseira, como o
consumo de recursos (tempo que leva para rodar e espaço de
memória que usa) de um processo cresce conforme a entrada (o
n) aumenta.
-
Se o tempo/espaço cresce na mesma proporção que
n, a gente diz que a ordem é linear, Θ(n). -
Se cresce na proporção do quadrado de
n, Θ(n2). -
Se cresce na proporção do logaritmo de
n, Θ(log n). -
Se cresce muito rápido, tipo exponencialmente (como o
fibrecursivo que gera um processo em árvore ingênuo), Θ(2n). - Se cresce extremamente rapido sendo até incomputavel, Θ(Ann…) ou Θ(A(n, n))
Entender as ordens de crescimento é fundamental para gente ter uma noção de performance sem precisar medir o tempo exato em milissegundos. É sobre a taxa de crescimento.
Note que o tópico 1.2 te força a olhar para dentro da caixa preta. Não é mais só sobre a receita (o procedimento), mas sobre o cozimento (o processo). A gente aprende que a mesma receita pode ser cozinhada de formas diferentes, gerando processos com dinâmicas (e eficiências!) bem distintas. A distinção entre procedimento recursivo/iterativo (como você escreve) e processo recursivo/iterativo (como a máquina executa) é um dos grandes “aha!” moments dessa seção. E começar a pensar em ordens de crescimento te dá uma ferramenta poderosa para comparar a ‘potência’ ou o ‘custo’ dos seus processos. É a matemática voltando para te dar superpoderes analíticos!
Formulando Abstrações com Procedimentos de Ordem Superior
Se o 1.1 te deu os blocos e o 1.2 te fez sacar a dinâmica dos processos, o tópico 1.3 é onde o negócio fica realmente poderoso e a gente começa a ver o que significa “construir abstrações com procedimentos” de uma forma mais sofisticada.
Aqui a gente mergulha na ideia de tratar procedimentos como “cidadãos de primeira classe”. O que isso significa? Basicamente, que na linguagem Scheme, os procedimentos podem ser manipulados como qualquer outro “dado” – tipo números ou textos. Você pode:
- Passar um procedimento como argumento para outro procedimento.
- Retornar um procedimento como o resultado de outro procedimento (embora o livro explore mais isso depois, a semente é plantada aqui).
-
Vincular um procedimento a um nome (o que a gente
já viu com o
define, mas agora a gente vê o poder disso combinado com o resto).
Essa capacidade de tratar procedimentos como dados é o que permite criar procedimentos de ordem superior (Higher-Order Procedures - HOPs). São procedimentos que operam em outros procedimentos, seja recebendo um procedimento como entrada ou gerando um procedimento como saída. Parece papo de maluco no início, mas é aí que a mágica da abstração atinge um novo patamar.
Procedimentos como Argumentos: A Fábrica de Ideias Comuns
O livro mostra isso com um exemplo clássico: somar séries. Tipo, somar os números de 1 a 10, somar os quadrados de 1 a 10, somar os inversos de 1 a 10… Percebe um padrão? Você tá sempre somando uma sequência de termos, onde cada termo é calculado usando uma regra diferente (o número em si, o quadrado dele, o inverso dele).
Em vez de escrever uma função soma-inteiros, outra
soma-quadrados, outra soma-inversos, a
gente pode criar um procedimento geral de soma!
(define (soma termo a proximo b)
(if (> a b)
0
(+ (termo a)
(soma termo (proximo a) proximo b))))
Agora, a “mastigação” dos pontos:
-
termoImagina que o
somaé um cara que sabe andar por uma lista de números (aatéb) e somar coisas. Mas ele é meio lerdo e não sabe o que exatamente ele tem que somar em cada posição. Você tem que dar essa instrução para ele!O argumento
termoé exatamente isso: é o procedimento que osomavai chamar para descobrir o valor que ele tem que somar agora, na posição atuala**.-
Quer somar os números de 1 a 10? O “termo” que você
soma na posição 5 é o próprio 5. Então, você passa um
procedimento que simplesmente retorna o número que recebeu.
Chamamos esse procedimento de
identidade:(define (identidade x) x). Você passariaidentidadecomo o argumentotermo. -
Quer somar os quadrados de 1 a 10? O “termo” que você
soma na posição 5 é o quadrado de 5, que é 25. Então, você
passa o procedimento
quadradoque a gente viu antes:(define (quadrado x) (* x x)). Você passariaquadradocomo o argumentotermo. -
Quer somar os inversos de 1 a 10? O “termo” que você
soma na posição 5 é 1/5. Você passaria um procedimento que
calcula o inverso:
(define (inverso x) (/ 1 x)).
Sacou? O
termonão é um número, não é um nome qualquer. É uma funçãozinha, um procedimento, que osomachama em cada passo para saber “qual o valor que eu pego dessa posição aqui para somar?”. Você tá dando para ele a regra de como transformar a posição atual no valor a ser somado. -
Quer somar os números de 1 a 10? O “termo” que você
soma na posição 5 é o próprio 5. Então, você passa um
procedimento que simplesmente retorna o número que recebeu.
Chamamos esse procedimento de
-
proximoBeleza, o
somasabe qual valor pegar na posiçãoa(usando o procedimentotermo). Mas e depois? Como ele anda para próxima posição?O argumento
proximodiz isso a ele: é o procedimento que osomavai chamar para descobrir qual é a próxima posição na sequência, depois da posição atuala.-
Quer somar uma sequência normal, tipo 1, 2, 3, 4…? A próxima
posição depois de 5 é 6. Você precisa de um procedimento que
some 1 ao número que recebe. Chamamos esse de
incremento:(define (incremento x) (+ x 1)). Você passariaincrementocomo o argumentoproximo. -
E se você quisesse somar só os números
ímpares numa faixa? Tipo, 1, 3, 5, 7…? A próxima
posição depois de 5 é 7. Você precisaria de um procedimento
que somasse 2 ao número que recebe. Você passaria um
procedimento tipo
(lambda (x) (+ x 2))como o argumentoproximo.
De novo, o
proximonão é um número fixo, é uma funçãozinha, um procedimento, que osomachama em cada passo para saber “qual a próxima posição que eu tenho que ir depois dessa?”. Você tá dando para ele a regra de como avançar na sua “lista” ou “sequência” de números. -
Quer somar uma sequência normal, tipo 1, 2, 3, 4…? A próxima
posição depois de 5 é 6. Você precisa de um procedimento que
some 1 ao número que recebe. Chamamos esse de
Por que isso é genial?
Porque o procedimento soma em si é totalmente
genérico. Ele não sabe se tá somando inteiros,
quadrados ou inversos. Ele só sabe seguir as instruções que você dá
para ele através dos procedimentos termo e
proximo. Você “programa” o comportamento específico da
soma (o que somar e como avançar) passando
outros procedimentos como dados para ele.
É como ter um robô que sabe somar, mas você troca os “olhos” (o
procedimento termo que diz o que ele vê/pega para
somar) e as “pernas” (o procedimento proximo que diz
como ele anda para a próxima posição). O robô (soma) é
o mesmo, mas o que ele faz muda completamente dependendo dos
“acessórios” (os procedimentos) que você dá a ele.
Essa é a beleza e o poder dos procedimentos de ordem superior: eles nos permitem encapsular métodos gerais, e as especificidades desse método são fornecidas por outros procedimentos que são passados como se fossem dados normais.
Lambda: Procedimentos Anônimos na Hora H
Às vezes, você precisa passar um procedimento como argumento, mas
ele é tão simples ou tão específico praquele lugar que dar um nome
define para ele parece exagero. Para isso, o SICP te
apresenta o lambda.
lambda te permite criar um procedimento “na hora”, sem
dar um nome a ele. É como criar uma função anônima.
(lambda (x) (* x x))
; Um procedimento que recebe x e retorna x*x (o quadrado)
Isso sozinho não faz nada, mas você pode usar ele diretamente onde um procedimento é esperado, como argumento de um HOP:
(soma (lambda (x) (* x x))
1
(lambda (x) (+ x 1))
10)
Isso faz a mesma coisa que
(soma quadrado 1 incremento 10), mas sem precisar
definir quadrado e
incremento separadamente se você só for usá-los ali. O
lambda dá uma fluidez incrível pra usar HOPs.
E é nesse ponto que o livro também mostra o let. O
let não cria procedimentos novos diretamente como o
lambda, mas ele é uma ferramenta útil para criar
variáveis temporárias, com um escopo bem restrito, só para usar ali,
naquele pedaço de código.
Pensa assim: às vezes, dentro de um cálculo, você precisa calcular
um valor intermediário e dar um nome para ele rapidinho só para
organizar a conta, mas não quer que esse nome “vaze” e atrapalhe o
resto do seu programa. O
let faz exatamente isso!
(let ((local-variavel valor) ... )
; Tudo o que tiver aqui: ... usará
; a variavel: local-variavel
E a conexão com o lambda? O SICP
revela que o let é, na verdade, um “açúcar sintático”
para uma combinação esperta usando lambda! Quando você
escreve um let, o interpretador do Scheme, por baixo
dos panos, traduz aquilo para algo que usa um
lambda para criar aquele escopo local e vincular a
variável.
Tipo, um (let ((x 10)) (+ x 5)) é basicamente
transformado em ((lambda (x) (+ x 5)) 10). A gente não
precisa se preocupar com essa tradução na hora de usar o
let, mas saber que ele se baseia no
lambda mostra como esses mecanismos de abstração e
combinação estão interligados na linguagem.
Então, enquanto o lambda te dá a flexibilidade de criar
procedimentos anônimos, o let te dá a conveniência de
criar nomes temporários (variáveis locais) para organizar seu
código, e os dois trabalham juntos, com o let sendo
construído em cima do poder do lambda.
Procedimentos como Métodos Gerais: A Essência da Ciência da Computação
A cereja do bolo do
1.3
é ver como essas ideias – procedimentos como cidadãos de primeira
classe, HOPs, lambda – nos permitem expressar
métodos computacionais gerais. Não é mais sobre
resolver um problema específico (tipo calcular uma raiz de
9); é sobre implementar o método de encontrar raízes que
funcione para qualquer função.
O livro demonstra isso com exemplos como:
- O Método da Bissecção: Um jeito geral de encontrar raízes de equações. Você pode escrever um procedimento de ordem superior que recebe a função que você quer encontrar a raiz, um intervalo onde procurar, e ele aplica o método da bissecção.
-
Busca por Ponto Fixo: Um método geral para
encontrar um valor
xondef(x) = x. De novo, você escreve um HOP que recebe a funçãofe aplica a busca por ponto fixo.
Nesses casos, o procedimento de ordem superior encapsula a
lógica do método. A função específica (f(x) = x² - 9
para encontrar a raiz de 9, ou f(x) = cos(x) para um
ponto fixo do cosseno) é passada como um argumento!
A Abstração de Poder Máximo
O tópico 1.3 te eleva. Você para de pensar só nas operações concretas e começa a pensar em padrões computacionais e métodos gerais. Procedimentos deixam de ser apenas sequências de instruções para se tornarem entidades que podem ser manipuladas, combinadas e usadas para construir abstrações ainda mais poderosas. A capacidade de tratar procedimentos como dados é o que desbloqueia essa nova camada de expressividade e permite que a gente escreva código que não só resolve problemas, mas que também estrutura nossas ideias sobre como resolver classes inteiras de problemas.
É aqui que a frase “Uma linguagem de programação poderosa serve… como estrutura para a organização de ideias sobre processos” do início do capítulo ecoa mais forte. No 1.3, os procedimentos de ordem superior se tornam nossas principais ferramentas para organizar e expressar essas ideias de forma elegante e poderosa.