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 (#t
para verdadeiro,#f
para 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
pi
para 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
fib
recursivo 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:
-
termo
Imagina que o
soma
é um cara que sabe andar por uma lista de números (a
até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 osoma
vai 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ê passariaidentidade
como 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
quadrado
que a gente viu antes:(define (quadrado x) (* x x))
. Você passariaquadrado
como 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
termo
não é um número, não é um nome qualquer. É uma funçãozinha, um procedimento, que osoma
chama 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
-
proximo
Beleza, o
soma
sabe qual valor pegar na posiçãoa
(usando o procedimentotermo
). Mas e depois? Como ele anda para próxima posição?O argumento
proximo
diz isso a ele: é o procedimento que osoma
vai 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ê passariaincremento
como 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
proximo
não é um número fixo, é uma funçãozinha, um procedimento, que osoma
chama 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
x
ondef(x) = x
. De novo, você escreve um HOP que recebe a funçãof
e 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.