Skip to content

Instantly share code, notes, and snippets.

@lagenorhynque
Last active October 23, 2024 07:55
Show Gist options
  • Save lagenorhynque/e8f56bcfb9a60ca2a94c464445ec3772 to your computer and use it in GitHub Desktop.
Save lagenorhynque/e8f56bcfb9a60ca2a94c464445ec3772 to your computer and use it in GitHub Desktop.
Kotlin Meets Data-Oriented Programming: Kotlinで実践する「データ指向プログラミング」
(ns dop-examples
(:require
[clojure.spec.alpha :as s]
[clojure.string :as str]))
;;; 原則 #1: コードをデータから切り離す
(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))
(comment
(let [data (make-author "Isaac" "Asimov" 500)]
(full-name data))
)
;;; 原則 #2: データを汎用的なデータ構造で表す
;; マップの代わりにレコードを定義してもよい(Clojureではインターフェース(Associative)レベルで互換性がある)
(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))
(comment
(let [data (make-author' "Isaac" "Asimov" 500)]
(full-name data))
)
;;; 原則 #3: データはイミュータブルである
;; Clojureではデータがデフォルトでイミュータブル
;;; 原則 #4: データスキーマをデータ表現から切り離す
(s/def ::name
(s/and string?
(complement str/blank?)
#(<= (count %) 100)))
(s/def ::first-name ::name)
(s/def ::last-name ::name)
(s/def ::num-of-books
(s/nilable
(s/and nat-int?
#(<= % 10000))))
(s/def ::author
(s/keys :req-un [::first-name
::last-name]
:opt-un [::num-of-books]))
(s/fdef make-author
:args (s/cat :first-name ::first-name
:last-name ::last-name
:num-of-books ::num-of-books)
: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?)
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
}
object AuthorExtensions {
fun Author.fullName(): String = NameCalculation.fullName(this)
fun Author.isProlific(): Boolean = AuthorRating.isProlific(this)
}
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}"
}
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)
}
}
case class Author(
firstName: String,
lastName: String,
numOfBooks: Option[Int]
)
object NameCalculation:
def fullName(data: Author): String = s"${data.firstName} ${data.lastName}"
object AuthorRating:
def isProlific(data: Author): Boolean = data.numOfBooks.exists(_ > 100)
extension (a: Author)
def fullName: String = NameCalculation.fullName(a)
def isProlific: Boolean = AuthorRating.isProlific(a)
type Namable = {
val firstName: String
val lastName: String
}
import reflect.Selectable.reflectiveSelectable
object NameCalculation2:
def fullName(data: Namable): String = s"${data.firstName} ${data.lastName}"
case class Author2(
firstName: String,
lastName: String,
numOfBooks: Option[Int]
):
require(!firstName.isBlank && firstName.length <= 100)
require(!lastName.isBlank && lastName.length <= 100)
require(numOfBooks.forall((0 to 10000).contains(_)))

Kotlin Meets Data-Oriented Programming

Kotlinで実践する「データ指向プログラミング」

#serverside_kotlin_meetup


x icon


Kotlin Fest 2024への私🐬のCfP

Kotlin Fest 2024 CfP


書籍『データ指向プログラミング』は、プログラミング言語Clojureにおいて典型的なプログラミングスタイルの根幹にある考え方を他言語でも応用できる形で抽出し紹介する試みであるということができます。

Clojureを実務や趣味で継続的に利用するとともに比較的最近Kotlinに再入門した立場から、この本で提示されている「データ指向プログラミング」というプログラミングスタイルを概説しながらKotlinらしい実践の可能性について考察します。


  1. データ指向プログラミングとは

  2. Kotlinへの適用可能性を探る

  3. まとめ


1. データ指向プログラミングとは


……特別なのは機能ではなく原則だということで意見が一致した。 Clojureの基本原則を抜き出そうとしていた私たちは、実際には、それらの原則を他のプログラミング言語に応用できることに気づいた。本書の構想が沸いてきたのはそのときだった。私がClojureでとても気に入っている点を世界中の開発者コミュニティに伝えたかった。


  • Clojureに魅了された著者がClojure言語/コミュニティで一般的なプログラミングスタイルのエッセンスを他言語でも応用できる形で抽出しようとした本

    • 古典的なOOPに対するアンチテーゼといえる

    • 他の言語や設計思想と必ずしも馴染まず批判されることもある(批判も理解できる)

  • Clojurianである🐬の視点から捉え直し、Kotlinでの現実的な応用の可能性を考えたい💪


(ちなみに) Clojureとは


データ指向プログラミングの背景

  • 古典的なOOPのアプローチに対する問題意識:

    • 必要以上の複雑さを生みがち
      • いろいろな要素が絡み合っている(complecting)
    • 硬直的で柔軟性に欠けることがある
      • フレームワークに頼らざるを得なかったり
  • → もっとシンプル(simple)に情報を扱うアプローチがあるはず

(🐬< そうして生まれたのがClojure言語でもある)


データ指向プログラミングの原則

  • 原則 #1: コードをデータから切り離す

  • 原則 #2: データを汎用的なデータ構造で表す

  • 原則 #3: データはイミュータブルである

  • 原則 #4: データスキーマをデータ表現から切り離す

(🐬< Clojureのプログラミングスタイルそのもの)


データ指向プログラミングと親和性の高い技術

  • ロックフリー(lock-free)な楽観的並行性制御
    • e.g. Clojureのatom
    • 純粋な関数、不変なデータとの相性が良い
  • 状態変化のタイムトラベル、リプレイ
    • 状態が不変の汎用データ構造で表現されていれば極めて簡単
  • 永続データ構造

2. Kotlinへの適用可能性を探る


データ指向プログラミングの原則(再掲)

  • 原則 #1: コードをデータから切り離す

  • 原則 #2: データを汎用的なデータ構造で表す

  • 原則 #3: データはイミュータブルである

  • 原則 #4: データスキーマをデータ表現から切り離す

(🐬< Clojureのプログラミングスタイルそのもの)


原則 #1: コードをデータから切り離す


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"

(書籍より) 利点とコスト

  • 主な利点:
    • コードをさまざまなコンテキストで再利用できる
    • コードを単体でテストできる
    • システムがあまり複雑にならない傾向にある
  • 主なコスト:
    • どのコードがどのデータにアクセスできるのかを制御できない
    • パッケージ化がない
    • システムを構成するエンティティの数が増える

Kotlinの場合

データと関数の定義

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)に縛られなくすることはできる


原則 #2: データを汎用的なデータ構造で表す


Clojureの場合

;; マップ(リテラルで作成)
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

連想データに対するあらゆるオペレータ(関数/マクロ/特殊形式)が利用できる


(書籍より) 利点とコスト

  • 主な利点:
    • 特定のユースケースに限定されないジェネリック関数を利用できる
    • 柔軟なデータモデル
  • 主なコスト:
    • パフォーマンスが少し低下する
    • データスキーマがない
    • コンパイル時にデータの有効性が確認されない
    • 静的に型付けされる言語では、明示的な型変換(キャスト)が必要になることがある

Kotlinの場合

  • 構造的型や拡張可能レコードのサポートがなく、後述のデータスキーマを記述するのも一般的ではない

  • → 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)

原則 #3: データはイミュータブルである


Clojureの場合

マップほかネイティブのデータ構造は不変

;; 関数 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


(書籍より) 利点とコスト

  • 主な利点:
    • すべての関数から自信を持ってデータにアクセスできる
    • コードの振る舞いが予測可能である
    • 等価のチェックが高速である
    • 並行処理の安全性が自動的に確保される
  • 主なコスト:
    • パフォーマンスが低下する
    • 永続的なデータ構造のためのライブラリが必要である

Kotlinの場合

再代入不可なvalプロパティとほぼ不変(単にread-onlyの場合あり)なデータ構造を利用することはできる

> val data1 = Author("Isaac", "Asimov", 500)

> val data2 = data.copy(numOfBooks = 100)

> data1 === data2
res9: kotlin.Boolean = false  // 参照が異なる別のデータ

cf. ミュータビリティとイミュータビリティの狭間: 関数型言語使いから見たKotlinコレクション


原則 #4: データスキーマをデータ表現から切り離す


Clojureの場合

契約プログラミングライブラリ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]

(書籍より) 利点とコスト

  • 主な利点:
    • 検証すべきデータを自由に選択できる
    • オプションフィールドを利用できる
    • 高度なデータ検証条件を利用できる
    • データモデルを自動的に可視化できる
  • 主なコスト:
    • データとスキーマの結び付きが弱い
    • パフォーマンスが少し低下する

Kotlinの場合

依存型(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)
    }
}

プリミティブなデータをリッチにすることもできる


3. まとめ


  • 「データ指向プログラミング」はClojure言語/コミュニティに由来するプログラミングスタイル

    • 🐬< その力が最も効果的に発揮されるのはClojureを使うときかも(Clojureもいいぞ!)
  • Kotlinのような静的型付きオブジェクト指向言語でも(こそ?)参考になる示唆を含んでいる

    • 🐬< そもそも近年の新興言語ではクラスベースのOOPを押し出していない印象がある。クラスという枠組みでのモデル化に囚われる必要はない

Further Reading

データ指向プログラミング


Clojure

#!/usr/bin/env bash
# npm install -g reveal-md
reveal-md kotlin-meets-data-oriented-programming.md --theme night --highlight-theme monokai-sublime -w $@
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment