nashcft's blog

時々何か書く。

Kotlin Android Extensions の view binding に関する知識の整理

Kotlin Android Extensions の解説記事、Kotlin のリファレンスとか英語ソースだったらよくまとまってるのがあるけど日本語ソースだとあんまりいい感じのないなーという印象なので自分でまとめてみる。ただし view binding の方だけ。英語が読めるなら以下の記事を読めばほぼ十分という感じの内容。

kotlinlang.org

antonioleiva.com

Read more

Ergo42 を買った

動機と購入までの経緯

一昨年前くらいに購入した ErgoDox EZチャタリングを起こすようになったのと、 ErgoDox の形状に100%満足していたわけではないので別のも試してみるかーと思って探してみたけど近頃の流行りは自作で電子工作力も道具もない私にはちょっと手を出しにくいなーってなってたところ、組み立て済みの物を販売していてちょうど在庫があったので買ってみた。もし工作力と設備があれば dactyl manuform 作りたいのだけど...

tanoshii-life.booth.pm

Keymap

現状こんな感じ

最初は default ベースに ErgoDox の時のを組み合わせた感じにしていたのだけど、キー数が少ない分レイヤ移動が頻繁に起こることが考慮されてない構成だったのでひたすら数字や記号が打ちにくく終わっていたのでとりあえず META レイヤの切り替えを親指の位置に持ってきたり他のキーもいくつか親指周りで重ねてみたりということをして様子を見ているという状況。

自分がまだ Ergo42 のキーの少なさに対応できていない感じがしていて、キー配置の考慮が行き届いていないと思うところがあり、もうしばらく模索しないとなーとなっている。今わかっていることはとにかくレイヤ切り替えが簡単にできること、 MOLT だったり modifier key だったりホールドする必要があって指の可動域が制限される状況を想定した時にどの指でホールドして何ができるようにしておくべきかを考慮した配置にすること、あたりが肝ではないかという感じ。ホールド用の modifier を組み合わせたキーとか作っとくといいかも?

今のところの感想

コンパクトさ

デスクの上を占める面積だったり持ち運びやすさだったり ErgoDox と比較してこのアドバンテージはかなり大きい。また殆どホームポジションから手を動かさずにタイピングできるのも楽で、ErgoDox では外側の端や親指のいくつかのキーを押すのに指を思い切り伸ばすとか割と無理なことしていたけどそういうこともなくなった。あとは個人的な状況でいうと Roost の RKM carrying case に収まるのでそれだけでキーボードとスタンドとケーブル類、その他小道具をまとめて持ち運べて大変便利している (ErgoDox は片方すらケースに収まらない)。

www.amazon.com

キー数・配置

4x7 は割とちょうど良いかもなーと keymap 考えている時に思った。必要なキーがきっちり収まって、1, 2キー余るという印象。5行あって数字キーも BASE と同じレイヤで使える方が馴染みやすいと思うけど4行でも案外いけるものだという感じ。
ただ中指・薬指・小指の列の一番下のキーはシームレスにタイプするのは無理だなーと思ってて、この位置のキーが親指のあたりにあったらもっとやりやすいかなーという気持ちになってる。Crkbd の親指まわりのキー数が5~6個あるみたいなのがちょうど良いかもなーって考えている。

booth.pm

おわりに

まだ使い始めて2週間とかそこらで慣れきってないし keymap も定まってない状況なのでキーボードに思考が奪われることがしばしばあって生産性死んでるけど良い感じだと思う。

ここまで書き終わってからこれを読んだのだけど意図せず製作者の考えてたことをたどりながら使ってた感がありなんか (私が) よかったですねってなった。

あ、読んだ流れで pixivFANBOX の支援はじめました。

余談

Ergo42 の購入を決める前に ErgoDox EZ 買い直すかーとも考えててサイト見に行ったらなんかはんだいらずでキースイッチを付け替えられるようになってた ので最高では??????????? となりとはいえ2つも買うのはどうだろうと悩んだ末にその時は Ergo42 だけ買うことにしたんだけど結局 ErgoDox EZ も買ってしまいましたとさ。

最近もやもや考えてる事

DroidKaigi 2018 が終わり続く三連休も過ぎ去って体調が低空飛行なりに業務に復帰しつつ考えてる事:

スプリント期間の短縮をしたい

今のチームというか会社では全体的に1スプリント2週間で回しているのだけど、土日を挟む事で勢いがリセットされてしまって2週目はいつもダレているような気がしている。(ダレてるのは) 自分だけかもしれないけど。
今週はその2週目にあたる週で、先週と比べて明らかにタスクの流れが滞っている。自分が持っていったタスクが一旦片付いたので昨日今日は勤務時間の半分くらいずつを使って溜まってしまっていたPRを全部レビューしてLGTMしたりコメントつけたりしたのだが、だいたい半分くらいしかその後のアクションが行われず、まあこれはうちのチームのPRとそのレビューがだらしない傾向にあるのも一因なのだけどなんだかなーという気持ちになったのでこのような事を考え出した。
それでスプリントを2週間ではなく1週間にすれば毎週新しいスプリントなので毎週リフレッシュされるしいいんじゃないかなーとか、計画とレビューが毎週発生するようになってMTGの時間がーという風にも考えられるがスプリントが短い分その中でこなすべきタスク量も2週間分と比較して少なくなって、MTG1回あたりの時間も短くなるんじゃないかとか、1週間でできるタスクの大きさに制限する事でより見積もりがしやすくなるんじゃないかとか考えていた。
とまあこの程度でまだ社内にうまく提案するまでまとまってないのでWIP。

PRとレビューがだらしない

上でもちょっと出たけどだらしないというのはどういうことかというと1つのPRが大きくなりがちだったりレビューもそれに対するリアクションも遅めで、典型的なコードレビューが開発のボトルネックになっている環境。
でかいPRというのは本当に厳しくて、まあレビュイーについてもコードを見て気持ちになってしまったというのはわかるのだけど見きれないよ... となり、レビューもだんだん雑になってきて結果的に無をしてマージ、みたいなことになる。
これは今ペアプロ・モブプロを導入しようとしている最中で、それで解決に持っていきたい。

会話の中で「話すべきこと」と「話したいこと」の分別がつけられずに発言すること

これは自分にも身に覚えがあるので自戒でもあるのだけど、相談事をしている最中に自分語りみたいなのを始めて相談や質問の回答が返ってくるまで数分以上かかったり、もしくはこちらから促さないと返ってこなかったり、真面目に議論している最中に突然脱線させて帰ってこなくなったりみたいなことが発生すると本当につらい。
これはやってしまっている本人は気づいてないというか、文脈を読み違えてしまった故の悲劇というか、意識しないと誰でもやってしまうように思われる。多分発生したら直接指摘するしかないのだと思うが、本人は自然に話をしているつもりなのだろうから忍びなく思ってしまう。故意だったら質が悪い。

年明けから今日まで

継続的に文章を書くという試みとはなんだったのかという様子ですね。とりあえず年が明けてからのことを書きます。

1月上旬〜中旬

年末年始休暇が終わっていきなり気分が沈み始め、業務の進捗も出なかったことが追い打ちになって完全に精神が終了していました。この時期Twitterではずっとダメになったとか終わってしまったとか呟いてたと思います。現実ではよくわからなかったのでリムスキーコルサコフ管弦楽法のテキストを買って読んでました。
ところでこの時期割と頻繁にチームの同僚に心情の吐露をしていたような気がするのですが、振り返ってみるとうちのケースにおいてはあまり良くなかったなあという反省があり、具体的には人の面倒を見ることに明るくない人間に感情に関する曖昧な共有をしてもただ困らせことになるだけだということです。この辺は同僚に対する愚痴ではなく弊チームの成熟度合いや体制プロセスetcと様々な要素が絡んでおり何かが悪いというより不運な事故が発生したという認識で、むしろ相手からしたら唐突に「私はしらみだ...」とだけ言ってくる同僚なんてどうしろってんだという感じだったでしょう。

1月下旬〜今日

下旬くらいになってからだんだん精神が自然回復してきて、業務の進捗も出るようになりました。そういえば弊社でもやっとAndroidアプリ開発にKotlinが導入されるようになってやっと文明の火を得たという心持ちです。
このくらいに昼食を外食からCOMPとプロテインミックスしたものに変えてみたり、『シリコンバレー式 自分を変える最強の食事』を読んで朝食をココナッツオイルコーヒー+αにするなどしてみたところ日中に頭がはっきりした状態を保てるようになって、それにつられて気分もだいぶ良くなりだいたい本調子に戻りました。最近はその辺が面白くて毎日食事の調整をしながら体調を観察して継続的に良い調子を保てる食事メニューを考えています。
最近はチームにペアプロ・モブプロを導入しようと色々やっています。弊チームは性格的にプルリクとコードレビューがだらしなくなりやすい (1つのプルリクが巨大になる、レビューが五月雨でマージされるまで時間がかかるなど) のでその辺の解決になるといいなーとか考えながら進めています。

とりあえず書きたいことをとにかく書き出そうという書き方をしたので脈絡のない乱文になりましたが何かしら文字に起こせて満足したのでいいやという所感です。溜めに溜めてビッグバンリリースをするから酷い事になるという様を表しているということにします。

ところで諸々のメモ書き的なものは使い勝手からScrapboxの方でやろうというということにしていて、こっちでは何かしら文章の体裁をとったものを投稿する時に使うことにしました。とはいえこっちもまだろくなこと書いていません。

Kotlin で『テスト駆動開発』を進める (第14章 - 第16章)

前回

さて Kotlin で『テスト駆動開発』を写経するシリーズも Part I が今回で終わるので一区切りとなる。前回で書籍のコードから設計方針を転換しているので、1つ1つのタスクに対してどのように方針を立て実装していくか、きちんと過程を残すように書きたいと思う。

現在のコード:

MoneyTest.kt

class MoneyTest {

    @Test
    fun testMultiplication() {
        val five = dollar(5)
        assertEquals(dollar(10), five * 2)
        assertEquals(dollar(15), five * 3)
    }

    @Test
    fun testEquality() {
        assertTrue(dollar(5) == dollar(5))
        assertFalse(dollar(5) == dollar(6))

        assertFalse(dollar(5) == franc(5))
    }

    @Test
    fun testCurrency() {
        assertEquals("USD", dollar(1).currency)
        assertEquals("CHF", franc(1).currency)
    }

    @Test
    fun testSimpleAddition() {
        val five = dollar(5)
        val sum = five + five
        val reduced = reduce(sum, "USD")
        assertEquals(dollar(10), reduced)
    }

    @Test
    fun testPlusReturnsSum() {
        val five = dollar(5)
        val result = five + five
        val sum = result as Sum
        assertEquals(five, sum.augend)
        assertEquals(five, sum.addend)
    }

    @Test
    fun testReduceSum() {
        val sum = Sum(dollar(3), dollar(4))
        val result = reduce(sum, "USD")
        assertEquals(dollar(7), result)
    }

    @Test
    fun testReduceMoney() {
        val result = reduce(dollar(1), "USD")
        assertEquals(dollar(1), result)
    }
}

Bank.kt

fun reduce(source: Expression, to: String) = when (source) {
    is Money -> source
    is Sum -> Money(sum(source.augend, source.addend, to), to)
}

private fun sum(augend: Expression, addend: Expression, to: String): Int =
            reduce(augend, to).amount + reduce(addend, to).amount

Money.kt

data class Money(val amount: Int, val currency: String) : Expression() {

    operator fun times(multiplier: Int) = Money(amount * multiplier, currency)

    operator fun plus(addend: Money): Expression = Sum(this, addend)

    companion object {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

sealed class Expression {

    data class Sum(val augend: Money, val addend: Money) : Expression()
}

TODO リスト:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10
  • [ ] $5 + $5Money を返す
  • [x] Bank.reduce(Money)
  • [ ] Money を変換して換算を行う
  • [x] Reduce(Bank, String)

第14章 学習用テストと回帰テスト

この章ではまず「Money を変換して換算を行う」に取り組む。まずはCHF -> USDの換算に関するテストを追加。

class MoneyTest {

    // ...

    @Test
    fun testReduceMoneyDifferentCurrency() {
        addRate("CHF", "USD", 2)
        val result = bank.reduce(franc(2), "USD")
        assertEquals(dollar(1), result)
    }
}

仮実装の段階なのでとりあえず Bank.kt に addRate() を追加する。

fun addRate(from: String, to: String, rate: Int) {}

このあと書籍では換算処理の記述を Money.reduce() から始めて Bank の中に持っていくために色々するが、現在の私の実装では該当する reduce() の処理は Bank.kt の reduce() の中に存在するので、いきなり Bank.kt に rate() を作って reduce() の中で呼ぶように書くだけでよい。

fun reduce(source: Expression, to: String) = when (source) {
    is Money -> Money(source.amount / rate(source.currency, to), to)
    is Sum -> Money(sum(source.augend, source.addend, to), to)
}

private fun rate(from: String, to: String) = if (from == "CHF" && to == "USD")  2 else 1

配列の比較は省略。このあと Pair クラスの作成にかかるが Kotlin には標準で Pair を持っているのでそれを使用することにする。Pair を key, 為替レートを value とする map を Bank に保持させることになり、ここで Bank が状態を保存しておく必要が出てきたので Bank クラスを作成し、これまで Bank.kt に定義していた関数群を Bank のメンバとして持たせるよう変更する。

class Bank {

    private val rates: MutableMap<Pair<String, String>, Int> = HashMap()

    fun reduce(source: Expression, to: String) = when (source) {
        is Money -> Money(source.amount / rate(source.currency, to), to)
        is Expression.Sum -> Money(sum(source.augend, source.addend, to), to)
    }

    private fun rate(from: String, to: String) = if (from == "CHF" && to == "USD")  2 else 1

    private fun sum(augend: Expression, addend: Expression, to: String): Int =
            reduce(augend, to).amount + reduce(addend, to).amount

    fun addRate(from: String, to: String, rate: Int) {}
}

Bank を作って関数を中に放り込んだためこれまで reduce() を読んでたところがコンパイルエラーになったので修正する。

class MoneyTest {

    // ...

    @Test
    fun testSimpleAddition() {
        val five = dollar(5)
        val sum = five + dollar(5)
        val reduced = Bank().reduce(sum, "USD")
        assertEquals(dollar(10), reduced)
    }

    // ...

    @Test
    fun testReduceSum() {
        val sum = Expression.Sum(dollar(3), dollar(4))
        val result = Bank().reduce(sum, "USD")
        assertEquals(dollar(7), result)
    }

    @Test
    fun testReduceMoney() {
        val result = Bank().reduce(dollar(1), "USD")
        assertEquals(dollar(1), result)
    }

    @Test
    fun testReduceMoneyDifferentCurrency() {
        val bank = Bank()
        bank.addRate("CHF", "USD", 2)
        val result = bank.reduce(franc(2), "USD")
        assertEquals(dollar(1), result)
    }
}

テストが通るか確認をして、addRate() で実際に為替レートを格納する処理、および rate() で為替レートを取得を実装する。

class Bank {

    // ...

    private fun rate(from: String, to: String) = rates[Pair(from, to)]
                    ?: throw IllegalArgumentException("Unregistered rate: $from to $to")

    // ...

    fun addRate(from: String, to: String, rate: Int) {
        rates.put(Pair(from, to), rate)
    }
}

Map.get() の返却値は V? (今回の場合 Int?) なので、為替レートが未登録の場合は例外を投げるか rate()rate(): Int? として定義するか適当なデフォルト値を返すがということになるが、内部処理で使用しているのでここで null を返されても困るし、例外設計は写経の本筋と離れるので今回は例外を投げるということにしておく。ここでテストを実行すると testReduceMoney() がレッドになる。同じ通貨への為替レートを返さないといけないので、回帰テストを追加してから対応する。

class MoneyTest {

    // ...

    @Test
    fun testIdentityRate() {
        assertEquals(1, Bank().rate("USD", "USD"))
    }
}
class Bank {

    // ...

    fun rate(from: String, to: String) =
            if (from == to) 1 else rates[Pair(from, to)]
                    ?: throw IllegalArgumentException("Unregistered rate: $from to $to")

    // ...
}

これで全テストがグリーンに戻り、14章の内容は終了。TODO リストを更新しておく:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 + $5 = $10
  • [ ] $5 + $5Money を返す
  • [x] Bank.reduce(Money)
  • [x] Money を変換して換算を行う
  • [x] Reduce(Bank, String)

第15章 テスト任せとコンパイラ任せ

この章で Part I の本題である $5 + 10CHF = $10 に着手することになる。

class MoneyTest {

    // ...

    @Test
    fun testMixedAddition() {
        val fiveBucks: Expression = dollar(5)
        val tenFrancs: Expression = franc(10)
        val bank = Bank()
        bank.addRate("CHF", "USD", 2)
        val result = bank.reduce(fiveBucks + tenFrancs, "USD")
        assertEquals(dollar(10), result)
    }
}

$5 と 10CHF を Expression として受けているためコンパイルエラーになる。書籍の次ステップの通り一旦これらを Money で受けるようにするとコンパイルが通りテストが... グリーンになる。これは第13章で設計を転換した時に Bank.reduce() の実装を Bank.sum() を介して再帰的に書いていたためで、実はこのあとの書籍の対応と同じだったのだ。

そういうわけで先ほどやろうとした Expression.plus の実現を進める。まずはテストケースを章の最初に示した状態に戻し、Sum のフィールドと Money.times() の返却値の型を Expression にする。

data class Money(val amount: Int, val currency: String) : Expression() {

    operator fun times(multiplier: Int): Expression = Money(amount * multiplier, currency)

    operator fun plus(addend: Money): Expression = Sum(this, addend)

    companion object {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

sealed class Expression {

    data class Sum(val augend: Expression, val addend: Expression) : Expression()
}

あとは Expression 自身が plus() を持っていればよいので、Money からそのまま連れてくる。

data class Money(val amount: Int, val currency: String) : Expression() {

    operator fun times(multiplier: Int): Expression = Money(amount * multiplier, currency)

    companion object {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

sealed class Expression {

    operator fun plus(addend: Money): Expression = Sum(this, addend)

    data class Sum(val augend: Expression, val addend: Expression) : Expression()
}

最後に TODO リストを更新しておしまい:

  • [x] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 + $5 = $10
  • [ ] $5 + $5Money を返す
  • [x] Bank.reduce(Money)
  • [x] Money を変換して換算を行う
  • [x] Reduce(Bank, String)
  • [x] Sum.plus
  • [ ] Expression.times

更新内容をみてわかるが、 Expressionplus() を持ってきた時点で Sum もこの関数を使えるようになっているので完了してしまっている。

第16章 将来の読み手を考えたテスト

さて前章で Sum.plus の実装も終わったことにしているが、本当にそうなのか? 本来 Sum.plus を実装するはずだったこの章で追加されるテストで確認をする。

class MoneyTest {

    // ...

    @Test
    fun testSumPlusMoney() {
        val fiveBucks: Expression = dollar(5)
        val tenFrancs: Expression = franc(10)
        val bank = Bank()
        bank.addRate("CHF", "USD", 2)
        val sum = Expression.Sum(fiveBucks, tenFrancs) + fiveBucks
        val result = bank.reduce(sum, "USD")
        assertEquals(dollar(15), result)
    }
}

きちんと通った。では次は Expression.times の実装をする。

class MoneyTest {

    // ...

    @Test
    fun testSumTimes() {
        val fiveBucks: Expression = dollar(5)
        val tenFrancs: Expression = franc(10)
        val bank = Bank()
        bank.addRate("CHF", "USD", 2)
        val sum = Expression.Sum(fiveBucks, tenFrancs) * 2
        val result = bank.reduce(sum, "USD")
        assertEquals(dollar(20), result)
    }
}

フィクスチャーは今はスルー。書籍では Expressionインターフェイスなので Sum に実装を書いているが、私の実装では sealed class なので Money.timesExpression に持ってきて、when で分岐させれば十分だろう。

data class Money(val amount: Int, val currency: String) : Expression() {

    companion object {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

sealed class Expression {

    operator fun times(multiplier: Int): Expression = when(this) {
        is Money -> Money(amount * multiplier, currency)
        is Sum -> Sum(augend * multiplier, addend * multiplier)
    }

    operator fun plus(addend: Money): Expression = Sum(this, addend)

    data class Sum(val augend: Expression, val addend: Expression) : Expression()
}

テストも通ったのでこれでOK。

さて最後の「$5 + $5Money を返す」だが、結果的に何もしないことになるので、これで第16章が終わる。

  • [x] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 + $5 = $10
  • [x] $5 + $5Money を返す
  • [x] Bank.reduce(Money)
  • [x] Money を変換して換算を行う
  • [x] Reduce(Bank, String)
  • [x] Sum.plus
  • [x] Expression.times

まとめと振り返りなど

最終的な実装を以下に示す。差分を書き直すのが面倒なので GitHub のレポジトリと合わせるためにテストクラスに雑なフィクスチャー (っぽいもの) を作ったりMoney.currencyenum class Currency とした実装を含めたりしている。

MoneyTest.kt

package money

import money.Money.Companion.dollar
import money.Money.Companion.franc
import money.Money.Currency.CHF
import money.Money.Currency.USD
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class MoneyTest {

    @Test
    fun testMultiplication() {
        val five = dollar(5)
        assertEquals(dollar(10), five * 2)
        assertEquals(dollar(15), five * 3)
    }

    @Test
    fun testEquality() {
        assertTrue(dollar(5) == dollar(5))
        assertFalse(dollar(5) == dollar(6))

        assertFalse(dollar(5) == franc(5))
    }

    @Test
    fun testCurrency() {
        assertEquals(USD, dollar(1).currency)
        assertEquals(CHF, franc(1).currency)
    }

    @Test
    fun testSimpleAddition() {
        val five = dollar(5)
        val sum = five + dollar(5)
        val reduced = Bank().reduce(sum, USD)
        assertEquals(dollar(10), reduced)
    }

    @Test
    fun testPlusReturnSum() {
        val five = dollar(5)
        val sum = (five + five) as Expression.Sum
        assertEquals(five, sum.augend)
        assertEquals(five, sum.addend)
    }

    @Test
    fun testReduceSum() {
        val sum = Expression.Sum(dollar(3), dollar(4))
        val result = Bank().reduce(sum, USD)
        assertEquals(dollar(7), result)
    }

    @Test
    fun testReduceMoney() {
        val result = Bank().reduce(dollar(1), USD)
        assertEquals(dollar(1), result)
    }

    @Test
    fun testReduceMoneyDifferentCurrency() {
        val bank = Bank()
        bank.addRate(CHF, USD, 2)
        val result = bank.reduce(franc(2), USD)
        assertEquals(dollar(1), result)
    }

    @Test
    fun testIdentityRate() {
        assertEquals(1, Bank().rate(USD, USD))
    }

    private val fiveBucks: Expression = dollar(5)
    private val tenFrancs: Expression = franc(10)

    @Test
    fun testMixedAddition() {
        val bank = setUpBankWithCHF2USDRate()
        val result = bank.reduce(fiveBucks + tenFrancs, USD)
        assertEquals(dollar(10), result)
    }

    @Test
    fun testSumPlusMoney() {
        val bank = setUpBankWithCHF2USDRate()
        val sum = Expression.Sum(fiveBucks, tenFrancs) + fiveBucks
        val result = bank.reduce(sum, USD)
        assertEquals(dollar(15), result)
    }

    @Test
    fun testSumTimes() {
        val bank = setUpBankWithCHF2USDRate()
        val sum = Expression.Sum(fiveBucks, tenFrancs) * 2
        val result = bank.reduce(sum, USD)
        assertEquals(dollar(20), result)
    }

    private fun setUpBankWithCHF2USDRate(): Bank {
        val bank = Bank()
        bank.addRate(CHF, USD, 2)
        return bank
    }
}

Money.kt

package money

data class Money(val amount: Int, val currency: Currency) : Expression() {

    companion object {
        fun dollar(amount: Int) = Money(amount, Currency.USD)
        fun franc(amount: Int) = Money(amount, Currency.CHF)
    }

    enum class Currency {
        USD, CHF
    }
}

sealed class Expression {

    operator fun plus(addend: Expression): Expression = Sum(this, addend)

    operator fun times(multiplier: Int): Expression = when (this) {
        is Money -> Money(amount * multiplier, currency)
        is Sum -> Sum(augend * multiplier, addend * multiplier)
    }

    data class Sum(val augend: Expression, val addend: Expression) : Expression()
}

Bank.kt

package money

import money.Money.Currency

class Bank {

    private val rates: MutableMap<Pair<Currency, Currency>, Int> = HashMap()

    fun reduce(source: Expression, to: Currency) = when (source) {
        is Money -> Money(source.amount / rate(source.currency, to), to)
        is Expression.Sum -> Money(sum(source.augend, source.addend, to), to)
    }

    fun rate(from: Currency, to: Currency) =
            if (from == to) 1 else rates[Pair(from, to)]
                    ?: throw IllegalArgumentException("Unregistered rate: $from to $to")

    private fun sum(augend: Expression, addend: Expression, to: Currency): Int =
            reduce(augend, to).amount + reduce(addend, to).amount

    fun addRate(from: Currency, to: Currency, rate: Int) {
        rates.put(Pair(from, to), rate)
    }
}

今回取り組んだ3章に関しては、第13章で実施した設計の転換によって1つの機能に対して実装する箇所が基本的に1箇所に集約されるようになって、書籍と比べてあっちこっち見ずに済み感覚的に実装の負担が少ないように思えたのが印象的だった。とはいえ主な修正箇所が Bank だったこと、そもそもの実装内容が小規模だったことからそれ以外は特に気になるような差異はなかったように思う。まあ sealed class を使うことで分岐における網羅性の保証について無駄なことを考慮しなくて済むので、積極的に分岐を使って実装を集約しやすいという点は前回と今回で割と活きたのではないだろうか。

全体を通して Kotlin という言語に持った印象は、便利機能が標準で入っていたり Java の Object にまつわるボイラープレート的な実装を省略できる機能があったりする点以外はやはり Java ベースの言語で、Kotlin は Java 8 とは異なる方向性の進化をした、順当な「モダンさ」を取り入れた Java という感じだった。これは data class やsealed class を用いて色々な実装の手間が省けた以外では記述方法が異なる場所があるだけでおおよそ Java の実装と変わらない形に落ち着いたなあという所感によるところが大きいと思う。ただ Bank の実装で見たように、Java とは異なり最初からクラスやオブジェクトという構造を単位として作らなくてよく、関数という機能単位で実装を進められることは、ボトムアップなアプローチができるという点で設計のやりようが柔軟になっているかもしれない。この辺は実際に Kotlin でアプリなどを開発している現場が延べでどのくらい package-level function を作ってるのか聞いてみたい所である。

TDD に関しては、これまで聞きかじったり仕事でもなんとなくそれっぽくやってみていたりしたこともあってサイクルやテンポがとても自然に感じられた。ただ設計についての考え方や実装の粒度については学ぶところが多く、特に大きな流れの中で小さなサイクルを回しながら前進しつつ、ゴールにきちんと向かっていくという第5章から第11章までの流れがとても印象的だった。

そういえば途中で振り返りで触れるぞーと言っていた話題があったが、sealed class や enum を使ってバリエーションを開発側で制限するのって、例えばこの多国通貨のシステムがパッケージなりライブラリなりとして提供された時の使い勝手的にどうなのかなーというもので、ユーザ定義で増やせる方が使いやすいとかでも予期しないエラーがどうのこうのとかそういう感じの話だったのだが、TDDとはそこまで関係ないなーと今になって思ったのでこれだけ。最後に currencyenum class にしたのはやっぱそっちの方が楽だよねという気持ちがあって、でも最後にやっても何の恩恵にもあずかれないのであった。

Part II 以降については別にやりたいことを進めているため、読み進めているだけで写経は一旦お休みという状態である。こっちも写経の様子をブログにできたらとは思っている。

Kotlin で『テスト駆動開発』を進める (第12章 - 第13章)

前回

前回までは通貨を表すクラスをシンプルにしていくタスクが中心だったが、今回からやっと本題である通貨の足し算に手を着け始める。

現在のコード:

MoneyTest.kt

class MoneyTest {

    @Test
    fun testMultiplication() {
        val five = dollar(5)
        assertEquals(dollar(10), five.times(2))
        assertEquals(dollar(15), five.times(3))
    }

    @Test
    fun testEquality() {
        assertTrue(dollar(5) == dollar(5))
        assertFalse(dollar(5) == dollar(6))

        assertFalse(dollar(5) == franc(5))
    }

    @Test
    fun testCurrency() {
        assertEquals("USD", dollar(1).currency)
        assertEquals("CHF", franc(1).currency)
    }
}

Money.kt

data class Money(private val amount: Int, val currency: String) {

    fun times(multiplier: Int) = Money(amount * multiplier, currency)

    companion object {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

第12章 設計とメタファー

まずは長くなった TODO リストから未完了で必要なタスクを取り出し、新しい TODO も追加する:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10

まずは同じ通貨の足し算から。追加するテストは以下の通り。

class MoneyTest {

    // ...

    @Test
    fun testSimpleAddition() {
        val sum = dollar(5).plus(dollar(5))
        assertEquals(dollar(10), sum)
    }
}

そして仮実装を書く。

data class Money(private val amount: Int, val currency: String) {

    // ...

    fun plus(addend: Money) = Money(amount + addend.amount, currency)
    // ...
}

書籍ではここで多国通貨間の計算をどう表現するかという議論とメタファーに関するお話が始まり、「式 (expression)」のメタファーを採用するという結果に着地しするので、それをテストコードに反映させる。

class MoneyTest {

    // ...

    @Test
    fun testSimpleAddition() {
        val five = dollar(5)
        val sum = five.plus(five)
        val reduced = reduce(sum, "USD")
        assertEquals(dollar(10), reduced)
    }
}

書籍では reduce() を「銀行の責務」として Bank クラスを作りそこに実装するのだが、今の所持つ状態も無さそうだししばらく package-level function でいいかという判断をして様子を見ることにした。

新しいものが追加されたのでそれらを実装していく。
まずは Expressionインターフェイスとして作成。

// Expression.kt
interface Expression

Moneyplus() 関数の返却値型を Expression に変更し、 Money 自身にも Expression を実装する。

data class Money(private val amount: Int, val currency: String) : Expression {

    // ...

    fun plus(addend: Money): Expression = Money(amount + addend.amount, currency)
    // ...
}

reduce() については、とりあえず Bank.kt ファイルを作成してそこに package-level function として定義して、テストを通すための仮実装をしておく。なお、実際に呼び出す際は reduce() だけでよいが、記事内では便宜上 Bank.reduce() と呼ぶ。

// Bank.kt
fun reduce(source: Expression, to: String) = Money.Companion.dollar(10)

これで12章の内容は終わりなのだが、ちょっとだけ寄り道をしたい。
今回新しく plus() 関数が追加されたが、以前から times() 関数があり、そういえばこの辺は四則演算を表す関数名だということを思い出す。通貨の計算も「通貨」の概念がある以外は分量の計算だし数値と同じように演算子でできたらいいなーと思い調べてみれば Operator overloading ができるみたいなのでこのタイミングで書き換えてしまおう。Operator overloading をするには対象となる関数定義に operator キーワードを加えるだけでよい。

data class Money(private val amount: Int, val currency: String) : Expression {

    operator fun times(multiplier: Int) = Money(amount * multiplier, currency)

    operator fun plus(addend: Money): Expression = Money(amount + addend.amount, currency)

    // ...
}

すると現在のテストコードは以下のように書き直せる。

class MoneyTest {

    @Test
    fun testMultiplication() {
        val five = dollar(5)
        assertEquals(dollar(10), five * 2)
        assertEquals(dollar(15), five * 3)
    }

    // ...

    @Test
    fun testSimpleAddition() {
        val five = dollar(5)
        val sum = five + five
        val reduced = reduce(sum, "USD")
        assertEquals(dollar(10), reduced)
    }
}

...乗算をする時の左辺と右辺が違う型の値なのが見た感じ微妙かもしれないけど慣れの問題のような気もする。まあ終わった TODO にも $5 * 2 = $10 とかあるし、よりこれに近い記述ができてるからよいということにする。ところで現状だと乗算の順序が固定されてしまうので、不満があれば Int に対して以下のように extension function を追加するということができる。この写経では使わないけど。

operator fun Int.times(money: Money) = money.times(this)

第13章 実装を導くテスト

前章で実装した plus() の重複を取り除くために先へ進める。まずは TODO の追加から:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10
  • [ ] $5 + $5Money を返す

Sum クラスを追加するためのテストを書く。とりあえずキャストなど含め書籍のコードそのままっぽく。

class MoneyTest {

    // ...

    @Test
    fun testPlusReturnsSum() {
        val five = dollar(5)
        val result = five + five
        val sum = result as Sum
        assertEquals(five, sum.augend)
        assertEquals(five, sum.addend)
    }
}

Sum を追加する。使い方がまだはっきりしないのでとりあえず普通の class で宣言しておく。

// Sum.kt
class Sum(val augend: Money, val addend: Money) : Expression

Sum を返すように plus() の実装を修正する。

data class Money(private val amount: Int, val currency: String) {

    operator fun times(multiplier: Int) = Money(amount * multiplier, currency)

    operator fun plus(addend: Money): Expression = Sum(this, addend)

    // ...
}

Sum の準備ができたので reduce()リファクタリングを始める。まずは現状の reduce() では失敗するようなテストを追加する。

class MoneyTest {

    // ...
    @Test
    fun testReduceSum() {
        val sum = Sum(dollar(3), dollar(4))
        val result = reduce(sum, "USD")
        assertEquals(dollar(7), result)
    }
}

Bank.reduce() を修正するが、記事が長くなってきたので書籍では2段階かけてるところを一気に進める。

fun reduce(source: Expression, to: String): Money {
    val sum = source as Sum
    return sum.reduce(to)
}

Sum.reduce() の追加:

class Sum(val augend: Money, val addend: Money) : Expression {

   fun reduce(to: String): Money {
        val amount = augend.amount + addend.amount
        return Money(amount, to)
    }
}

外部から参照されるようになったので Money.amountprivate を外す:

data class Money(val amount: Int, val currency: String) : Expression {

    // ...
}

ここで TODO を1つ追加:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10
  • [ ] $5 + $5Money を返す
  • [ ] Bank.reduce(Money)

この後も一気に進める。やることは Bank.reduce() 内で型キャストを行わなくて済むように、Expression インターフェイスreduce() を定義することだ。

class MoneyTest {

    // ...

    @Test
    fun testReduceMoney() {
        val result = reduce(dollar(1), "USD")
        assertEquals(dollar(1), result)
    }
}
interface Expression {

    fun reduce(to: String): Money
}
data class Money(val amount: Int, val currency: String) : Expression {

    // ...

    override fun reduce(to: String): Money = this

    // ...
}
class Sum(val augend: Money, val addend: Money) : Expression {

  override fun reduce(to: String): Money {
        val amount = augend.amount + addend.amount
        return Money(amount, to)
    }
}

これで Bank.reduce() では Expression.reduce() を呼ぶだけでよくなる。

fun reduce(source: Expression, to: String): Money = source.reduce(to)

これでこの章での実装は終わり。最後に TODO を消したり足したりしておく:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10
  • [ ] $5 + $5Money を返す
  • [x] Bank.reduce(Money)
  • [ ] Money を変換して換算を行う
  • [ ] Reduce(Bank, String)

第14章に入る前に

さて、単に写経をするだけならこれで第13章は終わりだが、一度ここで現在の実装を確認したい。

// Bank.kt
fun reduce(source: Expression, to: String): Money = source.reduce(to)
// Expression.kt
interface Expression {

    fun reduce(to: String): Money
}
// Money.kt
data class Money(val amount: Int, val currency: String) : Expression {

    operator fun times(multiplier: Int) = Money(amount * multiplier, currency)

    operator fun plus(addend: Money): Expression = Sum(this, addend)

    override fun reduce(to: String): Money = this

    companion object {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}
// Sum.kt
class Sum(val augend: Money, val addend: Money) : Expression {

  override fun reduce(to: String): Money {
        val amount = augend.amount + addend.amount
        return Money(amount, to)
    }
}

(テストコード省略)

この構成、多分 Java 的には特に違和感のない構成なのかもしれないが、私がここまで進めた時は以下のような疑問を抱いた:

  • なんで reduce() の実処理が Expression の実装にあるのか
    • Bank だけが処理の詳細を知っていればよいのでは?
    • Kotlin だったらこういう分岐って以前やった sealed class と when のパターンマッチングでいい感じに1ヶ所にまとめて表現できるよね?

ということでこのシリーズでもおなじみの Elm 版を参考に以下のように書き直した。

// Bank.kt
fun reduce(source: Expression, to: String) = when (source) {
    is Money -> source
    is Sum -> Money(sum(source.augend, source.addend, to), to)
}

private fun sum(augend: Expression, addend: Expression, to: String): Int =
            reduce(augend, to).amount + reduce(addend, to).amount
// Money.kt
data class Money(val amount: Int, val currency: String) : Expression() {

    operator fun times(multiplier: Int) = Money(amount * multiplier, currency)

    operator fun plus(addend: Money): Expression = Sum(this, addend)

    companion object {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

sealed class Expression {

    data class Sum(val augend: Money, val addend: Money) : Expression()
}

テストコードに関しては import のしかたによっては Sum を参照するところが Expression.Sum になる程度で、これはどっちで参照するようにするかはお好みなので特に変更無しということで省略。

コードだけではあんまりなので解説をすると、まず Bank.reduce() の処理の実態が MoneySum に散ってしまっているのを解決しようとして、書いてある通り Expression を sealed class としてそれのサブクラスが持つ reduce() の実装を Bank.reduce() 内で when を使ってまとめた。 これは最後に TODO に追加した Reduce(Bank, String) のように Bank をわざわざ連れまわすこともなくなるし、 "reduce" という処理を見るときにBank だけ見れば済むのでよいというところが私には嬉しい。
この辺は手続き的な分岐じゃなくて宣言的な検査だから〜など説いて書籍の実装のような操作対象のオブジェクトに振る舞いを持たせる設計と今回のパターンマッチングを用いた設計を比較してどうのこうの書こうと思ったが、今回使ったようなレベルのパターンマッチングと if 文などによる分岐の差*1とは...? となり、そもそも私がパターンマッチングについて十分な理解をしていないなあと思ったので不用意なことは書かないことにする。あえて何か書くとしたら、上で変更した後の実装の方が自分のものの整理のしかたと合ってて好ましいので可能ならばそちら側に倒す実装をするが、とはいえ Java のようにシステム上それを安全に実装することができない環境下であればそちらの流儀に合わせるだろうなあ、という所感くらい。

ところで Expression のサブクラスについて SumExpression の内側で宣言しているのは、具体的なものを表す Money と異なり Sum は抽象的な概念で Money のとは異なるレイヤーにあるというか、「式」というメタファーにおける内部的な表現のように感じられたので、その微妙な違いを表現してみたかったからで、特に何か構文上の制約を利用して何かしようとしているわけではない。

Bank.reduce()Sum に対する処理と sum() 関数については、これは Elm 版で書かれている実装をほぼそのまま持ってきている。

fun reduce(source: Expression, to: String) = when (source) {
    is Money -> source
    is Sum -> Money(sum(source.augend, source.addend, to), to)
}

private fun sum(augend: Expression, addend: Expression, to: String): Int =
            reduce(augend, to).amount + reduce(addend, to).amount

reduce()Sum 側の処理で呼んでいる sum() はその中で reduce() を呼んでおり、これが Money を返すので、返ってきた Moneyamount を足し合わせて返すことで大元の reduce() で各フィールドの amount が足し合わされた Money ができる、という再帰っぽい流れになっている。というか実際再帰になってて、先のネタバレをすると Sum のフィールドは Money 型だがこれが後に Expression 型になるので Sum を抱えた Sum が投げつけられることも起こりうるのだが、そんな時でも上の実装がそのまま使えるようになっている*2。また sum() については private な package-level function なので宣言された Bank.kt の中でしか参照することができないようになっており、内部詳細に当たる箇所もきちんと隠すことができている。

この修正により1つやらなくて良くなった項目があるので TODO リストを更新する:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10
  • [ ] $5 + $5Money を返す
  • [x] Bank.reduce(Money)
  • [ ] Money を変換して換算を行う
  • [x] Reduce(Bank, String)

ここまでのまとめ

13章にきてあえて書籍の Java コードとは異なる設計 (というか表現?) で実装を書くようにした。これによってこの先の変更の難易度はどのように異なってくるのかや、この設計のメリットやデメリットについて見ていくようにしたい。また12章では Bank についてクラスを作らず package-level function のみで同等のものを実装したが、これは状態を持つ必要のない機能群についてはあえて先にクラスという枠を作らずに関数の単位でポコポコ作っていって、あとで意味のあるかたまりとかでまとめておくみたいなボトムアップ的アプローチができるのかなーという風に感じた。実際どうなのだろうか。

続き:

nashcft.hatenablog.com

*1:今回のように sealed class と when の組み合わせでは考慮すべき場合を型レベルで狭められるという利点はあるが

*2:と言ってもここは書籍の実装も振る舞い的には変わらないのでアドバンテージとして見られるかというとそうではない