- 株式会社スマートラウンドのシニアエンジニア
- 主要技術スタック: Kotlin + Ktor
- Kotlin Fest 2024のたまごスポンサー🥚
- Server-Side Kotlin Meetupの運営企業
- 関数型言語/関数型プログラミングが大好き
書籍『データ指向プログラミング』は、プログラミング言語Clojureにおいて典型的なプログラミングスタイルの根幹にある考え方を他言語でも応用できる形で抽出し紹介する試みであるということができます。
Clojureを実務や趣味で継続的に利用するとともに比較的最近Kotlinに再入門した立場から、この本で提示されている「データ指向プログラミング」というプログラミングスタイルを概説しながらKotlinらしい実践の可能性について考察します。
-
データ指向プログラミングとは
-
Kotlinへの適用可能性を探る
-
まとめ
書籍『データ指向プログラミング』まえがき
……特別なのは機能ではなく原則だということで意見が一致した。 Clojureの基本原則を抜き出そうとしていた私たちは、実際には、それらの原則を他のプログラミング言語に応用できることに気づいた。本書の構想が沸いてきたのはそのときだった。私がClojureでとても気に入っている点を世界中の開発者コミュニティに伝えたかった。
-
Clojureに魅了された著者がClojure言語/コミュニティで一般的なプログラミングスタイルのエッセンスを他言語でも応用できる形で抽出しようとした本
-
古典的なOOPに対するアンチテーゼといえる
-
他の言語や設計思想と必ずしも馴染まず批判されることもある(批判も理解できる)
-
-
Clojurianである🐬の視点から捉え直し、Kotlinでの現実的な応用の可能性を考えたい💪
- 動的型付き(非オブジェクト指向)関数型言語
- モダンに再設計されたLisp系言語
- 2007年に登場したJVM言語
- Scala: 2004年〜
- Kotlin: 2011年〜
- 作者Rich HickeyのプレゼンSimple Made Easyは他のコミュニティでも多少知られているかも(?)
- Clojure言語の設計にも色濃く反映されている、simplicityの重要性について語っている
-
古典的なOOPのアプローチに対する問題意識:
- 必要以上の複雑さを生みがち
- いろいろな要素が絡み合っている(complecting)
- 硬直的で柔軟性に欠けることがある
- フレームワークに頼らざるを得なかったり
- 必要以上の複雑さを生みがち
-
→ もっとシンプル(simple)に情報を扱うアプローチがあるはず
(🐬< そうして生まれたのがClojure言語でもある)
-
原則 #1: コードをデータから切り離す
-
原則 #2: データを汎用的なデータ構造で表す
-
原則 #3: データはイミュータブルである
-
原則 #4: データスキーマをデータ表現から切り離す
(🐬< Clojureのプログラミングスタイルそのもの)
- ロックフリー(lock-free)な楽観的並行性制御
- e.g. Clojureのatom
- 純粋な関数、不変なデータとの相性が良い
- 状態変化のタイムトラベル、リプレイ
- 状態が不変の汎用データ構造で表現されていれば極めて簡単
- 永続データ構造
- 不変(immutable)かつ永続的(persistent)であれば効率も犠牲になりにくい
- cf. 『純粋関数型データ構造』
-
原則 #1: コードをデータから切り離す
-
原則 #2: データを汎用的なデータ構造で表す
-
原則 #3: データはイミュータブルである
-
原則 #4: データスキーマをデータ表現から切り離す
(🐬< Clojureのプログラミングスタイルそのもの)
関数の定義(データはマップ { }
で表す)
(ns dop-examples) ; 名前空間(namespace)の定義
(defn make-author [first-name last-name num-of-books]
{:first-name first-name
:last-name last-name
:num-of-books num-of-books})
(defn full-name [{:keys [first-name last-name]}]
(str first-name " " last-name))
(defn prolific? [{:keys [num-of-books]}]
(or (some-> num-of-books (> 100))
false))
利用例
;; FYI: プロンプトの `dop-examples` は現在の名前空間(モジュール)
;; そこでdef/requireされているものは非修飾名で参照できる
dop-examples> (let [data (make-author "Isaac" "Asimov" 500)]
(full-name data))
"Isaac Asimov"
「レコード」(≒ data class)を定義することもできる
(defrecord Author
[first-name
last-name
num-of-books])
(defn make-author' [first-name last-name num-of-books]
(->Author first-name last-name num-of-books))
マップとレコードはインターフェースが共通しているため、関数 full-name
はそのまま使える
dop-examples> (let [data (make-author' "Isaac" "Asimov" 500)]
(full-name data))
"Isaac Asimov"
- 主な利点:
- コードをさまざまなコンテキストで再利用できる
- コードを単体でテストできる
- システムがあまり複雑にならない傾向にある
- 主なコスト:
- どのコードがどのデータにアクセスできるのかを制御できない
- パッケージ化がない
- システムを構成するエンティティの数が増える
データと関数の定義
data class Author(
val firstName: String,
val lastName: String,
val numOfBooks: Int?,
)
object NameCalculation {
fun fullName(data: Author): String =
"${data.firstName} ${data.lastName}"
}
object AuthorRating {
fun isProlific(data: Author): Boolean =
data.numOfBooks?.let { it > 100 } ?: false
}
クラス/オブジェクトは「モジュール」でもある
利用例
> val data = Author("Isaac", "Asimov", 500)
> NameCalculation.fullName(data)
res2: kotlin.String = Isaac Asimov
OOPLらしい(?)ドット記法が必要であれば
> fun Author.fullName(): String =
NameCalculation.fullName(this)
> data.fullName()
res4: kotlin.String = Isaac Asimov
構造的型(structural type)や拡張可能レコード(extensible record)の代わりに
interface Namable {
val firstName: String
val lastName: String
}
data class Author2(
override val firstName: String,
override val lastName: String,
val numOfBooks: Int?,
) : Namable
object NameCalculation2 {
fun fullName(data: Namable): String =
"${data.firstName} ${data.lastName}"
}
インターフェースを定義することで特定の具象型(Author2
)に縛られなくすることはできる
;; マップ(リテラルで作成)
dop-examples> {:first-name "Isaac"
:last-name "Asimov"
:num-of-books 500}
{:first-name "Isaac", :last-name "Asimov", :num-of-books 500}
;; レコード(コンストラクタ関数で作成)
dop-examples> (->Author "Isaac" "Asimov" 500)
{:first-name "Isaac", :last-name "Asimov", :num-of-books 500}
;; どちらも Associative (連想データ)インターフェースを実装している
dop-examples> (associative? {:first-name "Isaac"
:last-name "Asimov"
:num-of-books 500})
true
dop-examples> (associative? (->Author "Isaac" "Asimov" 500))
true
連想データに対するあらゆるオペレータ(関数/マクロ/特殊形式)が利用できる
- 主な利点:
- 特定のユースケースに限定されないジェネリック関数を利用できる
- 柔軟なデータモデル
- 主なコスト:
- パフォーマンスが少し低下する
- データスキーマがない
- コンパイル時にデータの有効性が確認されない
- 静的に型付けされる言語では、明示的な型変換(キャスト)が必要になることがある
-
構造的型や拡張可能レコードのサポートがなく、後述のデータスキーマを記述するのも一般的ではない
-
→ data class主体で具体的な型としてデータを定義するのが妥当そう(適宜インターフェース化しうる)
> data class Author(
val firstName: String,
val lastName: String,
val numOfBooks: Int?,
)
> Author("Isaac", "Asimov", 500)
res6: Line_0.Author = Author(firstName=Isaac, lastName=Asimov
, numOfBooks=500)
マップほかネイティブのデータ構造は不変
;; 関数 assoc は連想データのエントリーをupsertする
dop-examples> (assoc {:first-name "Isaac"
:last-name "Asimov"
:num-of-books 500}
:num-of-books 100)
{:first-name "Isaac", :last-name "Asimov", :num-of-books 100}
dop-examples> (let [data {:first-name "Isaac"
:last-name "Asimov"
:num-of-books 500}
data' (assoc data
:num-of-books 100)]
(identical? data data'))
false ; 参照が異なる別のデータ(永続データなので内部的には共有がある)
安全であり(実用上)十分に効率的でもある
cf. Clojure Performance Guarantees
- 主な利点:
- すべての関数から自信を持ってデータにアクセスできる
- コードの振る舞いが予測可能である
- 等価のチェックが高速である
- 並行処理の安全性が自動的に確保される
- 主なコスト:
- パフォーマンスが低下する
- 永続的なデータ構造のためのライブラリが必要である
再代入不可なvalプロパティとほぼ不変(単にread-onlyの場合あり)なデータ構造を利用することはできる
> val data1 = Author("Isaac", "Asimov", 500)
> val data2 = data.copy(numOfBooks = 100)
> data1 === data2
res9: kotlin.Boolean = false // 参照が異なる別のデータ
cf. ミュータビリティとイミュータビリティの狭間: 関数型言語使いから見たKotlinコレクション
契約プログラミングライブラリclojure.specが標準で含まれており、広く使われている
(ns dop-examples
(:require
[clojure.spec.alpha :as s] ; clojure.specの導入
[clojure.string :as str]))
(defn make-author [first-name last-name num-of-books]
{:first-name first-name
:last-name last-name
:num-of-books num-of-books})
(defn full-name [{:keys [first-name last-name]}]
(str first-name " " last-name))
(defn prolific? [{:keys [num-of-books]}]
(or (some-> num-of-books (> 100))
false))
データの仕様
(s/def ::name
(s/and string?
(complement str/blank?) ; 空文字列/空白のみでない
#(<= (count %) 100))) ; 長さが100以下
(s/def ::first-name ::name)
(s/def ::last-name ::name)
(s/def ::num-of-books
(s/nilable ; nilになりうる
(s/and nat-int? ; (0を含む)自然数
#(<= % 10000)))) ; 10000以下
(s/def ::author
(s/keys :req-un [::first-name ; 列挙したキーを必ず含む
::last-name]
:opt-un [::num-of-books])) ; 列挙したキーを任意で含む
述語(predicate)により値レベルの制約まで記述できる
関数の仕様
(s/fdef make-author
:args (s/cat :first-name ::first-name ; 第1引数
:last-name ::last-name ; 第2引数
:num-of-books ::num-of-books) ; 第3引数
:ret ::author) ; 戻り値
(s/fdef full-name
:args (s/cat :data (s/keys :req-un [::first-name
::last-name]))
:ret string?)
(s/fdef prolific?
:args (s/cat :data (s/keys :req-un [::num-of-books]))
:ret boolean?)
データの仕様で関数の入出力仕様を記述できる
データに対する検証
;; 必須の :last-name キーが欠けたマップの場合
dop-examples> (s/explain ::author {:first-name "Isaac"
:num-of-books 500})
{:first-name "Isaac", :num-of-books 500} - failed:
(contains? % :last-name) spec: :dop-examples/author
nil
;; :num-of-books の値が負の数の場合
dop-examples> (s/explain ::author {:first-name "Isaac"
:last-name "Asimov"
:num-of-books -1})
-1 - failed: nat-int? in: [:num-of-books] at: [:num-of-books
:clojure.spec.alpha/pred] spec: :dop-examples/num-of-books
-1 - failed: nil? in: [:num-of-books] at: [:num-of-books
:clojure.spec.alpha/nil] spec: :dop-examples/num-of-books
nil
関数(引数)に対する検証
;; 関数の引数に対するチェックを有効化
dop-examples> (clojure.spec.test.alpha/instrument)
[dop-examples/make-author dop-examples/full-name
dop-examples/prolific?]
;; 第1引数(first-name)が空の場合
dop-examples> (make-author "" "Asimov" 500)
Execution error - invalid arguments to
dop-examples/make-author at (REPL:103).
"" - failed: (complement blank?) at: [:first-name] spec:
:dop-examples/name
;; キーにtypoがある(必須の :first-name キーがない)場合
dop-examples> (full-name {:fist-name "Isaac"
:last-name "Asimov"})
Execution error - invalid arguments to dop-examples/full-name
at (REPL:106).
{:fist-name "Isaac", :last-name "Asimov"} - failed:
(contains? % :first-name) at: [:data]
- 主な利点:
- 検証すべきデータを自由に選択できる
- オプションフィールドを利用できる
- 高度なデータ検証条件を利用できる
- データモデルを自動的に可視化できる
- 主なコスト:
- データとスキーマの結び付きが弱い
- パフォーマンスが少し低下する
依存型(dependent type)などのサポートはないので、型で表現しがたい仕様はアサーションで検証
data class Author3(
val firstName: String,
val lastName: String,
val numOfBooks: Int?,
) {
init {
require(firstName.isNotBlank()
&& firstName.length <= 100)
require(lastName.isNotBlank()
&& lastName.length <= 100)
require(numOfBooks?.let { it in 0..10000 } ?: true)
}
}
プリミティブなデータをリッチにすることもできる
-
「データ指向プログラミング」はClojure言語/コミュニティに由来するプログラミングスタイル
- 🐬< その力が最も効果的に発揮されるのはClojureを使うときかも(Clojureもいいぞ!)
-
Kotlinのような静的型付きオブジェクト指向言語でも(こそ?)参考になる示唆を含んでいる
- 🐬< そもそも近年の新興言語ではクラスベースのOOPを押し出していない印象がある。クラスという枠組みでのモデル化に囚われる必要はない
- 本発表のサンプルコード
- Clojure: dop_examples.clj
- Kotlin: dop_examples.kt
- Scala: dop_examples.scala
- 『データ指向プログラミング』
- データ指向プログラミングの真実をお話しします