無駄と文化

実用的ブログ

Perl と Rust

どうも id:todays_mitsui です。この記事は はてなエンジニア Advent Calendar 2023 の23日目の記事です。

昨日は id:cateiru さん、明日は id:motemen さんです。

 

 

『達人プログラマー』という有名な書籍があって、そのなかで毎年少なくとも一つの言語を学習することを薦めています。

言語が異なると、同じ問題でも違った解決方法が採用されます。つまり、いくつかの異なったアプローチを学習するこにより、幅広い思考が可能になるわけです。これによって、ぬかるみにはまってしまう事態を避けられるようになります。

『達人プログラマー』第1章 達人の哲学 より引用

私事ではありますが今年はなんと2つの新しい言語を学ぶことができました。Perl と Rust です。
それぞれの言語から感じたおもしろい対比について書きます。

 

3行まとめ

  • Perl は自由で hackable
  • Rust は堅苦しくて教育的

 

Perl は自由で hackable

Perl を学んでいて驚いたのはオブジェクト指向を実現するための素朴な方法と、それを補うための多くのモジュールでした。

Perl において class はただの package で、bless $obj, 'Some::Pkg; によって変数と package を結びつけます。
そしてメソッド呼び出し $obj->some_method($arg)Some::Pkg::some_method($obj, $arg) の糖衣でしかありません。

package Rectangle {
    sub new {
        my ($class, $width, $height) = @_;
        my $self = { width => $width, height => $height };
        return bless $self, $class;
    }

    sub area {
        my ($self) = @_;
        return $self->{width} * $self->{height};
    }
}

package main {
    my $rect = Rectangle->new(3, 4);

    print 'width: ' . $rect->{width} . ', height: ' . $rect->{height} .  "\n";
        # => width: 3, height: 4
    print 'area: ' . $rect->area . "\n";
        # => 12
}

おお、なんとシンプル。私はこのシンプルさが好きです。

 

シンプルな言語機能とたくさんのモジュール

が、この素朴な仕組みだけで現実世界のコードを書こうと思うと DRY でなかったり private なプロパティが無かったり不足を感じる人も多いようです。
というわけでオブジェクト指向を実現する (アシストする) モジュールがたくさんあります。

はい、
プロジェクトではこれらのモジュールを自由に選択して使うことが可能で。幸か不幸か class 毎に別のモジュールを選択して書くことも可能です。

なんというか、無駄に学習量が多いなと感じました。
たかがオブジェクト指向をやるために (デザインパターンをゴリゴリやっていこうとか言ってるわけでもないのに!)、これらのモジュールで できること/できないこと/読み方/書き方 を学ばないと始まらないとしたら、それは不毛だなと思います。

 

Perl は多くのことを許す

私はこれらのモジュールの乱立を批判的に見ているわけではなく「Perl って自由だなー」という感想を持っています。
ここで言う "自由" は "hackable" と読み替えることもできます。Perl はとても hackable な言語で、「こんな書き方できたらいいのに」と想像するものが大抵ユーザーランドで実装できてしまう柔軟性を持っています。

ここではオブジェクト指向まわりのモジュールを例に挙げましたが、他にも Perl の自由で柔軟な仕様を活かしたモジュールは多くあって。それらはときにマジカルに見えます。

 

そんな私のお気に入りは Exporter モジュールです。
モジュールの export の仕組み自体がモジュールになっている面白さ。そして Exporter モジュールを使わずに import メソッドの機構を直接利用すれば、これらの挙動をもっと魔改造できることにロマンを感じました。

 

Rust は堅苦しくて教育的

自由で hackable な Perl とは対照的に、Rust は何と云うか堅苦しいです。
所有権・借用システムがあり。その規則に反するコードは実行どころかコンパイルもさせてもらえません。

私はある程度のルールや制約はパズルとして取り組めるタイプなので楽しめました。が、分かりづらいコンパイルエラーで "なぜか動かない" という状態になることもありました。

例えば、ハッシュマップから1要素を取り出して返す関数を書いてみます。

fn take<T>(hash: HashMap<&str, T>, key: &str, default_value: T) -> T {
    match hash.get(key) {
        None => default_value,
        Some(value) => *value,
    }
}

この関数は rust の所有権チェッカーによって検査され、以下のようなエラーになります。

// error: cannot move out of `*value` which is behind a shared reference
fn take<T>(hash: HashMap<&str, T>, key: &str, default_value: T) -> T {
    match hash.get(key) {
        None => default_value,
        Some(value) => *value,
//                     ^^^^^^ move occurs because `*value` has type `T`, which does not implement the `Copy` trait
    }
}

「借用したものの所有権を他に渡してはいけない」というエラーが出ています。

この場合 *value を返す代わりに value.clone() してクローンした値を返してあげるといいんですが。value がとても大きなデータだったらクローンするときにその分のメモリが使われしまいます。

Rust のコンパイルエラーは (元が間違っていたのでない限り) 回避できない

さてもっと上手いやりかたでクローンせずに所有権つきの値を返す方法はないのでしょうか?

実は在りません。

はい Rust を学習し始めた当初、この手のエラーをどうにか回避する書き方はないかと試行錯誤しましたが時間の無駄に終わりました。
この例における制約は「一つの値の所有権を持てるのは一つの変数だけ」というルールから派生しています。

Rust はこのルールに対しての抜け道は決して用意していません。用意されている選択肢は「所有権をあきらめて借用でガマンするか」「クローンの痛み(コスト)を受け入れるか」の2択です。 *1

この経験から私が学んだのは「コレクション型にランダムアクセスする場合、得られるのは借用だけ」というルールと。より高次の気づきとしての「Rust は痛み(コスト)を隠蔽したりしない」という事実です。

 

Rust がコンパイルエラーを出すときは「そう上手くやれない事情」がある

私が Rust は教育的と感じるのはこの部分においてです。

Rust の所有権・借用システムや型システムはコストやリスクを裏側に隠したりせず、むしろコストやリスクを表明します。
Rust がコンパイルエラーを出すときは何か「そのように上手くやれない事情」があるのです。

Rust を書こうと思うとスタックとヒープについて知らなければいけません。所有権についても知らなければいけません。
最初に知らなければいけないことが多いという意味で「初期の学習コストが高い」と嫌厭されることもありますが。遅かれ早かれ知るべきことを逐一指摘してくれているだけと見ることもできます。
Rust はコストやリスクを便利なライブラリの裏側に隠しておいて、いざ問題が起こったときに「そういうリスクがあることはドキュメントの隅に小さな文字で書いておいたでしょう?」と云うようないじわるな言語ではありません。もっと手前で、あなたがプログラムを実行するタイミングで眼に見える形で止めます。

 

私は Rust ほどユーザーを親切丁寧に教育してくれる言語を知りません。それは驚異的なことです。

 

まとめ

という訳で今年学んだ2つの言語 Perl と Rust についてつらつらと書いてきました。

何かを学ぶって本当にいいものですね。

 

 

私からは以上です。

 

おまけ: どのように学んだか

誰も興味無いと思いますが Perl と Rust をそれぞれどのように学んだかメモしておきます。

Perl

まずは本を2冊ほどザーッと読みました。

どちらも1週間ほどあれば無理なく読めるボリュームです。
抽象的すぎず具体的すぎず読みやすい内容だと思います。

Perl は言語仕様がシンプルなので、上記の2冊をザッと流せば無理なく読み書きできるようになるでしょう。

 

...というのは嘘で。Perl 世界はマジカルな文法とググラビリティの低い演算子で溢れています。
それらを読み解く足掛かりとしては ChatGPT を大いに利用しました。Google 検索では上手くヒットしない記号だらけの演算子についても ChatGPT はちゃんと教えてくれました。

ChatGPT を活用した Perl 学習サポートについては、勉強会で発表したときの資料をブログにあげています

 

Rust

Rust についても本を読みました。

が、この本を読むだけで Rust を書けるようになるかと云うと厳しいと思います。本がダメなわけではなく、水泳の本を読んだだけで泳げるようにはならないというのに似ています。
泳ぎを学ぶにはやはり実際に水に触れて、勇気を出して頭まで潜ってみるのが大切で。Rust で云うと、実際にコードを書き進めないかぎり真の理解はえられないだろうなと思います。

私の場合は、自分が書いた既存のプログラムを Rust でリライトすることで Rust コードに触れました。
かつて自分が書いたコードなのでドメイン知識自体はすでに持っていて、Rust の文法やルールを守ることに集中しました。

加えて GitHub Copilot に大いに助けられました。
Rust はしっかりした型システムがあるおかげで Copilot との相性がとてもいいです。Copilot に補完されながらでもコードが書き進められれば自信が得られますし。補完されたコードを見て「え、そんな書き方できるんだ」と学びになることも多かったです。

 

オススメの書籍も一応あるので貼っておきます。

wasm をコンパイルターゲットとして Rust でゲームを作るという内容です。
まぁまぁ歯応えはありますが、とりあえず動くものを作りながら手を動かしつつ学びたい人にオススメです。

 

私からは以上です。

*1:Rc を使っても良さそうですが、Rc には参照カウントの痛み(コスト)がついてきます

JavaScript で関数とクラスを見分ける

JavaScript においては typeof や constructor を参照するだけでは関数と class を見分けることができません。

// ただの関数
function myFunction() {}

// class
class MyClass {}

// typeof では見分けられない
typeof myFunction;  // => 'function'
typeof MyClass;     // => 'function'

// constructor でも見分けられない
myFunction.constructor;  // => Function
MyClass.constructor;     // => Function

関数と class を見分けるには toString() すると良いです。

myFunction.toString();  // => 'function myFunction() {}'
MyClass.toString();     // => 'class MyClass {}'

class の場合、toString() した結果が 'class ' から始まります。

もし関数と class を判別する関数を書くとしたらこのようになります。

function isFunction(obj) {
  return typeof obj === 'function' && !obj.toString().match(/^class /);
}

function isClass(obj) {
  return typeof obj === 'function' && obj.toString().match(/^class /);
}

isFunction の方の条件を obj.toString().match(/^function /) にしていないのは、アロー関数を toString() した場合に結果が 'function ' から始まらないからです。

 

なぜこんなことになっている?

なぜ typeof や constructor だけでは関数と class が見分けられないのでしょうか。
それは class は本質的に関数と同じものだからです。

一昔前 [いつ?] まで JavaScript には class 構文が無くて、人々は function 構文を使って class 相当のものを書いていました。

例えばこのように、

class Rectangle {
  constructor(height, width) {
    this.height = height;
    this.width = width;
  }

  area() {
    return this.height * this.width;
  }
}

// ↓↓↓ かつてはこう書いていた

function Rectangle(height, width) {
  this.height = height;
  this.width = width;
}

Rectangle.prototype.area = function() {
  return this.height * this.width;
};

メソッドを定義するときに .prototype を参照しています。このあたりの構文が JavaScript がプロトタイプベース言語であることを思い出させてくれますね。 *1

このように私たちが class 構文で定義しているものは関数でもあるので、二つを見分けるのには特殊なテクニックが必要になってしまっています。

 

区別する必要はない?

この件を ChatGPT に聞いてみると「JavaScript において class は関数と同等なので二つを区別する必要はありません」と言われました。本当でしょうか?

もう一度 function 構文を用いて定義した Rectangle クラスのコードを引用します。

// コンストラクタ
function Rectangle(height, width) {
  this.height = height;
  this.width = width;
}

// area メソッドの定義
Rectangle.prototype.area = function() {
  return this.height * this.width;
};

コンストラクタの中では return で値を返すことをしていません。そのため Rectangle(2, 3) のように new を付けずに関数として Rectangle を呼び出すと返り値は undefined になってしまいます。

というわけでやはり関数と class の区別は必要ですね。おのれ ChatGPT 。

 

まとめ

function 構文を用いた class 定義、久しぶりに書いたのでとても懐かしい気持ちになりました。

 

 

私からは以上です。

*1:実際には現代もこのあたりの事情はまったく変わっていなくて、 class 構文は単なる function 構文のシンタックスシュガーです

自然数の集合が無限集合であることを証明する

6歳になる子供がいる。ある日、自転車に乗りながらこんな話をした、

👦「ねぇ、数字って数えていったらどこまで続くの?」
👨『いい質問だね、どう思う?』
👦「うーん、どこまでも続く?」
👨『そう、数字はどこまでも続くから 1, 2, 3, ... と数えていっても終わりはないんだよ』

本当だろうか?確かめてみよう。

 

目次

 

自然数の集合 ℕ は無限集合であると定義されているか?

自然数の定義はいろいろあり得るが、代表的かつ簡単な定義はこうだ。

次の公理を満たす集合 ℕ の元を自然数と云う

集合 ℕ と定数 o と関数 S について、
 1. o ∈ ℕ
 2. 任意の n ∈ ℕ について S(n) ∈ ℕ
 3. 任意の n ∈ ℕ について S(n) ≠ o
 4. 任意の n, m ∈ ℕ について n ≠ m ならば S(n) ≠ S(m)

この公理を ペアノの公理 と云う。

普段、私たちが素朴に "自然数" と呼んでいるものはこの公理を満たす。
定数 o とは 0 のことで。 関数 S とは「n に対して n の次の数を返す関数」のことだ。

集合 ℕ : 自然数の集合、0, 1, 2, ...
定数 o : 0
関数 S : S(n) = n + 1

こうなる。 *1 *2

さて自然数の定義を見てみたが「自然数は無限にある」とは書かれていなかった。
定義にも公理にも無い以上、自然数が無限にあると主張するには証明が必要だ。

というわけで証明してみよう。

 

ペアノの公理の気持ちを理解する

証明にはペアノの公理を使う。証明に取りかかるまえにペアノの公理の気持ちを理解しておこう。
公理 1~4 の "気持ち" を文章にするとこうだ、

  1. o ∈ ℕ
    → 集合 ℕ には少なくともひとつの要素 o が在る
  2. 任意の n ∈ ℕ について S(n) ∈ ℕ
    → 全て自然数には「次の数」が在って、「次の数」もまた自然数だ
  3. 任意の n ∈ ℕ について S(n) ≠ o
    → ただし o は「次の数」にはならない、o の「前の数」は無い
  4. 任意の n, m ∈ ℕ について n ≠ m ならば S(n) ≠ S(m)
    → 「次の数」が他の人と被ることはない

さらにペアノの公理の集合 ℕ を絵に描いてみよう、

graph LR;
    O((o)) -->|"S(o)"| N1(("n₁"));
    N1(("n₁")) -->|"S(n1)"| N2(("n₂"));
    N2(("n₂")) -->|"S(n₂)"| N3(("n₃"));
    N3(("n₃")) -->|"S(n₃)"| NEXT[どこまでも続く...];

o から始まって次の数 (関数 S による変換) を辿っていくとどこまでも続く。この「次の数チェーン」を辿った全体が自然数の集合 ℕ になる。

 

自然数が無限にあることは自明か

先ほどの絵を見ていると「うーん、自然数が無限にあるのは自明だなぁ」と思う読者もいるんじゃないか。
気持ちは分かる。さきほどの絵がきれいすぎるのが良くない。

今度はもう少し汚い絵を描いて「自然数ってもしかしたら有限かも」という気持ちも味わってみよう。
次に見ていくのはどれも "このようにはならない" という例である。

次の数チェーンが途切れる

もしも次の数チェーンが途中で途切れていたらどうだろう。その場合は「自然数は有限個」ということになりそうだ。

graph LR;
    O((o)) -->|"S(o)"| N1(("n₁"));
    N1(("n₁")) -->|"S(n₁)"| N2(("n₂"));
    N2(("n₂")) -->|"S(n₂)"| N3(("n₃"));
    N3(("n₃")) --->|...| Nz(("nₓ"));

nₓ には次の数が無い。なのでチェーンはここで終わり。

ということにはならない。公理 2 が「全ての自然数に次の数があり、かつ、次の数もまた自然数である」ことを要請してくれている。

次の数チェーンが袋小路にはまる

次の数チェーンの先がループになっている場合も問題がありそうだ。

graph LR;
    O((o)) -->|"S(o)"| N1(("n₁"));
    N1(("n₁")) -->|"S(n₁)"| N2(("n₂"));
    N2(("n₂")) -->|"S(n₂)"| N3(("n₃"));
    N3(("n₃")) --->|...| Nz(("nₓ"));
    Nz(("nₓ")) -->|"S(nₓ)"| N2(("n₂"));

全ての自然数に次の数があるという公理を満たしつつ有限個になってしまった。

このようなことも起こらないはずだ。公理 4 によって次の数が被らないことが要請されている。 S(n₁)S(nₓ) の矢印がどちらも n₂ に向いているのは公理 4 に反している。

次の数チェーンが元の場所に戻ってくる

次の数チェーンがぐるっと一周して元の場所 o に戻ってくる場合はどうだろう。

graph LR;
    O((o)) -->|"S(o)"| N1(("n₁"));
    N1(("n₁")) -->|"S(n₁)"| N2(("n₂"));
    N2(("n₂")) -->|"S(n₂)"| N3(("n₃"));
    N3(("n₃")) --->|...| Nz(("nₓ"));
    Nz(("nₓ")) -->|"S(nₓ)"| O((o));

チェーンは途切れていないし、次の数が被ってもいない。でもループができてしまった。

やはりこのようなことも起こらない。公理 3 が「o は次の数にならない」ことを要請している。 S(nₓ) の矢印が o に向いているのはおかしなことだ。

 

ここまでどうにか自然数有限個の危機は回避してきた。が、自然数が無限に存在していることは全然自明ではない気がしてきた。ちゃんと証明して心から安心したいところだ。

 

自然数の集合 ℕ が無限集合であることを証明する

というわけでようやく証明にとりかかろう。

背理法で示す。ペアノの公理を満たす有限集合 ℕ が存在し、その要素数が k であると仮定する。

関数 S によって ℕ の各要素が別の要素に移る様を書き並べると。

 \mathbb N \ni o \rightarrow n_{\, w} \in \mathbb N
 \mathbb N \ni n₁ \rightarrow n_{\, x} \in \mathbb N
 \mathbb N \ni n₂ \rightarrow n_{\, y} \in \mathbb N
 \vdots
 \mathbb N \ni n_{\, k-1} \rightarrow n_{\, z} \in \mathbb N

左辺に着目すると、公理 2 により全ての要素を S によって移せるため k 個の要素全てが現れていることになる。
一方右辺に着目すると、公理 3 により右辺には o が現れないため高々 k-1 個の要素しか現れない。

S によって k 個の要素が高々 k-1 個の要素に移されるため、 鳩の巣原理 によりある i, j が存在し、nᵢ ≠ nⱼ, S(nᵢ) = S(nⱼ) となる。しかしこれは公理 4 に矛盾する。

よって仮定に誤りがあり、ペアノの公理を満たす集合 ℕ が存在すればそれは無限集合であることが示された。

 

まとめ

子供の質問にちゃんと答えるのは難しい。

 

 

私からは以上です。

*1:自然数を 1 から始める流儀もあるが、この記事では 0 から始まる前提で進める。1 から始まる流儀を採用しても以降の議論は同じになる

*2:余談だがペアノの公理を満たすような (ℕ, o, S) の組は他にもある、何なら無限に存在するらしい