Gleamを使ってみた第一印象
原文は Michael Lynch により に公開されました。 このブログを購読する
今年学ぶ新しいプログラミング言語を探しているのですが、Gleamが一番楽しそうに見えました。Elixirに似た、静的型付けをサポートする言語です。
言語ツアーを読んで内容は理解できたのですが、プログラミング言語をきちんと評価するには、やはり何かを作ってみる必要があります。
Gleamを数時間使ってみた際のメモを共有します。Gleamを学ぼうとしている人や、言語を開発しているチームの参考になれば幸いです。
私のプロジェクト:古いAIMログをパースする
1999年から2007年頃までAOL Instant Messengerを使っていました。その大半の期間、会話をログに残してくれるAIMクライアントを使っていたのですが、フォーマットはさまざまでした。ログの多くはXMLやHTML形式で、読み返すのがとても面倒です。
最もシンプルなAIMログはプレーンテキスト形式で、次のようになっています。
Session Start (DumbAIMScreenName:Jane): Mon Sep 12 18:44:17 2005
[18:44] Jane: hi
[18:55] Me: hey whats up
Session Close (Jane): Mon Sep 12 18:56:02 200510年ごとに一度くらいのペースで、古いログをすべて統一された読みやすい形式に変換するための汎用AIMログパーサーを作ろうと試みています。ただ、いつも途中で飽きて挫折してしまいます。前回挑戦したのは7年前で、Python 2.7で作ろうとしていました。
ログのパースはGleamと相性が良いプロジェクトです。簡単な部分(例えばプレーンテキストログのパース)もあるので、Gleamという言語に慣れながら簡単なところから手をつけ、徐々に難しいログ形式やWebフロントエンドの追加へと進めていけるからです。
また、関数型言語はパースのようなタスクに特に向いていると聞いたことがありますが、その理由がこれまでよく分かっていなかったので、学ぶ良い機会でもあります。
プログラミング言語に関する私のバックグラウンド
私は20年間プログラマーをしていますが、言語設計の専門家というわけではありません。Gleamについて直感的でないと感じたことや扱いにくいと感じたことを共有しますが、これは言語批判ではなく、あくまで率直な感想です。
関数型プログラミングのために設計された言語で仕事をしたことはありません。一番近いのはJavaScriptでしょう。最もよく知っている言語はGoとPythonです。
コマンドライン引数はどうやってパースする?
最初にやりたかったのは、アプリを次のように呼び出せるよう、コマンドライン引数をパースする方法を調べることでした。
./log-parser ~/logs/aim/plaintextしかし、コマンドライン引数を読み取るためのGleam標準ライブラリのモジュールはありません。glintを見つけたのですが、たった1つの引数を読み取るだけにしては非常に複雑に感じました。そこで、argvというもっとシンプルなサードパーティライブラリがあることに気づきました。
次のようにコマンドライン引数をパースできます。
pub fn main() {
case argv.load().arguments {
[path] -> io.println("command-line arg is " <> path)
_ -> io.println("Usage: gleam run <directory_path>")
}
}$ gleam run ~/whatever
Compiled in 0.01s
Running log_parser.main
command-line arg is /home/mike/whateverなるほど、簡単ですね!
gleam buildは何をするのか?
gleam runでプログラムを実行できるようになりましたが、go buildやzig buildのように実行ファイルをコンパイルできるのか気になりました。
$ gleam build
Compiled in 0.01sうーん、何をコンパイルしたのでしょう?どこにもバイナリが見当たりません。
ドキュメントのgleam buildには単に“Build the project”と書かれているだけで、何をビルドするのか、成果物をどこに保存するのかは説明されていません。
buildディレクトリはありますが、明らかな実行ファイルは生成されません。
$ rm -rf build && gleam build
Downloading packages
Downloaded 5 packages in 0.00s
Compiling argv
Compiling gleam_stdlib
Compiling filepath
Compiling gleeunit
Compiling simplifile
Compiling log_parser
Compiled in 0.52s
$ ls -1 build/
dev
gleam-dev-erlang.lock
gleam-dev-javascript.lock
gleam-lsp-erlang.lock
gleam-lsp-javascript.lock
gleam-prod-erlang.lock
gleam-prod-javascript.lock
packagesいろいろ探ってみたところ、実行ファイルはbuild/dev/erlang/log_parser/ebin/の下にあるようです。
$ ls -1 build/dev/erlang/log_parser/ebin/
log_parser.app
log_parser.beam
log_parser@@main.beam
log_parser_test.beam
plaintext_logs.beam
plaintext_logs_test.beamこれらはBEAMバイトコードのようで、直接実行することはできません。BEAM VMを手動で起動して何とか実行できるのかもしれませんが、あまり魅力的には思えません。
というわけで、アプリの実行にはgleam runを使い続けることにしますが、gleam buildが何を生成し、それを開発者がどう扱えるのか、もっと分かりやすく説明してほしいところです。
最もシンプルなパーサーを実装してみる
手始めに、プレーンテキストログを基本的にパースする関数を書くことにしました。
そこで、期待する動作をテストとして書きました。
pub fn parse_simple_plaintext_log_test() {
"
Session Start (DumbAIMScreenName:Jane): Mon Sep 12 18:44:17 2005
[18:44] Jane: hi
[18:55] Me: hey whats up
Session Close (Jane): Mon Sep 12 18:56:02 2005
"
|> string.trim
|> plaintext_logs.parse
|> should.equal(["hi", "hey whats up"])
}最終的には、名前やタイムスタンプ、セッション情報を含む会話のすべてのメタデータをパースしたいと思っています。しかし最初のステップとして、関数にやらせることは、AIMのチャットログを文字列として読み込み、チャットメッセージを個別の文字列のリストとして出力することだけです。
つまり、実際の関数は次のようになります。
pub fn parse(contents: String) -> List(String) {
// Note: todo is a Gleam language keyword to indicate unfinished code.
todo
}とりあえずコンパイルが通るように、ダミーの実装を追加します。
pub fn parse(contents: String) -> List(String) {
["fake", "data"]
}そして次のようにテストできます。
$ gleam test
Compiling log_parser
warning: Unused variable
┌─ /home/mike/code/gleam-log-parser2/src/plaintext_logs.gleam:1:14
│
1 │ pub fn parse(contents: String) -> List(String) {
│ ^^^^^^^^^^^^^^^^ This variable is never used
Hint: You can ignore it with an underscore: `_contents`.
Compiled in 0.22s
Running log_parser_test.main
F
Failures:
1) plaintext_logs_test.parse_simple_plaintext_log_test: module 'plaintext_logs_test'
Values were not equal
expected: ["hi", "hey whats up"]
got: ["fake", "data"]
output:
Finished in 0.008 seconds
1 tests, 1 failuresよし、想定通りです。ハードコードされたダミーの結果がテストと一致しないため、テストは失敗しています。
頭を関数型言語に切り替える
さて、いよいよ本当にパースを実装する番です。実装すべき関数はこれです。
pub fn parse(contents: String) -> List(String) {
todo
}この時点で、ちょっと固まってしまいました。Gleamでは、他の言語で使い慣れた多くの道具が使えないことに気づいたのです。
if文がない- ループがない
returnキーワードがない- リストのインデックスアクセスがない
- 例:
Listのn番目の要素にアクセスできない
- 例:
一体どうすればいいのでしょう?文字列をトークンに分割して、それから何かする?
最終的に、シンプルな実装としては文字列を行ごとに分割すればいいのだと気づいたので、次のようにしたいと思います。
pub fn parse(contents: String) -> List(String) {
string.split(contents, on: "\n")
}再度テストすると、次のようになります。
$ gleam test
Compiling log_parser
Compiled in 0.21s
Running log_parser_test.main
F
Failures:
1) plaintext_logs_test.parse_simple_plaintext_log_test: module 'plaintext_logs_test'
Values were not equal
expected: ["hi", "hey whats up"]
got: ["Session Start (DumbAIMScreenName:Jane): Mon Sep 12 18:44:17 2005", "[18:44] Jane: hi", "[18:55] Me: hey whats up", "Session Close (Jane): Mon Sep 12 18:56:02 2005"]
output:
Finished in 0.009 seconds
1 tests, 1 failuresよし、少し近づきました。
ループのない言語でリストをどう反復する?
ログを行のリストに変換しましたが、そこでまた行き詰まりました。
forループに慣れすぎていて、頭の中では「要素を反復するためにforループをどう書けばいいんだ?」と考え続けてしまいました。
list.mapを呼ぶ必要があることに気づきました。リストの各要素に作用する関数を定義する必要があるのです。
import gleam/list
import gleam/string
fn parse_line(line: String) -> String {
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> line
}
}
pub fn parse(contents: String) -> List(String) {
string.split(contents, on: "\n")
|> list.map(parse_line)
}どの言語でもパターンマッチングを使うのは初めてですが、なかなか面白いです。ただ、まだあまりにも馴染みがなく、いつ使うべきか判断するのが難しいと感じます。
パターンマッチングの部分を少し拡大すると、ここです。
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> line
}これはline変数を評価し、波括弧内の後続のパターンのいずれかにマッチさせます。行が"Session Start"で始まる場合(<>は前の文字列がプレフィックスであることを意味します)、Gleamは->の後のコードを実行します。この場合は単なる空文字列です。"Session Close"についても同様です。
行が"Session Start"や"Session Close"のパターンにマッチしなかった場合、Gleamはcaseの最後の行を実行します。これは任意の文字列にマッチするものです。その場合、同じ文字列として評価されます。つまり"hi"は単に"hi"として評価されるということです。
ここで、returnキーワードがないことがいかに奇妙に感じられるかに気づきました。私が知る他のすべての言語では、returnキーワードで関数の値を明示的に返す必要がありますが、Gleamでは戻り値は関数内でGleamが最後に実行した行の値になるだけなのです。
テストを実行すると、次のようになります。
$ gleam test
Compiling log_parser
Compiled in 0.22s
Running log_parser_test.main
F
Failures:
1) plaintext_logs_test.parse_simple_plaintext_log_test: module 'plaintext_logs_test'
Values were not equal
expected: ["hi", "hey whats up"]
got: ["", "[18:44] Jane: hi", "[18:55] Me: hey whats up", ""]
output:
Finished in 0.009 seconds
1 tests, 1 failuresこれも想定通りで、目標に少し近づきました。
"Session Start"と"Session End"の行を空文字列に変換し、リストの真ん中の2つの要素がAIMメッセージを含む行になりました。
残りの作業は次の通りです。
- ログ行から時刻と送信者の部分を取り除く。
- 空文字列を除外する。
1行からAIMメッセージを抜き出す
この時点で、次のような文字列があります。
[18:55] Me: hey whats upそして、送信者名の後の部分だけを次のように抽出する必要があります。
hey whats up直感的には文字列分割関数を使って:文字で分割したくなります。List(String)を返すstring.splitがあることが分かりました。
string.split_onceという関数もあり、:(コロンの後の空白に注意)で一度だけ分割できるので、これも使えそうです。
問題は、split_onceがResult(#(String, String), Nil)を返すことで、私にはより恐ろしく感じられる型だということです。Resultでラップされた2要素タプルで、失敗時にエラーを返しうることを意味します。splitは失敗しないのにsplit_onceは失敗しうるというのが混乱するので、シンプルにするためにsplitを使うことにします。
fn parse_line(line: String) -> String {
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> {
echo string.split(line, on: ": ")
todo
}
}
}テストを実行すると、次のようになります。
$ gleam test
warning: Todo found
┌─ /home/mike/code/gleam-log-parser/src/plaintext_logs.gleam:10:7
│
10 │ todo
│ ^^^^ This code is incomplete
This code will crash if it is run. Be sure to finish it before
running your program.
Hint: I think its type is `String`.
Compiled in 0.01s
Running log_parser_test.main
src/plaintext_logs.gleam:9
["[18:44] Jane", "hi"]よし、やりたいことができています。"hi"の部分をうまく分離できたので、あとはそれを返せばいいだけです。
リストの最後の要素にどうアクセスする?
ここまで来ると、勝利が近いと感じます。行を文字列のリストに変換し、欲しい文字列がリストの最後の要素であることは分かっているのですが、どうやって取得すればいいのでしょう?
他の多くの言語なら単にline_parts[1]と書くところですが、Gleamのリストにはインデックスによるアクセス手段がありません。
gleam/listモジュールを見ると、list.lastという関数があるので、それを試してみます。
fn parse_line(line: String) -> String {
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> {
string.split(line, on: ": ")
|> list.last
|> echo
|> todo
}
}
}それを実行すると、次のようになります。
$ gleam test
Compiling log_parser
warning: Todo found
┌─ /home/mike/code/gleam-log-parser/src/plaintext_logs.gleam:12:11
│
12 │ |> todo
│ ^^^^ This code is incomplete
This code will crash if it is run. Be sure to finish it before
running your program.
Hint: I think its type is `fn(Result(String, Nil)) -> String`.
Compiled in 0.24s
Running log_parser_test.main
src/plaintext_logs.gleam:11
Ok("hi")少し近づきました!リストの最後の要素を抽出して"hi"を見つけましたが、今度はResult型でラップされています。
result.unwrapでアンラップできます。
fn parse_line(line: String) -> String {
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> {
string.split(line, on: ": ")
|> list.last
|> result.unwrap("")
}
}
}gleam testを再実行すると、次のようになります。
$ gleam test
Compiling log_parser
Compiled in 0.22s
Running log_parser_test.main
F
Failures:
1) plaintext_logs_test.parse_simple_plaintext_log_test: module 'plaintext_logs_test'
Values were not equal
expected: ["hi", "hey whats up"]
got: ["", "hi", "hey whats up", ""]
output:
Finished in 0.008 seconds
1 tests, 1 failures素晴らしい!やりたかったことができました。メッセージ行をメッセージ内容だけに絞り込めました。
空文字列を除外する
残る作業はリストから空文字列を除外することだけで、これはlist.filterで十分シンプルにできます。
pub fn parse(contents: String) -> List(String) {
string.split(contents, on: "\n")
|> list.map(parse_line)
|> list.filter(fn(s) { !string.is_empty(s) })
}そしてテストを再実行します。
$ gleam test
Compiling log_parser
Compiled in 0.22s
Running log_parser_test.main
.
Finished in 0.007 seconds
1 tests, 0 failuresやった!テストが通りました!
文字列分割をきれいにする
テストが通ったので、理論上は最初の目標を達成しました。
ここで勝利宣言をして終わりにすることもできます。あるいは、リファクタリングすることも!
リファクタリングしましょう。
文字列分割のロジックはGleamらしい書き方に感じられず、少し恥ずかしく思います。Resultのアンラップなしでできないでしょうか?
読み返してみると、この目新しいパターンマッチングというもので解決できることに気づきました。文字列は2要素のリストに分割されることが分かっているので、2要素リスト用のパターンを作ればよいのです。
fn parse_line(line: String) -> String {
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> {
case string.split(line, on: ": ") {
[_, message] -> message
_ -> ""
}
}
}
}これはresult.lastを呼ぶよりも少しエレガントに感じます。
さらにきれいにできないでしょうか?型がややこしくて避けていたstring.split_onceですが、1回だけ分割することを想定するならおそらくより良い選択肢なので、どのように見えるか試してみます。
fn parse_line(line: String) -> String {
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> {
echo string.split_once(line, on: ": ")
todo
}
}
}データを確認するために、再度テストを実行します。
$ gleam test
[...]
src/plaintext_logs.gleam:9
Ok(#("[18:44] Jane", "hi"))なるほど、思ったほど怖くありません。最初の直感ではエラーをアンラップしてタプルの最後の要素にアクセスしたくなりますが(タプルなら実際簡単で、リストほど難しくありません)、この時点ではおそらくパターンマッチング的な方法があるはずだと分かっています。そして実際にあります。
fn parse_line(line: String) -> String {
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> {
case string.split_once(line, on: ": ") {
Ok(#(_, message)) -> message
_ -> ""
}
}
}
}Ok(#(_, message))というパターンは、split_onceからの成功した結果にマッチします。これはOkでラップされた2要素タプルのStringです。もう一方のcaseの選択肢は、空文字列を返すキャッチオールです。
空文字列ハックをなくす
Gleamの魅力の一つは静的型付けなので、特定の行にメッセージがないことを表すのに空文字列を濫用しているのはハックっぽく感じます。空文字列を番兵値として使う代わりに、型システムを使えないでしょうか?
Gleamで、何かが失敗する可能性はあるが、その失敗が必ずしもエラーではないことを示すパターンはResult(<type>, Nil)なので、そのように書き換えてみます。
import gleam/list
import gleam/result
import gleam/string
fn parse_line(line: String) -> Result(String, Nil) {
case line {
"Session Start" <> _ -> Error(Nil)
"Session Close" <> _ -> Error(Nil)
line -> {
case string.split_once(line, on: ": ") {
Ok(#(_, message)) -> Ok(message)
_ -> Error(Nil)
}
}
}
}
pub fn parse(contents: String) -> List(String) {
string.split(contents, on: "\n")
|> list.map(parse_line)
|> result.values
}素晴らしい!メッセージのない行が空文字列ではなくError(Nil)を返すよう、より明示的になったのが気に入っています。また、result.valuesは以前のlist.filter(fn(s) { !string.is_empty(s) })よりも、空行のフィルタリングが簡潔になります。
全体的な感想
Gleamを数時間触ってみて、楽しんでいます。程よくコンフォートゾーンから押し出してくれて、プログラミングについての新しい考え方を学んでいる感覚がありつつ、圧倒されすぎて何も学べないというほどではありません。
Gleamで見つけた最大の欠点は、比較的小さなチームによる若い言語だということです。誕生からちょうど6年ですが、創設者が1年前まで一人で開発していたようです。現在は数人のコアメンテナがいますが、誰かがフルタイムでGleamに取り組んでいるのか分からず、エコシステムはやや限定的です。今後、HTMLやXML形式の他のログをパースすることを考えていますが、Gleam用のHTMLやXMLパーサーは存在するものの、あまり広く使われているようには見えず、どれくらい上手く動くか分かりません。
気に入った点:パイプライン
Gleamのパイプライン構文が大好きで大好きでたまりません。テストで|>文字を使って使っているのが見られるでしょう。
"..."
|> string.trim
|> plaintext_logs.parse
|> should.equal(["hi", "hey whats up"])パイプラインを使わない同等のテストは次のようになります。
pub fn parse_simple_plaintext_log_test() {
let input = "..."
let trimmed = string.trim(input)
let parsed = plaintext_logs.parse(trimmed)
should.equal(parsed, ["hi", "hey whats up"])
}それに比べると、びしょ濡れのゴミのように見えます。
一度パイプラインを見てしまうと、それがあまりにも当然のものに感じられ、使っている他のすべてのプログラミング言語で明らかに欠けているように思えます。
bashでのパイプラインは楽しんできましたが、他のプログラミング言語がそれを採用してこなかったことがいかに奇妙か、これまで考えたこともありませんでした。
良い点:例を中心としたドキュメント
Gleamのドキュメントは少し簡潔ですが、例が豊富な点が気に入っています。
私は例を読んで学ぶのが一番合っているので、Gleam標準ライブラリの多くが各API関数のシンプルな使い方を示す例とともに文書化されていることをありがたく思います。
良い点:未使用シンボルの警告が組み込み
Gleamコンパイラが未使用の関数、変数、importについてネイティブに警告してくれるのが気に入っています。そして、それらがエラーではなく警告であることも良い点です。
Goでは、デバッグ中に一時的に何かをコメントアウトすると、コンパイラがそのくだらないimportを修正するまで頑なに何もしてくれず、デバッグが終わったらまた元に戻さなければならないことにイライラさせられます。
良い点:todoキーワード
私のお気に入りのくだらないプログラミングジョークの一つは、約15年前の最初のプログラミングの仕事で起こりました。数人のC++開発者が参加するグループメールのスレッドで、友人がC++開発に関する裏技を共有してくれました。
彼いわく、難解なC++のコンパイルエラーにうんざりしたときは、ソースコードに特別な一行を追加するだけで、無効なC++コードでも正常にコンパイルされるようになるとのことでした。
#pragma always_compileネタバレすると、これは実在するC++プリプロセッサディレクティブではありません。
しかし、開発の最中で、コンパイラが守ろうとしているバグなど気にしないときに、言語にこういうものがあればいいのにと思うことが時々ありました。
Gleamのtodoは、ほとんど#pragma always_compileのようなものです。コードが無効であっても、Gleamコンパイラは「わかった、いいよ。とにかく実行するよ」と言ってくれるのです。
これはparse_lineを実装している途中で見ることができます。
fn parse_line(line: String) -> String {
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> {
echo string.split(line, on: ": ")
todo
}
}
}todoを取り除くと、Gleamはコードの実行自体を拒否します。
$ gleam test
Compiling log_parser
error: Type mismatch
┌─ /home/mike/code/gleam-log-parser/src/plaintext_logs.gleam:8:5
│
8 │ ╭ line -> {
9 │ │ echo string.split(line, on: ": ")
10 │ │ }
│ ╰─────^
This case clause was found to return a different type than the previous
one, but all case clauses must return the same type.
Expected type:
String
Found type:
List(String)そう、正しくない型を返しているのだから、コンパイラが協力してくれるはずがないでしょう?
しかしtodoを追加すれば、実装を終えていなくても関数を実行できるので、コードが何をしているのか理解するのに役立ちます。
$ gleam test
warning: Todo found
┌─ /home/mike/code/gleam-log-parser/src/plaintext_logs.gleam:10:7
│
10 │ todo
│ ^^^^ This code is incomplete
This code will crash if it is run. Be sure to finish it before
running your program.
Hint: I think its type is `String`.
Compiling log_parser
Compiled in 0.21s
Running log_parser_test.main
src/plaintext_logs.gleam:9
["[18:44] Jane", "hi"]
F
[...]
Finished in 0.007 seconds
1 tests, 1 failures良い点:パターンマッチング
パターンマッチングはエレガントで簡潔だと感じますが、Gleamの中で最も順応するのが難しい部分でもあります。私が知る他の言語で慣れ親しんだ手続き型のプログラミングスタイルとはあまりにも違って感じられます。
欠点は、パターンマッチングが適切な道具であることを認識するのが難しく、読むのも難しいと感じることです。ただ、これは単に経験不足だと思いますし、もっと練習すればパターンマッチングで考えられるようになると思います。
気に入らない点:エラー処理
Gleamのエラー処理はかなりぎこちないと感じます。特に、エラーがきれいに整ったパイプラインの美しさを台無しにしてしまうからです。
例えば、次のような文字列処理パイプラインがあったとします。
string.split(line, on: "-")
|> list.last
|> result.unwrap("") // Ugly!
|> string.uppercaseそのresult.unwrapの行がとても醜く、場違いに感じられます。構文が次のようになればいいのにと思います。
string.split(line, on: ": ")
|> try list.last
|> string.uppercase
|> Okここでtryは関数にエラーを返させるもので、Zigにおけるものと似ています。
気に入らない点:コア言語が小さい
これが長期的な設計上の選択なのか、それともインディー開発の言語だから今は小さいだけなのか分かりませんが、Gleamについて最初に際立って感じたのは、組み込み機能がどれほど少ないかということです。
例えば、List型の要素を反復するための組み込み機能はなく、その型自体も反復するための関数を公開していないため、標準ライブラリの gleam/listモジュールを使わなければなりません。
同様に、失敗しうる関数はResult型を返しますが、Resultを扱うための組み込み関数はなく、関数が成功したかどうかを確認するにはgleam/resultモジュールを使わなければなりません。
私にとって、その機能は言語の中核をなすものに感じられるので、標準ライブラリではなく言語自体の一部であってほしいと思います。
気に入らない点:標準ライブラリが限定的
言語自体が小さく感じられることに加えて、標準ライブラリもかなり限定的に感じられます。
現在、Gleam標準ライブラリには19個のモジュールしかありません。特に目立って欠けているのはファイルシステムを扱うモジュールで、事実上の標準はサードパーティのsimplifileモジュールのようです。
比較のために、PythonやGoの標準ライブラリはそれぞれ約250のモジュールを持っています。とはいえ、公平を期すなら、それらの言語はGleamの約1000倍のリソースを持っているのですが。
ソースコード
このプロジェクトのソースコードはCodebergで公開されています。
コミット291e6dがこのブログ記事に対応するバージョンです。
この記事への有益なフィードバックをくれたIsaac Harris-Holt氏に感謝します。
記事をランダムに読む
コメント
ログインしてコメントする