Skip to content

Instantly share code, notes, and snippets.

@lagenorhynque
Last active December 8, 2025 10:52
Show Gist options
  • Select an option

  • Save lagenorhynque/2d0edfbf7c8922ea2ff531957db279c9 to your computer and use it in GitHub Desktop.

Select an option

Save lagenorhynque/2d0edfbf7c8922ea2ff531957db279c9 to your computer and use it in GitHub Desktop.
Functional Calisthenics in Kotlin: Kotlinで「関数型エクササイズ」を実践しよう

Functional Calisthenics

in Kotlin

Kotlinで「関数型エクササイズ」を実践しよう


  • 株式会社スマートラウンドのシニアエンジニア
    • スタートアップと投資家のやり取りを効率化するデータ管理プラットフォームを開発している
      • 技術スタック: Kotlin/Ktor & TypeScript/Vue.js
    • Server-Side Kotlin Meetupの運営にも協力
  • Clojure, Haskellなどの関数型言語の愛好者
  • Java, Scala, Clojure, KotlinとJVM言語での開発経験
    • Kotlinの実務利用は1年半ほど🐣

  1. オブジェクト指向エクササイズ

  2. 関数型プログラミングというスタイル

  3. 🐬の「関数型エクササイズ」

  4. Kotlinでの実践例


1. オブジェクト指向エクササイズ


『ThoughtWorksアンソロジー』

thoughtworks anthology

O'Reilly Japanの書籍詳細ページより

オブジェクト指向エクササイズ

  • 『ThoughtWorksアンソロジー』第5章のタイトル

    • 原題: Object Calisthenics (≒ オブジェクト体操)
  • 手続き型プログラミングからオブジェクト指向プログラミングのコード設計の発想に親しむための訓練方法として(少々大胆で今や古めかしい?)ルール集

  • i.e. パラダイムシフトに順応してもらうきっかけ

    • → 関数型プログラミングについても同じようなアプローチを考えたい🐬

オブジェクト指向エクササイズ 9つのルール

  • ルール1: 1つのメソッドにつきインデントは1段階までにすること

    • 主な狙い: 責務の分離
  • ルール2: else句を使用しないこと

    • 主な狙い: 可読性
  • ルール3: すべてのプリミティブ型と文字列型をラップすること

    • 主な狙い: カプセル化、型安全性

  • ルール4: 1行につきドットは1つまでにすること

    • 主な狙い: 責務の分離、カプセル化
  • ルール5: 名前を省略しないこと

    • 主な狙い: 可読性、責務の分離
  • ルール6: すべてのエンティティを小さくすること

    • 主な狙い: 責務の分離、カプセル化

  • ルール7: 1つのクラスにつきインスタンス変数は2つまでにすること

    • 主な狙い: 責務の分離、カプセル化
  • ルール8: ファーストクラスコレクションを使用すること

    • 主な狙い: カプセル化
  • ルール9: Getter、Setter、プロパティを使用しないこと

    • 主な狙い: カプセル化

ルール1: 1つのメソッドにつきインデントは1段階までにすること

リファクタリング前:

class Board {
    fun board(): String =
        buildString {
            for (row in data) {
                for (square in row)
                    append(square)
                appendLine()
            }
        }
}

リファクタリング後:

class Board {
    fun board(): String =
        buildString {
            collectRows(this)
        }
    fun collectRows(sb: StringBuilder) {  // 拡張関数にする案も
        for (row in data)
            collectRow(sb, row)
    }
    fun collectRow(sb: StringBuilder, row: List<Square>) {
        for (square in row)
            sb.append(square)
        sb.appendLine()
    }
}

ルール2: else句を使用しないこと

リファクタリング前:

fun endMe() {
    if (status == DONE) {
        doSomething()
    } else {
        doSomethingElse()
    }
}

リファクタリング後:

fun endMe() {
    if (status == DONE) {
        doSomething()
        return
    }
    doSomethingElse()
}

リファクタリング前:

fun head(): Node {
    if (isAdvancing())
        return first
    else
        return last
}

リファクタリング後:

fun head(): Node =
    if (isAdvancing()) first else last

ルール4: 1行につきドットは1つまでにすること

リファクタリング前:

class Board {
    class Piece(..., val representation: String)

    class Location(..., val current: Piece)

    fun boardRepresentation(): String =
        buildString {
            for (l in squares())
                append(l.current.representation.substring(0,
1))
        }
}

リファクタリング後:

class Board {
    class Piece(..., private val representation: String) {
        fun character(): String =
            representation.substring(0, 1)
        fun addTo(sb: StringBuilder) {
            sb.append(character())
        }
    }

    class Location(..., private val current: Piece) {
        fun addTo(sb: StringBuilder) {
            current.addTo(sb)
        }
    }
    // 次ページに続く

    // 前ページから続く

    fun boardRepresentation(): String =
        buildString {
            for (l in squares())
                l.addTo(this)
        }
}

ルール7: 1つのクラスにつきインスタンス変数は2つまでにすること

リファクタリング前:

class Name(
    val first: String,
    val middle: String,
    val last: String,
)

リファクタリング後:

class Name(
    val family: Surname,
    val given: GivenNames,
)

class Surname(val family: String)

class GivenNames(val names: List<String>)

2. 関数型プログラミングという

スタイル


[参考] 関数型まつり2025での🐬の発表

functional language tasting

関数型言語テイスティング: Haskell, Scala, Clojure, Elixirを比べて味わう関数型プログラミングの旨さ


(現在の)🐬によるFPとFPLの定義

  • 関数型プログラミング := 純粋関数を基本要素として、その組み合わせによってプログラムを構成していくプログラミングスタイル

    • → 言語を問わず実践可能(実践しやすさは異なる)
  • 関数型言語 := 関数型プログラミングが言語/標準ライブラリレベルで十分に支援される(そして関数型プログラミングスタイルがユビキタスな)言語

    • → 例えばJavaScript/TypeScriptやJava、Kotlin、古典的なLisp方言は含めない

functional programming concept map

※ 🐬が思い浮かぶ概念/用語を連想的に列挙したもの(網羅的でも体系的でもない)

functional programming concept map (values)


関数型プログラミングで重要な性質

  • 純粋性(purity)

  • 不変性(immutability)

  • 合成可能性(composability)

  • 式指向(expression-oriented)

  • 宣言型プログラミング(declarative programming)

  • (型)安全性((type) safety)


3. 🐬の「関数型エクササイズ」

関数型プログラミングのコード設計に親しむために


🐬の「関数型エクササイズ」 9つのルール

  • ルール1: 1つの関数は単一の(文ではなく)式で表すこと

    • 主な狙い: 式指向
  • ルール2: 関数は引数と戻り値を持つこと

    • 主な狙い: 純粋性
  • ルール3: 関数は引数以外の入力に依存しないこと

    • 主な狙い: 純粋性

  • ルール4: I/O処理は関数として分離し注入すること

    • 主な狙い: 純粋性
  • ルール5: 再代入可能な変数、可変なデータ構造を使用/定義しないこと

    • 主な狙い: 不変性
  • ルール6: 繰り返し処理はループ構文ではなくコレクション操作で行うこと

    • 主な狙い: 宣言型プログラミング

  • ルール7: 汎用的な構文や関数よりも目的に特化した関数を選択すること

    • 主な狙い: 宣言型プログラミング
  • ルール8: 既存の関数を部分適用/合成して新たな関数を定義すること

    • 主な狙い: 合成可能性
  • ルール9: 不正な状態が表せないようにデータ型の選択/定義で制限すること

    • 主な狙い: (型)安全性

4. Kotlinでの実践例


今回採用した方針

  • Kotlinの基本的な言語機能を活かす

    • Kotlinに無理なく馴染む表現を目指す
  • オブジェクト指向スタイルを排除せず併用する

    • Kotlinはオブジェクト指向言語
  • 準標準/サードパーティライブラリに依存しない

    • 🐬< 例えばArrowの便利な要素を利用するのも良さそうだが、全面的に使いたくなったらむしろScalaが適していそう😈(Kotlinでそこまでする?)

ルール1: 1つの関数は単一の(文ではなく)式で表すこと

リファクタリング前:

fun endMe() {
    if (status == DONE) {
        doSomething()
        return
    }
    doSomethingElse()
}

リファクタリング後:

fun endMe() =
    if (status == DONE) doSomething()
    else doSomethingElse()

  • 文ではなく式として表すことで命令型のコードが排除されやすくなる

  • Kotlinでは:

    • 命令型言語でお馴染み(?)の構文を引き継ぎつつも分岐構文は式になっていて扱いやすい

    • 単一式(single-expression)関数の構文を積極的に活用すると良い制約になる

      • 1つの式で表しづらくなったら分割することを強いられる

ルール2: 関数は引数と戻り値を持つこと

リファクタリング前:

fun endMe() =
    if (status == DONE) doSomething()
    else doSomethingElse()

リファクタリング後:

fun endMe(input: SomeInput): SomeOutput =
    if (status == DONE) doSomething(input)
    else doSomethingElse(input)

  • 引数をとらない/戻り値を返さない関数は副作用を持ちやすいので必要最小限にする

  • Kotlinでは:

    • オブジェクト指向言語でのクラスに属する関数(メソッド)のレシーバーは暗黙的な引数といえる

    • クラスとしてモデル化するなら、明示的な引数のない関数もありうる

      • ただし、クラスで表す理由は自問したい

ルール3: 関数は引数以外の入力に依存しないこと

リファクタリング前:

var n: Int = 42  // 関数外の不安定な変数/値

fun f(x: Int): Int = x + n

リファクタリング後:

fun f(x: Int, y: Int): Int = x + y

// 適宜、インターフェースを整える
fun g(x: Int): Int = f(x, 42)
fun h(x: Int, y: Int = 42): Int = f(x, y)

  • 引数を介さず関数外からの入力(グローバル/モジュール/クラス変数など)にアクセスすると関数の参照透過性が損なわれやすいので避ける

    • 不変の値(定数)を参照するのであれば問題はない(関数型言語でもクロージャーはありふれている)
  • Kotlinでは:

    • 厳格に従うと、クラスのメソッドが他のメンバー変数にアクセスすることさえできなくなる

    • プライベートメソッドでは引数を介したアクセスのみに制限するような規約も考えられる


ルール4: I/O処理は関数として分離し注入すること

リファクタリング前:

fun listUsers(ids: List<UserId>): List<UserView> =
    UserRepository()
        .findByIds(ids)
        .map { UserView(it) }

リファクタリング後:

fun listUsers(
    ids: List<UserId>,
    resolveUsers: (List<UserId>) -> List<User>,
): List<UserView> =
    resolveUsers(ids).map { UserView(it) }

// 利用例
listUsers(userIds) { ids ->
    UserRepository().findByIds(ids)
}

  • 純粋関数を基本ブロックとするため、I/O処理など副作用の発生箇所は分離/局所化したい

    • 高階関数によって注入するアプローチがシンプルかつ汎用的

    • I/Oを型レベルで分離できる言語/ライブラリも

  • Kotlinでは:

    • interfaceabstract class を利用してもよいが、高階関数で十分な状況も多々ありそう
      • インターフェースを最小化することにも繋がる

ルール5: 再代入可能な変数、可変なデータ構造を使用/定義しないこと

リファクタリング前:

val wordCount = mutableMapOf<String, Int>()
words.forEach { word ->
    val count = wordCount.getOrDefault(word, 0)
    wordCount[word] = count + 1
}
// wordCount.toMap() で読み取り専用マップは得られる

リファクタリング後:

val wordCount: Map<String, Int> =
    words.groupingBy { it }.eachCount()

  • 関数型言語では再代入可能な変数がなく可変データ構造が定義/利用しにくくなっていることも多い

    • 不変だが効率的なコレクション実装もある

    • 関数/モジュールに閉じて可変な変数/データ構造を扱うのは問題ない(パフォーマンスの都合など)

  • Kotlinでは:

    • 変数は val で宣言し、標準コレクションは読み取り専用なものを使う(今や一般的かも?)

    • 明示的に可変な変数やデータ構造を扱わずに済む関数を選択/設計する


ルール6: 繰り返し処理はループ構文ではなくコレクション操作で行うこと

リファクタリング前:

for (n in 1..100) {
    when {
        n % 15 == 0 -> println("FizzBuzz")
        n % 3 == 0 -> println("Fizz")
        n % 5 == 0 -> println("Buzz")
        else -> println(n)
    }
}

リファクタリング後:

fun fizzBuzz(n: Int): String =
    when {
        n % 15 == 0 -> "FizzBuzz"
        n % 3 == 0 -> "Fizz"
        n % 5 == 0 -> "Buzz"
        else -> n.toString()
    }

(1..100)
    .map(::fizzBuzz)
    .forEach(::println)

  • (イミュータブル)コレクションの操作(変換)は関数型プログラミングのありふれた日常の一部

    • プログラムとはデータ変換の連鎖

    • 効率のために命令型のループ構文を局所的に利用することはありうる

  • Kotlinでは:

    • 標準ライブラリに高レベルな関数が充実しているので活用する

    • for, whileforEach 関数はI/Oなどの副作用発生を意図する状況以外では利用しない


ルール7: 汎用的な構文や関数よりも目的に特化した関数を選択すること

リファクタリング前:

val numberOfAdultUsers =
    users.fold(0) { acc, user ->
        if (user.age >= 18) acc + 1 else acc
    }

リファクタリング後:

val numberOfAdultUsers =
    users.count { it.age >= 18 }

  • より宣言的になるように意図が表れる形式を選ぶ

    • 汎用構文 < 汎用関数 < 目的特化関数

    • コレクションに対して:

      • e.g. loop, match < fold < map, filter, sum
    • 直和型に対して:

      • e.g. match < fold < map, filter
  • Kotlinでは:

    • 様々な用途の関数を知って使い分ける、自ら定義する

ルール8: 既存の関数を部分適用/合成して新たな関数を定義すること

リファクタリング前:

fun filterUsersByTargetAge(users: List<User>, minAge: Int):
  List<User> =
    users.filter { it.age >= minAge }
fun <K : Comparable<K>> sortUsers(users: List<User>, keyFn:
  (User) -> K): List<User> =
    users.sortedBy(keyFn)
fun takeFirstUsers(users: List<User>, n: Int): List<User> =
    users.take(n)
// 上記の関数がある状況で
fun listFirst5AdultUsers(users: List<User>): List<User> =
    takeFirstUsers(
        sortUsers(
            filterUsersByTargetAge(users, 18)
        ) { it.joinedAt }, 5
    )

リファクタリング後(1):

fun listFirst5AdultUsers(users: List<User>): List<User> =
    filterUsersByTargetAge(users, 18)
        .let { sortUsers(it) { it.joinedAt } }
        .let { takeFirstUsers(it, 5) }

リファクタリング後(2):

fun listFirst5AdultUsers(users: List<User>): List<User> =
    users
        .filterByTargetAge(18)
        .sortByJoinedAt()
        .takeFirst(5)
private fun List<User>.filterByTargetAge(minAge: Int):
  List<User> =
    filterUsersByTargetAge(this, minAge)
private fun List<User>.sortByJoinedAt(): List<User> =
    sortUsers(this) { it.joinedAt }
private fun List<User>.takeFirst(n: Int): List<User> =
    takeFirstUsers(this, n)

  • 関数を簡潔に再利用するために部分適用や関数合成に役立つユーティリティを活用する

    • 多くの関数型言語にはラムダ式の略記法、パイプ演算子、自動的なカリー化などがある
  • Kotlinでは:


ルール9: 不正な状態が表せないようにデータ型の選択/定義で制限すること

リファクタリング前:

data class User(
    val id: UserId,
    val isRegistered: Boolean,
    val isActive: Boolean,
    val joinedAt: LocalDateTime?,
    val leftAt: LocalDateTime?,
) {
    companion object {
        fun registeringUser(id: UserId): User =
            User(id, false, false, null, null)
        fun activeUser(id: UserId, /**/): User =
            User(id, true, true, joinedAt, null)
        fun inactiveUser(id: UserId, /**/): User =
            User(id, true, false, joinedAt, leftAt)
    }
}

リファクタリング後:

sealed interface User {
    val id: UserId

    data class RegisteringUser(
        override val id: UserId,
    ) : User
    data class ActiveUser(
        override val id: UserId,
        val joinedAt: LocalDateTime,
    ) : User
    data class InactiveUser(
        override val id: UserId,
        val joinedAt: LocalDateTime,
        val leftAt: LocalDateTime,
    ) : User
}

  • 代数的データ型でとりうる値のパターンを定義し、パターンマッチングで網羅的に分岐/分解する

  • booleanやoptional/nullableの乱用を避ける

    • 組み合わせで不正な状態が生じやすくなるため
  • Kotlinでは:

    • sealed interface/classenum で代数的データ型を表せる

    • when 式で網羅的に場合分けできる

      • 🐬< パターンマッチしたい(Javaではできる)

おわりに

🐬が考える、関数型プログラミング実践者の発想:

  • ⛓️ 適切な制約が解放をもたらす

    • → 純粋関数と不変データを基本に

    • → 不正値を表現不能にしてより(型)安全に

  • 🧱 単純で安定なブロックを基礎に全体を構成する

    • → 式指向に、宣言的に、合成可能に

Kotlinらしく関数型プログラミングを実践しよう🐥

設計改善の機会になるはず💪

(もの足りなくなったら(?)、本格的な関数型言語もぜひ😈)


Further Reading

/*
ルール1: 1つの関数は単一の(文ではなく)式で表すこと
*/
// リファクタリング前
fun endMe() {
if (status == DONE) {
doSomething()
return
}
doSomethingElse()
}
// リファクタリング後
fun endMe() =
if (status == DONE) doSomething()
else doSomethingElse()
/*
ルール2: 関数は引数と戻り値を持つこと
*/
// リファクタリング前
fun endMe() =
if (status == DONE) doSomething()
else doSomethingElse()
// リファクタリング後
fun endMe(input: SomeInput): SomeOutput =
if (status == DONE) doSomething(input)
else doSomethingElse(input)
/*
ルール3: 関数は引数以外の入力に依存しないこと
*/
// リファクタリング前
var n: Int = 42 // 関数の不安定な変数/値
fun f(x: Int): Int = x + n
// リファクタリング後
fun f(x: Int, y: Int): Int = x + y
// 適宜、インターフェースを整える
fun g(x: Int): Int = f(x, 42)
fun h(x: Int, y: Int = 42): Int = f(x, y)
/*
ルール4: I/O処理は関数として分離し注入すること
*/
// リファクタリング前
fun listUsers(ids: List<UserId>): List<UserView> =
UserRepository()
.findByIds(ids)
.map { UserView(it) }
// リファクタリング後
fun listUsers(
ids: List<UserId>,
resolveUsers: (List<UserId>) -> List<User>,
): List<UserView> =
resolveUsers(ids).map { UserView(it) }
// 利用例
listUsers(userIds) { ids ->
UserRepository().findByIds(ids)
}
/*
ルール5: 再代入可能な変数、可変なデータ構造を使用/定義しないこと
*/
// リファクタリング前
val wordCount = mutableMapOf<String, Int>()
words.forEach { word ->
val count = wordCount.getOrDefault(word, 0)
wordCount[word] = count + 1
}
// wordCount.toMap() で読み取り専用マップは得られる
// リファクタリング後
val wordCount: Map<String, Int> =
words.groupingBy { it }.eachCount()
/*
ルール6: 繰り返し処理はループ構文ではなくコレクション操作で行うこと
*/
// リファクタリング前
for (n in 1..100) {
when {
n % 15 == 0 -> println("FizzBuzz")
n % 3 == 0 -> println("Fizz")
n % 5 == 0 -> println("Buzz")
else -> println(n)
}
}
// リファクタリング後
fun fizzBuzz(n: Int): String =
when {
n % 15 == 0 -> "FizzBuzz"
n % 3 == 0 -> "Fizz"
n % 5 == 0 -> "Buzz"
else -> n.toString()
}
(1..100)
.map(::fizzBuzz)
.forEach(::println)
/*
ルール7: 汎用的な構文や関数よりも目的に特化した関数を選択すること
*/
// リファクタリング前
val numberOfAdultUsers =
users.fold(0) { acc, user ->
if (user.age >= 18) acc + 1 else acc
}
// リファクタリング後
val numberOfAdultUsers =
users.count { it.age >= 18 }
/*
ルール8: 既存の関数を部分適用/合成して新たな関数を定義すること
*/
// リファクタリング前
fun filterUsersByTargetAge(users: List<User>, minAge: Int): List<User> =
users.filter { it.age >= minAge }
fun <K : Comparable<K>> sortUsers(users: List<User>, keyFn: (User) -> K): List<User> =
users.sortedBy(keyFn)
fun takeFirstUsers(users: List<User>, n: Int): List<User> =
users.take(n)
// 上記の関数がある状況で
fun listFirst5AdultUsers(users: List<User>): List<User> =
takeFirstUsers(
sortUsers(
filterUsersByTargetAge(users, 18)
) { it.joinedAt }, 5
)
// リファクタリング後(1)
fun listFirst5AdultUsers(users: List<User>): List<User> =
filterUsersByTargetAge(users, 18)
.let { sortUsers(it) { it.joinedAt } }
.let { takeFirstUsers(it, 5) }
// リファクタリング後(2)
fun listFirst5AdultUsers(users: List<User>): List<User> =
users
.filterByTargetAge(18)
.sortByJoinedAt()
.takeFirst(5)
private fun List<User>.filterByTargetAge(minAge: Int): List<User> =
filterUsersByTargetAge(this, minAge)
private fun List<User>.sortByJoinedAt(): List<User> =
sortUsers(this) { it.joinedAt }
private fun List<User>.takeFirst(n: Int): List<User> =
takeFirstUsers(this, n)
/*
ルール9: 不正な状態が表せないようにデータ型の選択/定義で制限すること
*/
// リファクタリング前
data class User(
val id: UserId,
val isRegistered: Boolean,
val isActive: Boolean,
val joinedAt: LocalDateTime?,
val leftAt: LocalDateTime?,
) {
companion object {
fun registeringUser(id: UserId): User =
User(id, false, false, null, null)
fun activeUser(id: UserId, joinedAt: LocalDateTime): User =
User(id, true, true, joinedAt, null)
fun inactiveUser(id: UserId, joinedAt: LocalDateTime, leftAt: LocalDateTime): User =
User(id, true, false, joinedAt, leftAt)
}
}
// リファクタリング後
sealed interface User {
val id: UserId
data class RegisteringUser(
override val id: UserId,
) : User
data class ActiveUser(
override val id: UserId,
val joinedAt: LocalDateTime,
) : User
data class InactiveUser(
override val id: UserId,
val joinedAt: LocalDateTime,
val leftAt: LocalDateTime,
) : User
}
slides:
charset: utf-8
theme: night
highlight_theme: monokai-sublime
separator_vertical: ^\s*----\s*$
revealjs:
transition: convex
plugins:
- extra_css:
- https://cdnjs.cloudflare.com/ajax/libs/meyer-reset/2.0/reset.min.css
/*
ルール1: 1つのメソッドにつきインデントは1段階までにすること
*/
// リファクタリング前
class Board {
fun board(): String =
buildString {
for (row in data) {
for (square in row)
append(square)
appendLine()
}
}
}
// リファクタリング後
class Board {
fun board(): String =
buildString {
collectRows(this)
}
fun collectRows(sb: StringBuilder) {
for (row in data)
collectRow(sb, row)
}
fun collectRow(sb: StringBuilder, row: List<Square>) {
for (square in row)
sb.append(square)
sb.appendLine()
}
}
/*
ルール2: else句を使用しないこと
*/
// リファクタリング前
fun endMe() {
if (status == DONE) {
doSomething()
} else {
doSomethingElse()
}
}
// リファクタリング後
fun endMe() {
if (status == DONE) {
doSomething()
return
}
doSomethingElse()
}
// リファクタリング前
fun head(): Node {
if (isAdvancing())
return first
else
return last
}
// リファクタリング後
fun head(): Node =
if (isAdvancing()) first else last
/*
ルール4: 1行につきドットは1つまでにすること
*/
// リファクタリング前
class Board {
class Piece(..., val representation: String)
class Location(..., val current: Piece)
fun boardRepresentation(): String =
buildString {
for (l in squares())
append(l.current.representation.substring(0, 1))
}
}
// リファクタリング後
class Board {
class Piece(..., private val representation: String) {
fun character(): String =
representation.substring(0, 1)
fun addTo(sb: StringBuilder) {
sb.append(character())
}
}
class Location(..., private val current: Piece) {
fun addTo(sb: StringBuilder) {
current.addTo(sb)
}
}
fun boardRepresentation(): String =
buildString {
for (l in squares())
l.addTo(this)
}
}
/*
ルール7: 1つのクラスにつきインスタンス変数は2つまでにすること
*/
// リファクタリング前
class Name(
val first: String,
val middle: String,
val last: String,
)
// リファクタリング後
class Name(
val family: Surname,
val given: GivenNames,
)
class Surname(val family: String)
class GivenNames(val names: List<String>)
#!/usr/bin/env bash
# pip install mkslides
open http://localhost:8000 \
&& mkslides serve *.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment