F# で関数型っぽく GUI を書く
TL;DR
Preview
F# + Avalonia UI でクロスプラットフォーム対応のタスクトレイ常駐アプリを 関数型プログラミング (以降、FP) で書くにあたっての所感を綴ったものです。あくまで FP の雰囲気を感じていただくことに主眼をおいているため、ライブラリの扱い方や環境構築などには触れません。詳細につきましてはリポジトリをご参照ください🙏
はじめに
GUI アプリケーションの開発はその性質上オブジェクト指向 (以降、OOP) と非常に相性が良く、 Java や C# などがその最たるものでした。しかし時代の流れとともに OOP の諸問題[1]が浮き彫りとなり始め、昨今では Kotlin や TypeScript (≒Electron) などの機運が高まっており、 C++ の後継ともいうべきマルチパラダイム言語である Rust も大きな注目を集めているように感じます。 Tauri を皮切りに優秀なフレームワークや従来の OOP の懸念事項への解答となるトレイトと所有権システムという言語設計により、クラスを用いずとも柔軟な表現が可能になりました。とはいえど、もはや .NET もクロスプラットフォーム対応なのは既知でしょうし、 C# = Windows 専用という固定観念は過去の産物となり、長い歴史の中で築き上げられた .NET 周辺のエコシステムが大きな資産であることもまた事実です。……という長い前置きをした上で、何かが忘れられている気がします。そう、 F# です。関数を第一級オブジェクトとする FP ですが、れっきとしたマルチパラダイム言語でもあります。しかも C# ひいてはその他各種ライブラリやフレームワークの恩恵を存分に享受できます。此度はクロスプラットフォーム対応のタスクトレイ常駐アプリを Avalonia UI + FP のアプローチで実装し、 GUI 開発の側面から F# ならびに FP の魅力をお伝えできればと思います✨
Avalonia UI とは?
Avalonia UI は 主に C# を対象とした .NET のクロスプラットフォーム対応の GUI フレームワークです。 UI 要素は XAML に、ロジック部分はコードビハインドに記述します。また React ライクに宣言的な UI 構築が可能な Avalonia FuncUI という F# 用のラッパーもありますが、今回は FP の解説に重きを置くためにもあえて純粋な Avalonia UI を F# から呼び出してみます[2]。これには WPF ライクなコントロールが揃っており、その中から TrayIcon (アイコン表示)、 NativeMenu (コンテキストメニュー)、 NativeMenuItem (メニューの各項目) の3つを使用します。 Avalonia UI については下記の記事が超詳しいのでなにとぞ🔰
FP の概要
ある1つの関数は、1つの責任 (目的) のみを持つべきであることが FP における根底であり、これを単一責任の原則 (SRP) といいます。プログラム中の各処理を細切れにし、それらを組み合わせていくイメージです。とりあえず ChatGPT に FP の良し悪しを要点別に比較してもらいました。
要点 | メリット | デメリット |
---|---|---|
コードの可読性 | 副作用が排除され、予測可能なコードとなるため、理解しやすい。 | 高度な抽象化により、複雑なコードが増え、初心者には理解が難しいことがある。 |
開発速度 | 再利用可能な小さな関数を組み合わせることで、機能追加が簡単になる。 | 初期設計が煩雑になり、全体のアーキテクチャを整えるのに時間がかかる。 |
性能 | 並列処理がしやすいため、高速化や効率化がしやすい。 | 過剰な抽象化や不必要なコピーを使うことでパフォーマンスが低下することがある。 |
保守性 | ユニットテストが簡単になり、コード変更が容易。 | 外部ライブラリや他のシステムとの統合が複雑になりやすい。 |
うーん、わかるようなわからないような、微妙な感じです。堅苦しい文章はこれくらいにして次に進みましょう💪
OOP っぽく書いてみる
論よりコード、まずは愚直に OOP っぽく書いてみます。試しにTrayIcon
と NativeMenuItem
のクラスを作り、必要に応じてメンバ関数も実装するとこんな感じになります。
type TrayIconControl (fileName: string, hintText: string) =
let trayIcon = new TrayIcon ()
do
trayIcon.Icon <- WindowIcon (fileName)
trayIcon.ToolTipText <- hintText
member self.Icon with get () = trayIcon.Icon and set (value) = trayIcon.Icon <- value
member self.ToolTipText with get () = trayIcon.ToolTipText and set (value) = trayIcon.ToolTipText <- value
member self.Menu with get () = trayIcon.Menu and set (value) = trayIcon.Menu <- value
member self.Show () = trayIcon.IsVisible <- true
type NativeMenuItemControl (header: string) =
let nativeMenuItem = new NativeMenuItem ()
do
nativeMenuItem.Header <- header
member self.Header with get () = nativeMenuItem.Header and set (value) = nativeMenuItem.Header <- value
member self.ToggleType with get () = nativeMenuItem.ToggleType and set (value) = nativeMenuItem.ToggleType <- value
member self.Click = nativeMenuItem.Click
呼び出し側は必要な情報を定義して TrayIcon
のインスタンスを作成し、そのあとに Show()
でアイコンを表示させるという非常に単純なコードです。
// アイテム
let header = "remilia"
let remilia = new NativeMenuItemControl (header)
remilia.ToggleType <- NativeMenuItemToggleType.CheckBox
// クリックイベント
let remiliaOnClick (sender: obj) (e: EventArgs) = Console.WriteLine ("れみぃ")
remilia.Click.AddHandler (remiliaOnClick)
// コンテキストメニュー
let nativeMenu = new NativeMenu ()
nativeMenu.Add (remilia)
// アイコン
let iconPath = "assets/tray.ico"
let hintText = "OOP styles"
let trayIcon = new TrayIconControl (iconPath, hintText)
trayIcon.Menu <- nativeMenu
// 表示
trayIcon.Show ()
このようにクラスを使えば、さながら C# のように見えます。もちろんこれでも問題なく動作はするのですが、アイテムの個数が多くなると煩雑になったり、副作用が多くエラーハンドリングが大変だったりと、わざわざ C# と XAML を使わずに F# で OOP っぽく書くメリットは薄いです💦
FP っぽく書いてみる
肝心要の本題です。TrayIcon
から取り掛かります。先述の単一責任の原則を意識しながら進めてみましょう。 F# には関連するコードをグループ化してコードの見通しを良好にするモジュールという機能があります。ここでは TrayIcon
というモジュール内に下記のような create
関数を実装します。通常、タスクトレイアプリは何らかのアイコンを持つため、アイコンのオブジェクトを引数に取ることとします。 TrayIcon
のアイコンを設定する WindowIcon
型 は Bitmap
, Stream
, string
のいずれかを指定可能なため、受け取ったオブジェクトの型に合わせてキャストします。適宜エラーハンドリングをしつつ、最後に TrayIcon
を返せば完成です。
// 受け取ったオブジェクトをアイコンとする TrayIcon を返す
let create (icon: obj) =
if icon = null then failwith "Icon cannot be null."
let trayIcon = new TrayIcon ()
match icon with
| :? Bitmap as bitmap -> trayIcon.Icon <- WindowIcon bitmap
| :? Stream as stream ->
if not (stream.CanRead || stream.Length > 0L) then
failwith "Stream is invalid or empty."
trayIcon.Icon <- WindowIcon stream
| :? string as fileName ->
if not (Path.Exists (fileName)) then
failwith $"'{fileName}' was not found."
trayIcon.Icon <- WindowIcon fileName
| _ -> failwith "Unsupported icon type."
trayIcon
何らかのオブジェクトを受け取ると TrayIcon
またはエラーを返すcreate
関数は、明確に1つの責任を持っています。同じ要領で OOP におけるクラスのメンバ関数のように各処理を実装していきます。TrayIcon
モジュール内に続けて書いていきます。
// ヒントテキストと TrayIcon を受け取り、テキストが更新された TrayIcon を返す
let updateText hint (trayIcon: TrayIcon) =
trayIcon.ToolTipText <- hint
trayIcon
// メニューと TrayIcon を受け取り、メニューが設定された TrayIcon を返す
let setMenu menu (trayIcon: TrayIcon) =
trayIcon.Menu <- menu
trayIcon
// TrayIcon を受け取り、表示状態に更新された TrayIcon を返す
let showIcon (trayIcon: TrayIcon) =
trayIcon.IsVisible <- true
trayIcon
ここで注目すべき点は TrayIcon
を受け取り TrayIcon
を返しているということです。原則として FP は何かを受け取ると何かを返す (=必ず戻り値がある) ことが自然です。また F# は強力な型推論を持ち合わせており、型を明示しなくてもある程度はコンパイラがよしなに解釈してくれます。updateText
関数の引数の hint
は (hint: string)
とせずともstring
型を受け入れるべきだと判断してくれるということですね。解釈できない場合はしっかりと怒られるので、そのときは適宜教えてあげてください。
さて、コンテキストメニューの実装に移りましょう。新たに NativeMenuItem
モジュールを作って……、といっても基本的な考え方は変わらないためコードのみ記載しておきます。
NativeMenu と NativeMenuItem の実装
type ToggleState =
| None
| CheckBox
| Radio
module NativeMenuItem =
// 受け取ったテキストをヘッダー (表示名) とする NativeMenuItem を返す
let create header =
let item = new NativeMenuItem ()
item.Header <- header
item
// ハンドラーと NativeMenuItem を受け取り、クリックイベントが追加された NativeMenuItem を返す
let onClick (callback: unit -> unit) (item: NativeMenuItem) =
item.Click.Add (fun _ -> callback ())
item
// アイテムの属性と NativeMenuItem を受け取り、属性が設定された NativeMenuItem を返す
let setToggleState state (item: NativeMenuItem) =
match state with
| None -> item.ToggleType <- NativeMenuItemToggleType.None
| CheckBox -> item.ToggleType <- NativeMenuItemToggleType.CheckBox
| Radio -> item.ToggleType <- NativeMenuItemToggleType.Radio
item
module NativeMenu =
// 受け取った NativeMenuItem をメニューとする NativeMenu を返す
let create (items: NativeMenuItem list) =
let menu = new NativeMenu ()
items |> List.iter menu.Add
menu
ようやく呼び出し側です。まずはコンテキストメニューのアイテムを定義していきます。
let remilia =
"remilia"
|> NativeMenuItem.create
|> NativeMenuItem.setToggleState ToggleState.Radio
|> NativeMenuItem.onClick (fun _ -> printfn "れみぃ")
let flandre =
"flandre"
|> NativeMenuItem.create
|> NativeMenuItem.setToggleState ToggleState.Radio
|> NativeMenuItem.onClick (fun _ -> printfn "ふりゃ")
let patchouli =
"patchouli"
|> NativeMenuItem.create
|> NativeMenuItem.setToggleState ToggleState.CheckBox
|> NativeMenuItem.onClick (fun _ -> printfn "ぱちぇ")
let separator = NativeMenuItem.create "-"
let exit =
"exit"
|> NativeMenuItem.create
|> NativeMenuItem.setToggleState ToggleState.None
|> NativeMenuItem.onClick (fun _ -> System.Environment.Exit(0))
let items = [ remilia; flandre; patchouli; separator; exit ]
F# ではシェルスクリプトよろしくパイプライン |>
でつなげて処理を書けます。しかし各アイテムを定義するときに同じような処理があり、少し無駄がありそうです。そこで共通化できる部分を関数として切り出してみます。セパレータ以外のアイテムには '名前' '属性' 'クリックイベント' の3つの共通項があることに着目し、それらをまとめて処理する createItem
関数を作ってあげることで、より柔軟にアイテムを定義できるようになります。
let createItem name state callback =
name
|> NativeMenuItem.create
|> NativeMenuItem.setToggleState state
|> NativeMenuItem.onClick callback
let remilia = createItem "remilia" ToggleState.Radio (fun _ -> printfn "れみぃ")
let flandre = createItem "flandre" ToggleState.Radio (fun _ -> printfn "ふりゃ")
let patchouli = createItem "patchouli" ToggleState.CheckBox (fun _ -> printfn "ぱちぇ")
let separator = NativeMenuItem.create "-"
let exit = createItem "exit" ToggleState.None (fun _ -> System.Environment.Exit(0))
let items = [ remilia; flandre; patchouli; separator; exit ]
いよいよタスクアイコンの作成です。 let iconPath = "assets/tray.ico"
のように let 束縛しても構いませんが、特段使い回すわけでもないため今回は直接パスを渡してパイプでつなげていきます。このあたりは好みが分かれそうな気がします。
let trayIcon =
"assets/tray.ico"
|> TrayIcon.create
|> TrayIcon.setMenu (NativeMenu.create items)
|> TrayIcon.updateText "FP styles"
|> TrayIcon.showIcon
各処理を細分化してモジュール化することで、いい感じにセットアップできました。 Avalonia UI の TrayIcon
や NativeMenuItem
には当然ほかにもさまざまな機能が実装されているため、関数を書いてパイプでつなげることで機能の拡充が可能です🔧
モジュールに関して少しだけ補足を。 F# の関数は'モジュール名.関数名'の形で呼び出せるため、同じプロジェクト内に複数の create
関数が存在していたとしても、モジュールごとに分散させることで共存が可能となります。TrayIcon.create
や NativeMenuItem.create
といった具合に記述することで、何を create
するのかがより明瞭になります。 .NET (というよりかは主として C# ) の命名規則的には createIcon
とすべきなのでしょうが、F# でそのように命名すると TrayIcon.createIcon
のようにやや冗長な印象を受けるため、 ( C# で使われないことが前提のプログラムでは) 私は呼び出すときの扱いやすさを重視した関数名にすることが多いです[3]。
FP っぽくない関数
OOP の感覚が抜けないと陥りがち[4]な、あまりよろしくない書き方の例をいくつか挙げてみます。
// 引数を持たずに関数内で直接値を指定している
let create () =
let trayIcon = new TrayIcon ()
trayIcon.Icon <- WindowIcon "assets/tray.ico"
trayIcon
ハードコーディングは柔軟性に欠けますし、関数内部で値を固定しているため再利用性も低くなってしまいます。
// 1つの関数内で多くの処理をしている
let setup (iconPath: string) (hint: string) =
let trayIcon = new TrayIcon ()
if Path.Exists iconPath then trayIcon.Icon <- WindowIcon iconPath
trayIcon.ToolTipText <- hint
trayIcon.IsVisible <- true
// その他いろいろな処理が続く
trayIcon
関数が肥大化してメンテナンスが難しくなるだけでなく、単一責任の原則に反しており1つの責任に絞られていません。
// 戻り値がない
let setIconVisibility (isVisible: bool) (trayIcon: TrayIcon) =
trayIcon.IsVisible <- isVisible
状態を変更するだけの関数が多くなると副作用が目立ち、テストや予測が困難になってしまいます。
あとがき
状態の変化を前提とした GUI においても、可能な限り個々の関数に切り出すことで副作用を最小限に留める工夫をしてあげれば一応 FP っぽく書けるような気になってきます。当然 GUI や .NET のエコシステムに依存しない場合はさらに厳格なアーキテクチャや設計が大切になってくるわけですが……。そして FP を試みるにあたって、プログラミング言語の枠にとらわれる必要は実はあまりなかったりもします。 JavaScript や Kotlin は map
や filter
などの高階関数が標準でサポートされていますし、 Rust のイミュータブルなデータを重視する設計やパターンマッチなどは関数型のそれに強く影響を受けていることがうかがえます。さりとて Python や C++ で無理くり書こうとして煩雑化させるのは的はずれな感じもするため、 FP はアプローチの一種として捉えるのがよさそうです。純粋関数言語に振り切った Haskell という選択肢もありますが、モナドまわりの学習コストの高さにより人を選んでしまう可能性も……。そこで推したい言語というのが、 FP ベースで柔軟な振る舞いができる F# というお話でした。もし FP に触れる折には、ぜひ F# も選択肢に入れてあげてください。最後までお付き合いいただきありがとうございました💤
Discussion