見出し画像

初心者がプログラミングを学ぶときに最も効果的な方法は「写経」だと思う

こういうエントリを見かけたので。

僕は1990年代からプログラミングを人に教える仕事をしています。最初は中学の時に技術家庭科の授業を先生から任されて同級生にプログラミングを教えることから始まりました。その後、色々な方法を試しましたが、結論としてプログラミング初心者は写経した方が結局は上達が速いと今は考えています。

それが特に強く感じられたのは2015年頃から色々な人にAI関連のプログラミングを教え始めた頃です。

AI関連のプログラミングには、当時からPythonが主流になりつつありました。Pythonについては僕自身も当時そんなに詳しいわけではなかったので、最初は独特の記法に戸惑うことが多かったのです。

多分、C言語やJavaScriptに慣れた人がいきなりPythonのプログラムを書こうとするとあまりに独特なルールが多くて面食らってしまうと思います。

ブロックの書き方が{と}で囲むのではなく、インデントで行うと言う簡単なものから、配列(リスト)の参照が[1][2]だけでなく[1:]や[-1]のようなトリッキーな呼び方がある、など、他の言語を知っていればいるほどPythonの異質さにちょっと戸惑うわけです。

この頃僕は産総研と農研機構でAIプログラミングの講師をやるのですが、最初に写経をさせたところ、みんな嫌がりました。

産総研や農研機構の研究者は専門分野は違えど一端のプログラマーです。今更ゼロから写経なんかやってられるか、と思うのも無理なからぬことです。

しかし、いざ実際にプログラムを書いてみると、一流の研究者たちが皆面白いようにみんな間違うのです。しかも同じところで。

その同じところというのが、大抵は括弧の対応関係があってないとか、インデントがずれているとか、初心者が最初につまづく場所です。

そして[,]と(,)の違いと厳密さ、なんかに慣れていくうちにだんだんとPythonのお作法がわかってきて、同時にnumpy配列の扱い方もわかってくるようになったりするわけです。

これを最初に体に叩き込むと、エラーが出ても「おかしいのはインデントか括弧の対応か、それとも関数呼び出しか」ということが瞬時に判断できるようになります。

ある程度慣れたら、写経からは卒業していいと思いますが、最初の段階では「何が悪いのか」「どんなミスを犯しがちなのか」ということを体験的に知っておく必要があります。そのために写経は理想的な方法なのです。

今はAIがほとんどのコードを書いてくれるようになりましたが、痒いところにが届くかどうかはまだ微妙な段階です。

ちょっとした修正が取り返しのつかない大惨事を生んだ時、「これインデントがおかしいよ」と指摘してくれるほどまだAIは賢くありません。時間の問題かもしれませんが。

ただ、僕は写経をせずにDelphiというIDE(統合開発環境)で使っていたPascalという言語では今に至るまでbeginとendとend.の区別が曖昧な記憶のままです。Pascalの試験を受けたら間違いなく落第すると思います。

写経は、その言語の性質を体に叩き込むためにはまず第一にやっておいた方がいい練習です。

ピアノを練習するのに一番最初にやるのは運指の練習で、これをちゃんとやらないとピアノが上手く弾けるようになりません。運指は全くクリエイティブなことではないのですが、基礎のトレーニングとして欠かせないものです。

写経を経ずにAIに頼ったコーディングばかりしていると、AIが結局何を言ってるのか(どんなコードを書いたつもりになっているのか)よくわからないまま使うことになってしまいます。プログラムは一度動き出して仕舞えば、そのスピードは人間が対応できる限界を軽く超えてしまうので、重要なプログラムであればあるほど、それがどう動くかを人間が事前に把握しておくことが大切になります。

僕はLispの写経もあまりしてないので、最近はAIにLispを書かせていますがAIの書いたLispコードを追いかけるのが大変なので、AIが書いたコードを写経しながら「これはこう省略できるんじゃないか」と省略してみてはエラーで怒られたりしています。

「これは省略してはいけない」「これは省略できる」「これはもっと短く書ける」ということを体験的に学べるのが写経の持つ価値ではないかと思います。

遠回りなようでも、新しい言語やフレームワークに触れるときは僕は必ず一度は写経をします。

というのも、プログラミング言語はどれも洗練されているため、誤解を生みやすいのです。

例えばPythonで言うと

a = [1,2,3]
b = (1,2,3)

このaとbはほとんど同じ書き方に見えますがaは書き換え可能でbは書き換え不能というすごく重要な違いがあります。

Pythonコードで動作が怪しい時、僕は必ずREPLで確認します。この時、写経した経験がゼロだとREPLさえ満足に使うことができません。

自分の指でちゃんと扱えるから、それがもっと多くの手順に拡張されてもちゃんと手の上にあると実感できるのですが、それをしないといつまで経っても何が起きているのかちゃんと確信を持って理解することは難しくなります。

写経をしてない初心者がいかにダメか説明するために恥を忍んで写経をせずにLispをAIにばかり書かせている僕がLispでごく簡単なHello Worldプログラムを書いてみます。このプログラムはPythonでは以下のような簡単なコードです。

name = "shi3z"
print(f"Hello %s"%name)

これが書けないPythonプログラマはいません。しかし写経をしないとこの程度のプログラムさえ書けない可能性があります。

僕が参考にしたのは、最近AIに書かせた以下のようなLispコードです。


(defun process-user-input (input)
  (format t "User input: ~A~%" input)
  (let ((result (send-openai-request input)))
    (if result
        (format t "~A~%" result)
        (format t "Failed to get response from the server.~%"))))

これを見て、「よーし、じゃあnameにshi3zと言う文字列を入れてformatで表示しちゃうぞー」と思ったとします。思いました。

それで僕は適当にこんな感じで入れてみました。
どうやらLispではletで変数への代入、formatでフォーマット指定ができるようです。

 ((let name "shi3z")
   (format t "Hello ~A~%" name))

これをsbcl(LISPのREPL)に入れてみましょう。

* ((let name "shi3z")
   (format t "Hello ~A~%" name))
; in: (LET NAME
;    "shi3z") (FORMAT T "Hello ~A~%" NAME)
;     ((LET NAME
;        "shi3z")
;      (FORMAT T "Hello ~A~%" NAME))
; 
; caught ERROR:
;   illegal function call
; 
; compilation unit finished
;   caught 1 ERROR condition

debugger invoked on a SB-INT:COMPILED-PROGRAM-ERROR in thread
#<THREAD tid=259 "main thread" RUNNING {7008390603}>:
  Execution of a form compiled with errors.
Form:
  ((LET NAME
   "shi3z")
 (FORMAT T "Hello ~A~%" NAME))
Compile-time error:
  illegal function call

Type HELP for debugger help, or (SB-EXT:EXIT) to exit from SBCL.

restarts (invokable by number or by possibly-abbreviated name):
  0: [ABORT] Exit debugger, returning to top level.

((LAMBDA ()))
   source: ((LET NAME
              "shi3z")
            (FORMAT T "Hello ~A~%" NAME))
0] 

Lispの特徴として、エラーが長すぎてなんだかわかりません。
どれがエラーメッセージの本文なのかよくみないとわからないので、エラーの内容をよくみる必要があります。

ChatGPTに聞くと次のような答えが返ってきました。

エラーメッセージが出ている原因は、LETの使い方です。LETの引数には、変数の名前とその初期値を対にしてリストで指定する必要があります。しかし、以下のコードでは LET に直接 (NAME "shi3z") と書かれており、それが正しい形式ではありません。

以下が修正済みのコードです:

(let ((name "shi3z")) (format t "Hello ~A~%" name))

LETの中で変数 name に "shi3z" という値を代入していますが、代入はリストの中で行う必要があります。((name "shi3z")) という形式で変数名と値をペアにして記述します。これが正しい構文です。

この修正により、エラーが解消されて動作するはずです。

なるほど、「(let (name "shi3z"))」ではなく、「(let ((name "shi3z"))」なんだなと。「(」ではなく「((」なのは、複数の変数を定義する可能性があるから、と言うことらしい。

ではそのまま入れてみる。

* (let ((name "shi3z")))
; in: LET ((NAME "shi3z"))
;     (NAME "shi3z")
; 
; caught STYLE-WARNING:
;   The variable NAME is defined but never used.
; 
; compilation unit finished
;   caught 1 STYLE-WARNING condition
NIL

エラーは出なかった。
では続けてformatでnameを使ったメッセージを作ってみる。

* (format t "Hello ~A~%" name)

debugger invoked on a UNBOUND-VARIABLE @7003785B18 in thread
#<THREAD tid=259 "main thread" RUNNING {7008390603}>:
  The variable NAME is unbound.

Type HELP for debugger help, or (SB-EXT:EXIT) to exit from SBCL.

restarts (invokable by number or by possibly-abbreviated name):
  0: [CONTINUE   ] Retry using NAME.
  1: [USE-VALUE  ] Use specified value.
  2: [STORE-VALUE] Set specified value and use it.
  3: [ABORT      ] Exit debugger, returning to top level.

(SB-INT:SIMPLE-EVAL-IN-LEXENV NAME #<NULL-LEXENV>)
0] 3

盛大にエラーが出た。何が悪いんだよ。
と思ったが、Lispではそもそもletはレキシカルスコープと関係するんだったと思い出す。

最初のletを抜けた時点でnameはフリーになってるから次のformatで使えないと言うわけだ。
ではこれでいいのか?

( (let ((name "shi3z"))
    (format t "Hello ~A~%" name) )

試してみる。

* ( (let ((name "shi3z"))
    (format t "Hello ~A~%" name) )
)
; in: (LET ((NAME "shi3z"))
;    (FORMAT T "Hello ~A~%" NAME))
;     ((LET ((NAME "shi3z"))
;        (FORMAT T "Hello ~A~%" NAME)))
; 
; caught ERROR:
;   illegal function call
; 
; compilation unit finished
;   caught 1 ERROR condition

debugger invoked on a SB-INT:COMPILED-PROGRAM-ERROR in thread
#<THREAD tid=259 "main thread" RUNNING {7008390603}>:
  Execution of a form compiled with errors.
Form:
  ((LET ((NAME "shi3z"))
   (FORMAT T "Hello ~A~%" NAME)))
Compile-time error:
  illegal function call

Type HELP for debugger help, or (SB-EXT:EXIT) to exit from SBCL.

restarts (invokable by number or by possibly-abbreviated name):
  0: [ABORT] Exit debugger, returning to top level.

((LAMBDA ()))
   source: ((LET ((NAME "shi3z"))
              (FORMAT T "Hello ~A~%" NAME)))
0] 

あちゃー、今度は自分でも原因がわかった。letの変数定義の後に続けて他の関数呼び出しを入れていいんだ。

じゃあこれならどうだ

(let (name "shi3z"))
  (foramt t "Hello ~A~%" name))

試してみる

* (let ((name "shi3z"))
   (format t "Hello ~A~%" name))
Hello shi3z
NIL

 やっとできた。
Lispはほとんど触ってないが、他の言語であればプログラミング歴40年でこれである。

いかに写経しないプログラマーが「本当はちゃんとわかってない」かわかっていただけたのではないかと思う。

まあ本当はEmacsとかlisp文法に対応したエディタを使えばここまで悲惨なことにはならないと思うが、それはそれでやはり「身体に染み込んでない」と言うことなので。