前日16日はpiyopiyo.exのオーガナイザー @nako_sleep_9h の「『elixirと見習い錬金術師』Discordサーバー紹介」でした!
おはこんばんにちは、OkazaKirin.beam の pojiro です。
2022年の12月も残りわずかとなりました。今年は私にとってあっという間の一年でした。
開発業務はすべて Elixir に関わるものを(あるいは関連させるように)させていただくことができ(その中で C++ も C# も書きました)、非常に感謝しております。
その開発の日々の中でうまく使えることができた気がする behaviour を今日は紹介したいと思います。
behaviour とは
特定のビヘイビアを実装すると宣言したモジュールは、関連するすべての関数を実装しなければならない。
...
ビヘイビアは Java のインタフェースに似ている。
refs. 「プログラミング Elixir」 p.257
「インタフェース」を知っていれば『なるほど』となるかも知れませんが、わからない場合でも大丈夫です。
実際のコードを見てみます。
まずは、 behaviour の定義です。
defmodule BehaviourSample do
@callback say(hello :: String.t()) :: :ok
end
「これだけ?」そう、これだけです。 behaviour とはそれを使用するモジュールが実装すべき関数を定める文法です。なので、関数にその実装はありません。
上述は、この behaviour を使用するモジュールは 「入力(引数) に String.t() をとり、出力(戻り値) を :ok とする say 関数を実装してね」ということを定めています。
※例として1つしか定義していませんが、複数定義することも可能です。
なぜこんなものを用意するのかは、おいおい見ていきます。
まずは、この behaviour の使い方を見てみます。
defmodule ImplSample1 do
@behaviour BehaviourSample # この行で behaviour の使用を宣言しています。
def say(hello) do
IO.puts(hello) # IO.puts の戻り値は :ok
end
end
BehaviourSample を使用する宣言をして、関数を定義せずにコンパイルすると warning で実装漏れが指摘されます。
NOTE: 型の違いはチェックされません。Elixir Language Server を使用するとチェックできるようになります。
mix compile --force
Compiling 2 files (.ex)
warning: function say/1 required by behaviour BehaviourSample is not implemented (in module ImplSample1)
lib/ImplSample1.ex:1: ImplSample1 (module)
behaviour のまとめ
- behaviour は、関数の入出力を定義する
- behaviour を使用するモジュールは、 behaviour に定められた関数の実装を求められる
- コンパイル や LS で実装をチェックできる
さて、ここまでで使い方は分かりましたが、使用することによるメリットが「?」だと思います。次はそれを見ていきます。
どのように使えるの?
チーム開発で使う
A さんと pojiro が同じチームで開発を進めます。
このとき、 A さんは pojiro の実装担当モジュールを利用するモジュールの実装を担当しています。
A さんは pojiro の実装を待たないと開発をすすめることはできないでしょうか?
このような場合、pojiro が実装するモジュールのインタフェース(境界)を予め決め A さんに伝えることで、並行に開発が可能になります。
Elixir ではそれを behaviour によって行うことができます。
pojiro が behaviour を定義しその実装を約束することで A さんはそれを信じて自身の実装を進めればよくなります。
これは口約束ではなく、 behaviour モジュールとしてコード上で約束できます。
defmodule BehaviourSample do
# pojiro が say 関数を実装するモジュールを作成することを A さんに約束する。
@callback say(hello :: String.t()) :: :ok
# A さんの実装では BehaviourSample.module().say("hello") で関数を呼び出すようにしてもらう。
def module(), do: ImplSample1 # A さんは pojiroが実装するモジュール名すら考える必要がありません。
end
コード上で明示的に約束するため、pojiro が実装上の理由によりそのインタフェース変更の必要性を感じたら behaviour も変更する必要があります。なので A さんにとって暗黙の変更というものが起きなくなります。コード上に定義した behaviour によるインタフェース(境界)が責任の境界になります。
また、behaviour を作成することにより関数設計の再検討を開発者同士で行うことができるようになります。
- 「:ok 以外返らないってことは、異常時は 例外起こすの?」と A さんは聞くことができるかもしれません。
- pojiro はその質問を受け(あるいは behaviour の定義をしながら)設計漏れに気づけるかもしれません
- 異常時は例外を起こすから、そのことを関数名でわかるようにを
say
からsay!
にしようとか - 副作用としてファイル生成をするからその旨を分かるように
@doc
に書くようにしようとか、そのファイルを使用する A さんには、事前にその正常時のファイルフォーマットを伝えたりや異常時のハンドリングを相談しようとか
- 異常時は例外を起こすから、そのことを関数名でわかるようにを
インタフェース(境界)を明示的にコード上に定義することで、これらのようなメリットがあります。
FAQ
A さんは pojiro の実装を待つまで単体テストを書くことができないの?
「Mox」を使うことで可能です。Mox は jose が所属する Dashbit 製のライブラリで、behaviour にテスト用の実装を与えることが可能なライブラリです。
Mox を使うことで A さん自身が実装したロジックの単体テストを pojiro の実装を待たずに作成することができます。
※使い方の詳細は割愛します。
pojiro の仕事が遅くても A さんの仕事は遅延しません、嬉しいですね♪
『インタフェース(境界)を先に定めることで並行開発を可能にする』については、「オブジェクト指向の本質」が私の拙い文章を補ってくれると思います。
※オブジェクト志向という言葉にとらわれず、文章を読んでみて下さい。
モジュール設計で使う
前節でも関数設計で behaviour を使うことに触れましたが、モジュール設計で使うことについてもう少し考えてみます。
ライブラリ開発やチームでのモジュール開発では、開発したモジュールのインタフェース(境界)設計が重要と思います。
- 破壊的変更はモジュールの利用ユーザに対し、追従の負担をかけます
- 「こう書いたら、こう動くんじゃね?」と利用方法が想像しやすくできるのであればその方が良いはずです
なので、実際の実装前に行う試験的な実装、必要機能の洗い出し等が終わったら、モジュールの利用者サイドに立った視点で入出力のインタフェース(境界)を設計するのは良いアイデアかもしれません。その取り組みの中で behaviour を定義すれば、最終的にはその behaviour を使用するモジュールの実装を行うだけで良くなります。
behaviour, インタフェース(境界), の定義では
- 外から変更可能(DI可能)な自由度をどこまで残すか(何を露出し、何を隠蔽するか
- 利用時の疑似コードをいくつも書いてみて、その使いやすさや関数名の妥当性
等を検討することで、実際のロジックを実装する前にインタフェースを洗練させることができるような気がしています。
(著者自身はライブラリ開発等の経験はこれからなので上記は想像です
実験的なことを試し発散していた事項が収斂しまとまっていく中でコードができあがっていくこともあるので、ケースバイケースではあると思います。
実装切替の自由度を与える
behaviour を使うことでできることに「実装の切替」があります。
先に紹介した Mox も実装の切替を利用してテスト時に実装を切り替えます。
defmodule BehaviourSample do
@callback say(hello :: String.t()) :: :ok
# :impl_module が put_env されていればそれを、そうでなければ実実装の ImplSample1 を使用させる
def module(), do: Application.get_env(:sample, :impl_module, ImplSample1)
end
また、behaviour を定義しユーザに公開しておき、その実装をユーザにまかせるという使い方もあります。
例えば、 Nx は Nx.Backend(behaviour) を切り替えることでその数値計算の内部実装を切り替えられるようになっているそうです。
refs. Nxバックエンド勉強会#2 Nxバックエンドソースコードリーディングの初手で @zacky1972 に学びました。
また、 @mnishiguchi が 「Elixir mix.exsのconfig_providersオプションで設定ファイルの読み込みをカスタマイズ」 で紹介している Config.Provider も behaviour で内部実装をユーザに任せています。
他の言語の Behaviour ?
Elixir でいうところの behaviour は他言語でも同様に用いることができる考え方で、例えば以下が他言語の behaviour だと大雑把には考えられます。
- Java: Interface
- Rust: Trait
- C#: Interface
- Go: Interface
- PHP: Interface
- C++: 抽象クラス(を使うとできる
インタフェース(境界)を定義するという手法は Elixir によらず一般に使用される手法です。
なので、考え方の本質を理解しそのメリットを言語に依らず享受したいですね♪
C言語でもインタフェース?
インタフェース(境界)定義の考え方は C 言語でも使えるの?
はい、使えます!
私は Elixir を通してインタフェースの考え方を理解するまで、その点に気づいていませんでしたが C 言語でも十分に可能です。
C 言語でインタフェースを定義するのはヘッダファイルです。
入出力の型を定義し、利用ユーザに公開している点がまさにインタフェースですね。
実装の切り替えはヘッダファイルに定義した関数を実装したソースファイルをコンパイルしたオブジェクトのリンクを切り替えることで可能です。たとえば、Linux の汎用入出力ピンを制御できる Elixir の 「Circuits.GPIO」 というライブラリはこの方法をつかって、ホストマシンや CI でテストが可能なように実装されています。
手前味噌ですが、以前調べてまとめた資料として 「Circuits.GPIOはいかにテストされているか」 があります。
まとめ
behavirour を使ってできることを紹介しました。
プログラミングをする上で「インタフェース(境界)」という考え方はとても有効(有用)なことを本記事でお伝えすることでができたら嬉しいです♪
明日18日は @MzRyuKa の担当日です!お楽しみに!