2 ポイント 投稿者 GN⁺ 2024-11-18 | 1件のコメント | WhatsAppで共有

David Beazley と SICP 講義の感想: 1週間の体験

2022年末に David Beazley の SICP 講義 に参加した体験を共有する。無料の資料は数多くあるが、Dave の講義は特定のテーマを選び、深く掘り下げて説明することで非常に効果的だった。

出発点

SICP 講義は Scheme 言語で進められ、ここでは Python で簡単な Scheme インタプリタを実装し、基礎概念である 置換(substitution) モデルを説明する。

Scheme 言語の基本

  • プリミティブ(Primitive): 基本的な値(例: 整数)
  • 演算子: +, -, *, / などの基本演算を前置記法で使用
  • define: 変数定義
> (define x 2)  
> (+ x 3) ; 結果: 5  
  • if: 条件式
  • lambda: 無名関数の定義
> ((lambda (x) (* x x)) 3) ; 結果: 9  

Python における Scheme インタプリタ

Python を使って Scheme コードを評価する簡単なインタプリタを実装する。基本演算は Python 関数として定義される。

definitions = {  
    "+": lambda x, y: x + y,  
    "*": lambda x, y: x * y,  
}  

:

> evaluate(("+", 2, 3)) # 結果: 5  

definelambda の実装、そして条件式 if の処理まで含まれる。

置換モデル(Substitution Model)

置換モデルは単純なプログラム解釈の方式で、変数を値に置き換えながらプログラムを評価する。しかし 代入(assignment) が含まれると、このモデルは破綻する。

状態(State)

置換モデルが崩れる例として 代入(assignment) を挙げられる。たとえば銀行口座の残高をモデル化する際、set! を使って変数を更新する。

(define balance 100)  
  
(define (withdraw amount)  
  (set! balance (- balance amount))  
  balance)  

この場合、置換モデルは更新前と更新後の残高状態を区別できない。

環境(Environment) モデルが必要になる。変数は環境の中で定義され、各手続きはそれぞれ独自の環境を持つ。

ストリーム(Streams)

状態をモデル化するもう一つの方法として ストリーム がある。ストリームは遅延評価(lazy evaluation)を通じて未来の値もモデル化できる。

無限ループと評価順序

評価順序の違い: ほとんどの言語は 適用順評価(applicative-order evaluation) を使い、引数を先に評価する。

> (square (+ 1 2)) ; 結果: 9  

しかし 正規順評価(normal-order evaluation) は、引数が実際に必要になるまで評価を遅らせる。これにより無限ループを回避できる。

> (define (p) (p))  
> (define (test x y) (if (= x 0) 0 y))  
> (test 0 (p)) ; 正規順では 0 を返し、適用順では無限ループ  

ラムダ計算と Church 数

Church エンコーディング によって、数を手続き(procedure)として表現できる。これは関数型プログラミングの重要な概念である。

(define (zero f) (lambda (x) x))  
(define (increment n) (lambda (f) (lambda (x) (f ((n f) x)))))  
  • zero は引数をそのまま返す関数(identity 関数)
  • increment は関数呼び出しをもう一度適用する

> ((zero (lambda (x) (+ x 1))) 0) ; 結果: 0  
> (((increment zero) (lambda (x) (+ x 1))) 0) ; 結果: 1  

反復 vs 再帰

Scheme は for ループ の代わりに 再帰 を使って反復処理を行う。

再帰の例: 階乗

(define (factorial n)  
  (if (= n 1)   
    1   
    (* n (factorial (- n 1)))))  

この再帰呼び出しはスタックを使うため、多くのメモリを消費する可能性がある。

末尾再帰最適化(Tail-call optimization)

Scheme は 末尾再帰最適化 によってメモリ使用量を減らす。これにより反復的(iterative)プロセスのように動作する。

(define (factorial n)  
  (define (iter product counter)  
    (if (> counter n)  
        product  
        (iter (* product counter) (+ counter 1))))  
  (iter 1 1))  

まとめ

David Beazley の講義は、SICP の主要概念を選び出して深く扱っている。特に関数型プログラミング、ラムダ計算、評価順序など、さまざまなプログラミングパラダイムを理解する助けになる。

Knuth の引用

理論だけを学んでいるなら実践的な面に集中すべき時期であり、実践だけをしているなら理論的な面に集中すべき時期であることを意味する。

1件のコメント

 
GN⁺ 2024-11-18
Hacker News のコメント
  • 注意しておくと、SICP/Lisp/Schemeに深く入り込むと、プログラミングについての考え方が変わることがあり、そうした知的刺激は常に歓迎すべきものです。
    ただし、そのアイデアをオブジェクト指向のコードベースに丸ごと適用すると、たいてい逆効果になったり、チームメンバーの反発を招いたりします。
    たとえば Lisp 以後、すべての for ループを forEach に置き換えたり、あらゆるものを map/reduce チェーンにしたくなるかもしれませんが、その言語が関数型プログラミングを全面的に受け入れていないなら、可読性と性能の両方を損なう可能性があります。
    結局、可変メモリと CPU がコードを処理しているという事実を覚えておくことが基盤を与えてくれますし、最近では Church 数のような抽象概念よりも、データ指向設計やハードウェアの現実に合わせた「機械的共感」のほうが、日常的にはより実用的だと思います。

    • 不変性のほうが安全だと思います。
      副作用が少ないほど予測しやすく、オブジェクト指向は単なる関数で十分な場面でもよく使われます。
      ゲーム開発では計算や危険なコードが多いためオブジェクト指向がかなりよく合いますが、Web 開発では関数型プログラミングのほうがずっと自然で、SaaS では Elixir のような言語のほうが、より信頼性が高く、バグが少なく、テストしやすいコードを書かせてくれます。
    • Common Lisp Object System のようなものもあるのではないかと思います。
      さらに forEachmap/reduce のようなものは Smalltalk のコレクションにすでにあり、第一級の構文ではないにせよ Object Pascal や C++ にもコピーされました。
      下層のメモリが可変であるため、本当に必要なときには ML 系の言語にも変更を扱うためのメカニズムがあります。
    • CPU が制御フローを実装する唯一の方法は goto に近いですが、goto は Lisp よりもさらに人気がありません。
      もっと奇妙なのは、現代の CPU で性能を出すにはキャッシュが不可欠なのに、メモリ階層を第一級市民として扱う言語がまだないことです。
      最も近いのは、アンダースコアが海のようにあふれる Linux 風の C くらいに見えます。
  • 純粋関数で状態をエンコードするための良い入門になっています。
    実際、木、整数、和/積型、画像、モナドなど、あらゆるデータについて純粋関数型のエンコーディングははるかに多く存在します。
    エンコーディングは少し混乱することもありますが、同時に優雅で小さいものです。
    たとえば JavaScript で Maybe モナドを関数型に実装すると、次のようになります。
    Nothing = nothing => just => nothing
    Just = v => nothing => just => just(v)
    pure = Just
    bind = mx => f => mx(mx)(f)
    evalMaybe = maybe => maybe("Nothing")(v => "Just " + v)
    console.log(evalMaybe(bind(Nothing)(n => pure(n + 1)))) // Nothing
    console.log(evalMaybe(bind(Just(42))(n => pure(n + 1)))) // Just 43

    • このような実装は、その型の再帰原理から導出できます。
      data Maybe a = Nothing | Just a
      foldMaybe :: (Unit -> r) -> (a -> r) -> Maybe a -> r
      foldMaybe に渡す2つの高階関数が、それぞれ NothingJust に対応します。
      ただし Nothing 側は、少し正確にするために Unit パラメータを追加した形です。
    • 帰納的型を、その型の再帰子関数型に変換するものと見ることができます。
      型理論ではかなり見事ですが、実際のプログラミングにはあまり向いていません。
    • 数学的には優雅かもしれませんが、整列や内部的な正当化なしにASCII 文字集合だけでできた言語を通じて伝えられると、見た目にはかなりつらいものがあります。
    • 短く言えば、型なしラムダ計算はチューリング完全です。
  • 以前、実際のSICP講義、つまりMIT OCWの1986年の録画を見たことがある
    情報密度が高いとよく称賛されるが、実際には学生との質疑応答、講義室で「マルチメディア」発表を試みたことに講師が注意を向ける時間、授業全体の計画が事前に完全には整理されておらず質疑応答を先回りして抑えられていない部分などで、かなり多くの時間が浪費されている
    黒板に書く時間も積み重なると相当なものになる
    もちろん教材の順序はいくらでも議論して組み替えられるし、いつか自分の感覚に合う形でこの内容を説明する動画シリーズを自分で作るつもりもある
    この講義がPythonのようなより現代的な言語を使いながらも根を保っているようでうれしいし、Pythonは実用的なマルチパラダイム言語なので、完全な純粋性ではないとしても関数型イディオムによる表現力を人々は十分に評価していないと思う

    • この講義はPythonでSchemeを実装したあと、さらにSchemeでSchemeを実装している
      Pythonはこの講義から外してもよいし、外すべきだと思う
      Pythonの関数型プログラミング支援は非常に弱い
      リストはconsベースではなく、ラムダには制約が多く、パターンマッチングはひどく、式ベースでもなく、名前空間も妙だ
      Pythonは現代的な言語とも言いにくく1990年代にとどまっており、まともなC-APIがあったために、残念ながらよりよい言語たちを犠牲にしながら成長しただけだ
    • Pythonの関数型プログラミングはかなり弱く、その点ではJavaScriptのほうがむしろ優れていると思う
      末尾呼び出し最適化がないため、一部のコード演習はSchemeとはまったく違う解法が必要になる
      1:1で翻訳したコードが失敗したら、講師は学生に対して、選んだ言語のせいでそう動作しないのだからただ信じてくれと言うべきなのか、それともすべてをスタックを外部化する問題として見てそう解くべきなのかと思う
      SICPをPythonに無理やり押し込むのはかなり愚かに見える
    • ArsDigita Universityの講義もある
      サイトは今はオフラインだが、archive.orgで講義を見ることができる
      https://en.m.wikipedia.org/wiki/ArsDigita#ArsDigita_Foundati...
      https://archive.org/details/arsdigita_01_sicp/
      全カリキュラムが入ったUSBキーを販売していたが、誰かがISOを上げてくれたら本当にありがたい
      https://web.archive.org/web/20190222145553/aduni.org/drives/
    • Pythonと大半の非Lisp系言語の問題は、プログラムをデータのように扱うことを簡単には許してくれない点だ
      Schemeでは簡単に表現できることが、他の言語では複雑な演習問題になる
      学生は土台となる概念に集中する代わりに、Schemeなら気にしなくてよい実装言語の細部に集中することになる
  • cons/car/cdrをラムダで実装したものを初めて見たときは魔法のようだった
    ただ結局のところ、言語ランタイムがキー/値ディクショナリを実装していて、その実装を借りて別のデータ構造を作れることを示しているのだと思う

    • Elixirの分割束縛ロジックのほうがはるかに興味深く、ES6の弱められた版のほうがはるかに実用的だと思う
      Elixirでは望むだけ先頭から取り出せる
    • キー/値ディクショナリではなく、ポインタさえあればよい
      動作のないクロージャは閉じ込めた変数へのポインタにすぎず、2つのポインタを持つクロージャはcarcdrを得られるペアだ
      ランタイムは参照先を定義の外でも使えるようにしなければならないので、エスケープ解析やガベージコレクションなどは必要だが、ディクショナリは必要ない
  • 最近、定理証明で0 != 1のようなものを証明するにはChurchエンコーディングだけではだめで、帰納的データ型が必要だという概念に触れた
    関連する内容をざっくりここに上げてあり、SICPに対する別の批判も一緒にある: https://intellec7.notion.site/Drinking-SICP-hatorade-and-why...
    「すべては単なる関数」という見方の限界をもっとよく理解したい

    • 不等式について利用できる他の具体的な事実があれば、0 ≠ 1を証明できそうだ
      f = g -> f x = g xという定理から推論して右辺に不等式の事実を作り、その対偶を取ることができる
      Church数同士の不等式は、不等式に関する他の事実なしには直接証明できないという話は正しそうに見える
      一方、帰納的データ型では、証明システムが同じ帰納的型の2つの具体的なインスタンスから最も外側のコンストラクタ適用を再帰的に取り除き、同等性または非同等性を直接「観察」できる
    • 定理証明にはSigma型とPi型、そして何らかの形の同等性の概念が必要だ
      ScottエンコーディングやChurchエンコーディングでそれを実現できるのか気になる
    • 修正後のリンク: https://goosetaco.notion.site/Drinking-SICP-hatorade-and-why...
  • 本自体はすでにここで議論されている: https://news.ycombinator.com/item?id=42157558
    リンクがそのページの冒頭ではなく下のほうの議論に行く理由があるのか気になる
    この記事を既存の議論に統合できるのではないかと思う

  • David Beazley は Python の世界ではかなり伝説的な人物で、この講義は最初は驚くべきアイデアに見えたが、2秒ほど考えてみると完璧な組み合わせに思えて、次の講義に申し込んだ。
    要点は、こうした形が今後の ソフトウェアエンジニア向け継続教育 の姿になりそうだという点。

    • 第5章の コンパイル を扱っていないなら、SICP の最も良い部分を扱っていないことになる。
  • “the substitution model” セクションのコードに誤字がある。
    ("+", ("fib", ("-", "n", 2)), ("fib", ("-", "n", 1))),
    定義されているのは fibonacci で、fib は定義されていないので、2つの fib 呼び出しは明らかに fibonacci であるべき。
    実際の GitHub リポジトリのコードは正しい: https://github.com/savarin/pyscheme/blob/0f47292c8e5112425b5...

  • SICP は素晴らしいと思う。
    ただ、さらに学び、数学をより勉強するほど、関係のほうがより根本的なプリミティブ概念だという結論に近づいた。
    すべての関数は制限された形の関係として表現できるが、その逆はかなりの追加の仕組みを入れない限り成り立たない。
    リレーショナルデータベースと SQL はリレーショナルプログラミングの最もよく知られた成功例ではあるが、この領域は依然として大きく未開拓だと思う。
    今の関心は、プログラミング言語設計よりも、ごく幼い子どもたちに数学の基礎を教えることに近い。
    不思議なことに、「大きい」のような述語を1項関係として、「より大きい」を2項関係として教えるほうが、同じ概念を関数として捉えようとするよりずっと簡単だ。

  • 「すべてが関数」という見方は過度に単純化されていて、しばしば役に立たないのであまり好きではない。
    たとえば、キャッシュ、RAM、ディスクなどに収まらない関数があり、N-way JOIN や探索/マッチングのように ビッグオーが爆発 する関数があり、非冪等性を含む副作用のある関数もある。
    関数に対するサイドチャネル攻撃を考える人はほとんどいない。
    日付、時刻、継続時間などに依存する非決定的な関数もあり、関数は途中で失敗したり、優雅に失敗できなかったりもする。
    共有リソースプールを使う別の「関数」に影響を与えるリソースを消費しない、と仮定するのも難しい。
    関数の引数は任意に大きく複雑になりうるが、現実には限界があり、そうなるとポインタが必要になり、さらに Web やディスクのようなリモート参照が必要になる。
    どこで止めろと言ってくれると助かる。いくらでも話せる。

    • 過度な単純化が、ときには素晴らしいこともある。
      この場合、ラムダ計算モデル は「すべてがただの関数」というアプローチの基盤であり、非常に単純でありながらチューリングマシンなどに比べて扱いやすく推論しやすいので、良い計算モデルだ。
      そのため、ほとんどのコンピュータ論理と証明システムの基盤になっている。
    • その多くは関数としてモデル化でき、実際にそのように書かれさえすればよい。
      ある関数がリソースを必要とするなら、そのリソースを要求するようにすればよく、日付/時刻に依存するなら日付/時刻に依存するようにすればよく、非決定的な値を返すなら 非決定的な値 を返すようにすればよい。
      関数型プログラミングのアプローチが光る理由の一つは、こうしたものを真剣に扱うことを強制するからだ。
      暗黙に共有されるリソースを使いたいなら、それをモデル化しなければならず、暗黙の共有リソースに依存する「関数」は実際の関数と明示的に区別される。
    • ここで関数と呼ばれているものの半分は、実際には関数ではなく、関数型プログラミングの世界でも多くの人は関数とは呼ばないだろう。
      むしろ 手続き に近い。
      関数は手続きだが、すべての手続きが関数であるわけではない。
    • 理論的な目的においては、それが唯一の希望だと思う。