序
あなたはElixirというプログラミング言語を知っているか。
私は結構前から知っていた。
知ったのは2016年かな。
知ったきっかけは、私はErlangが気になっていて、Erlangについて調べていたときのこと。
で、私は今、新しいSaaSプロダクトを立ち上げようとしていて、そのサーバーにElixirを使っている。
そして、その実験を兼ねてExolyteという簡易なチャットアプリを作った。
概要
ElixirはErlangを下地として作られている言語で、Eralng VMであるBEAM上で動作する。
概要として短く言うと「動的型付けの関数型プログラミング言語」である。
Erlang上に構築されている言語であることもあり、Erlangの機能・エコシステムを利用できる。
これは、JavaとScalaの関係のようなもの。
Elixir処理系はBEAM上で動作するバイトコードを生成するが、バイトコードの生成で止めることも、バイトコードをそのまま実行することもできる。
つまり、Elixir処理系であるelixirはコンパイル言語のようにも、スクリプト言語のようにも使うことができる。
逐次実行速度はRuby, Python, Perlといったスクリプト言語群よりは速いけれど、RustやGoみたいなCPU言語にコンパイルする言語ほどには速くない。
ただし、ElixirはErlangの性質をそのまま引き継いでいるため、並列実行がめちゃくちゃ軽い上に効率的で速い。
そして並列化しなくても並列化できるようなモデルで考える必要があるので、他の言語のコードをベタ移植するのには向いておらず、必然的にElixirで書く以上は並列化を前提として書くため結果的にバカ速い。
他の言語でも並列化前提で最適化すれば別にElixirのアドバンテージではなくなるんだけど、Elixirは並列化を書くのが非常に楽だし、そもそも並列化できるような書き方をする圧があるから、自然に書けば並列化できることになる。
対して、他の一般的な言語だと並列化のためにがんばる必要があってハードルが全く違う。
だから、Elixirで書くという時点で並列化もセットにして差が開くというのは現実的にある話だ。
色々な理由で「Rubyと似ている」と言われることが結構あるんだけど、Rubyistの私の意見としては、あんまり似てない。
Rubyに寄せたような部分もなくはない(例えば無値がnilだとか、do ... endブロックだとか)んだけど、ほとんどは見た目が若干似ているだけで実際の中身は全く違う。
「見た目が似ているけど挙動は違う」みたいなのはかなり強い混乱の原因になるから、正直Rubyistにはそんなに優しくない。
また、Rubyの流儀とは180°違う部分もあったりするので、書き心地はRubyとは似ても似つかない。
ただ、「自然に書きやすくて生産性が高い」という点は似ている。
「Rubyが好きな人が好きになれそうな言語」ではある。
ちなみに、私の知る言語の中で何に似ているかというと、Lispに似ている。
共通点はそこまで多くはないんだけど、Lispが好きな人はとても気に入るだろうなと思う。
各エディタの対応としては、Xedは非対応。VSCodeとZedは拡張機能での対応。
ZedはElixirを開くと言語サポートを導入するか聞かれる。
この記事について
私は別にElixirについて紹介したり解説したりするつもりはない。
だいたい私のElixir力は現状大したことがないので、そんなことをする立場でもない。
どちらかというとElixir初学者視点の話をするんだけど、私は既にかなりプログラミングができるほうだし、扱えるプログラミング言語の数も2桁には届いているので、プログラミング初学者としての話ではない、ということは留意してほしい。
学び方は少し迷う
私は当初「プログラミングElixir 第2版」を読んでいたのだけど、正直あまり良い本ではないと思う。
ずっとREPLであるiexで概念と書き方を説明していくだけで、いつまでも動くコードの塊が出てこない上に、そのコード例に説明されていない記法が含まれていたり、説明が説明されていない概念によってなされたりするため、いつまで経ってもコードが書けるようにならず、ハンズオンで学べない。
理解を重視して座学が長いということに定評のある私でさえそう言うレベルなのだ!
なので、結局私は「プログラミングElixirで出てきた消化できない記述についてMicrosoft Copilotに説明してもらう」という方法をとった。
途中から本を投げ捨て、Copilotに手伝ってもらいながらコードを書くという方法に変えた。
1日2時間くらいずつで進めていった。
初日、私が一番最初に書いたコードはこれだ。
name = "Haruka"
"Haruka" = name
"Haruko" = name
最後に書いたコードは、本にあった課題に対するもの
defmodule Greeter do
def for(name, greeting) do
fn
^name -> "#{greeting} #{name}"
_ -> "I don't know you"
end
end
end
ga = Greeter.for("Alice", "Hello")
IO.puts ga.("Alice")
IO.puts ga.("Bob")
2日目最後には、私が「新しいプログラミング言語を覚えるときに必ず作っているもの」である三山崩しのコードを書いた。
三山崩しは単純でコード量少なく書けるが、その言語の各種記法を知っていないと書けないし、そのコードにプログラミング言語に対する差がかなりはっきりと出る程度には精錬させることが可能である。
そのため、とてもおすすめ。
defmodule MountainsGame do
def change_player(player) do
rem(player + 1, 2)
end
def gameloop(mounts, player) do
IO.puts("Player #{player + 1}'s turn.")
IO.inspect(mounts)
mt_input = IO.gets("Take from [1-3]>")
{mt_index, _} =
mt_input
|> String.trim()
|> Integer.parse()
with true <- mt_index in 1..3,
count when count > 0 <- Enum.at(mounts, (mt_index - 1)) do
takable_max = min(count, 3)
vol_input = IO.gets("How many take[1-#{takable_max}]")
{vol_value, _} =
vol_input
|> String.trim()
|> Integer.parse()
if vol_value in 1..takable_max do
index = mt_index - 1
current = Enum.at(mounts, index)
mounts_updated = List.replace_at(mounts, index, current - vol_value)
mounts_sum = Enum.sum(mounts_updated)
if mounts_sum == 0 do
gameover(player)
else
next_player = change_player(player)
gameloop(mounts_updated, next_player)
end
else
IO.puts "You cannot take #{vol_value}"
gameloop(mounts, player)
end
else
_ ->
IO.puts "You cannot take from #{mt_index}"
gameloop(mounts, player)
end
end
def gamestart(mounts) do
gameloop(mounts, 0)
end
def gameover(player) do
winner = change_player(player)
IO.puts "Game is over"
IO.puts "Player #{winner + 1} won!"
end
end
mt = [Enum.random(1..20), Enum.random(1..20), Enum.random(1..20)]
MountainsGame.gamestart mt
3日目は並列処理。
ファイル書き込みの競合排除を目指して進めた。
最終的にはGenServerで競合排除しつつ、大量にプロセスが作られても大丈夫なように自動シャットダウンを入れた。
ファイル数が増えてmixを使うようになったので、一部分だけ
defmodule FileManager do
def update(path, content) do
case Registry.lookup(FileRegistry, path) do
[] ->
spec = %{
id: FileUpdate,
start: {FileUpdate, :start_link, [path]},
restart: :temporary
}
DynamicSupervisor.start_child(FileSupervisor, spec)
GenServer.call(via(path), {:update, content})
[{pid, _}] ->
GenServer.call(pid, {:update, content})
end
end
defp via(path), do: {:via, Registry, {FileRegistry, path}}
end
defmodule FileUpdate do
use GenServer
def start_link(path) do
GenServer.start_link(__MODULE__, path, name: via(path))
end
defp via(path), do: {:via, Registry, {FileRegistry, path}}
def init(path) do
ref = schedule_shutdown()
{:ok, {path, ref}}
end
def handle_call({:update, content}, _from, {path, old_ref}) do
File.write!(path, content)
{:ok, current} = File.read(path)
IO.puts("Updated #{path}: #{current}")
Process.cancel_timer(old_ref)
new_ref = schedule_shutdown()
{:reply, :ok, {path, new_ref}}
end
def handle_info({:shutdown, ref}, {path, current_ref}) when ref == current_ref do
IO.puts("Shutting down #{path}")
{:stop, :normal, {path, nil}}
end
def handle_info({:shutdown, _ref}, state) do
{:noreply, state}
end
defp schedule_shutdown do
ref = make_ref()
Process.send_after(self(), {:shutdown, ref}, 10_000)
ref
end
end
Elixirを学ぶ価値
- プログラミング言語を学ぶのが就職のためであればやめておいたほうがいい
- 求人は無ではないが、大変少ない
- 習得しやすい言語ではある
- ただし学習環境はあんまり整ってない
- 書き心地は非常に良いが、「Ruby並」はちょっと求めすぎになる
- 「ややマイナー言語」のレベル
- Nimよりは100倍マシ
- 稀に採用されたり、採用を検討されたりすることもある
- リファレンスもある程度あって、ナレッジも多少は公開されている
- Erlangのエコシステムを利用できるという前提ではあるけど、ライブラリもそれなりにある
- 第二言語として有力ではある
- かなり珍しい「採用理由に明確なモチベーションが求められる」タイプの言語
- そのモチベーションは「軽量・高速で書きやすい並列処理」が不可欠かどうか
- 他の言語で書き心地がいいコードであればあまりElixirを使う理由がない
- 「強い部分に極度に強い」上にそれが他の言語では苦しい並列化なので、補完として非常に有力
- あと書いていて楽しい
- RubyとElixirは似ていないが、RailsとPhoenixは似ている
- Rails慣れしている人なら構成や慣習はパッと見である程度分かると思う
- ただElixirが明示を強く意識しているので、そこがRailsと大きく違う部分になる
- 企業のプロダクトの言語としてはややリスキー
- 生産性はそれなりに高いので、素早く展開することは可能
- 「小さく始めて大きく育てる」適性は高く、発展させたり分割したりということは楽
- しかし人材市場にElixir書ける人は少なすぎる
- しかも一般的なプログラミング言語の概念をそのまま持ち込めるわけではないため、採用してから覚えさせるには向き不向きが出過ぎる
- 普段使いには悪くはないが最適でもない
- Rubyみたいにさくっといい感じに書けるわけではないので、適所ではない
- 得手不得手が割とはっきりしているのでメイン言語としてはやや微妙
- ウェブアプリには基本的に向いている
- まず前提として、Phoenixがあるためウェブアプリを作る下地がある
- 並列処理が得意なのはウェブアプリ適性に直結している
- ただし、ライブラリ都合でデータベースの選択肢が限られ、SDKがないといったこともある
- Phoenixありきというわけではなく、普通にPlugで書いてもいい
- LiveViewなどPhoenixの魅力的な機能もあるけど、特にAPIサーバーであれば嬉しい部分のほとんどはPlug1に由来している
- Plug+Cowboyみたいな選択肢もある
- ウェブアプリはEcto2への依存度が高い
- データベースの選択肢が狭いという理由もある
- PhoenixはEcto前提の作りではあるが、
--no-ectoでEctoなしのプロジェクトも作れる - シリアライゼーション込みの純KVSが欲しいならCubDBが便利。ただし交換性はない
- LiveViewは簡単に作るのにはとても良い
- WebSocketで接続し、ステート更新ができる
- サーバー側で
socket.assignsを更新することで自動的にクライアント側で差分更新する - 結構面倒なライブアプリが簡単に作れる
- 加えてTailwind CSS + DaisyUIが標準でついてくる
- ただし、WebSocketを使うことを含めて、単純なページでもかなり重いアプリになる
- そして基本的にLiveViewのやり方に従うのが基本で、
- GUIアプリにも向いている
- こちらも並列処理に向いていることがGUIアプリの構築に直結した適性になっている
- さらにScenicというGUIライブラリがある
- ちょっと触った感じではかなりイケてるので、多分かなり向いている
- ただし、Windows上ではあまり動作しないのでクロスプラットフォームGUIアプリ構築のための選択肢としては微妙
書き心地
めっちゃ良いと感じたもの
並列処理で悩まされるファイルへの書き込みがGenServerを使って簡単に実現できる。
GenServer経由で書き込んでいるものはname単位でsingletonで、必要に応じて分割可能。
Supervisorが必要になるのでちょっと面倒だけど、そこらへんがなければシンプル。
defmodule FileUpdate do
use GenServer
def start_link(init \\ 0) do
GenServer.start_link(__MODULE__, init, name: __MODULE__)
end
def init(state), do: {:ok, state}
def update(content) do
GenServer.call(__MODULE__, {:update, content})
end
def handle_call({:update, content}, _from, _) do
path = "elixir.txt"
File.write!(path, content)
{:ok, current} = File.read("003.txt")
IO.puts current
{:reply, :ok, current}
end
end
# FileUpdate.start_link()
# FileUpdate.update("Alice")
Plugはreqとresを一体化させたようなconnを使う。
そして、このconnはそのセッション向けに値を保持させる方法が、ちゃんと用意されている。
# user_idをセット
conn
|> assign(:user_id, user_id)
# user_idを参照
conn.assigns.user_id
「こういうことでよく使うよね」みたいな機能が割と用意されていたりする。
例えばEnum.random/1。
user_color = Enum.random(@name_colors)
ただし、個人的に気に入らないところもある。
まず、基本的にmixを使うことに偏っていること。
プロジェクトディレクトリ以下にファイルを数多く配置する形式になっており、Linux環境で日常的に使うソフトウェアを書くのにあまり適していない。
シングルバイナリにコンパイルできたりするわけでもないので、取り回しが悪い。
BEAMプロセスが高速・軽量・安全な並列処理になっているんだけど、それゆえかOSプロセスとの相性があまりよろしくなく、全体的に短命なプロセスに適さない。
結果、適している条件がかなり狭い。
あと、名称がわかりにくかったり独特だったりするのも難点。
並列実行の単位が「プロセス」なのも非常にわかりづらく、これはspawnしたりGenServerを使ったりするBEAM上のものであり、OSプロセスとは別。
Phoenixのファイル配置はRailsに似たもので、無駄に細かく分けることになる。
ちょっとしたことのために必要になるファイル数が多く、儀式的な内容もどうしても多くなる。
標準でforamtterがつくが、かなりお節介。
VSCodeで書く場合はformatterの利用は任意な感じだが、Zedは保存するたびに自動的に適用して鬱陶しい。
mixプロジェクトの場合はformatterの設定をすれば良いが……
また、Railsの問題でもあるFile.exists?のような関数がある。
sがつくかどうかは一定のルールというわけでもなく、感覚的な法則なので分かりづらい。
ただし、Railsほど非英語ネイティブに対してしんどい仕様ではないし、Phoenixの規則もRailsに比べれば穏やか。
私としては、気に入らないところもあるけれど、基本的には気が合う言語って感じだ。