Construindo Abstrações com Procedimentos.

Structure and Interpretation of Computer Programs, capítulo 1

← Voltar para a página inicial

Este 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:

  1. Expressões Primitivas
  2. Meios de Combinação
  3. 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:

  1. 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).

  2. 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 o soma vai chamar para descobrir o valor que ele tem que somar agora, na posição atual a**.

    • 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ê passaria identidade como o argumento termo.
    • 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ê passaria quadrado como o argumento termo.
    • 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 o soma 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.

  • proximo

    Beleza, o soma sabe qual valor pegar na posição a (usando o procedimento termo). Mas e depois? Como ele anda para próxima posição?

    O argumento proximo diz isso a ele: é o procedimento que o soma vai chamar para descobrir qual é a próxima posição na sequência, depois da posição atual a.

    • 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ê passaria incremento como o argumento proximo.
    • 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 argumento proximo.

    De novo, o proximo não é um número fixo, é uma funçãozinha, um procedimento, que o soma 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.

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 onde f(x) = x. De novo, você escreve um HOP que recebe a função f 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.

← Voltar para a página inicial