Gleamを使ってみた最初の感想
今年学ぶ新しいプログラミング言語を探していたのですが、Gleamが一番楽しそうに見えました。Elixirに似ていて静的型付けをサポートする言語です。
言語ツアーを読んでみたところ内容は理解できましたが、実際に何かを作ってみないと、言語の良し悪しは判断できません。
Gleamを数時間触ってみた際のメモを共有します。Gleamを学んでいる方や、言語を開発しているチームの参考になれば幸いです。
私のプロジェクト:古いAIMログを解析する
1999年から2007年ごろまでAOL Instant Messengerを使っていました。その期間のほとんどで会話をログに残すAIMクライアントを使っていたのですが、フォーマットはまちまちでした。多くのログはXMLやHTML形式で、読み返すのがとても面倒です。
最もシンプルなのがプレーンテキストのログで、次のように見えます。
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 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直感的には文字列分割関数を使って:文字で分割したいところです。string.splitがあり、List(String)を返すことが分かります。
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でラップされたStringの2要素タプルです。もう一方のcaseは、空文字列を返すキャッチオールです。
空文字列ハックをやめる
Gleamの魅力の1つは静的型付けですが、特定の行にメッセージがないことを表すのに空文字列を乱用しているのはハックっぽく感じます。空文字列を番兵値として使う代わりに、型システムを使えないでしょうか。
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)を返すよう明示できたのが気に入っています。また、空行の除外も、以前のlist.filter(fn(s) { !string.is_empty(s) })よりresult.valuesの方が簡潔です。
全体的な感想
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コンパイラが未使用の関数や変数、インポートについて標準で警告してくれるのが気に入っています。そして、それがエラーではなく警告である点も良いです。
Goでは、デバッグ中に一時的に何かをコメントアウトすると、コンパイラが頑固に何もしてくれず、くだらないインポートを修正するまで先に進めないことにイライラします。デバッグが終わればまた元に戻さなければならないのに、です。
良い点:todoキーワード
お気に入りのしょうもないプログラミングジョークの1つは、15年ほど前の最初の仕事でのことです。数人のC++開発者が参加するグループメールで、友人がC++開発のとっておきの裏技を共有してくれました。
彼は、難解なC++のコンパイルエラーにうんざりしたときは、ソースコードに特別な1行を追加すれば、無効な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氏に感謝します。
記事をランダムに読む