まずは関数とcontextについて

数多のプログラミング言語でもよく扱われるcontextですが、そもそも関数のcontextとは何でしょうか?contextを語るには、まず関数の仕組みを理解する必要があります。

関数(メソッド)は、数学から来る考え方で、入力に対して処理を施し出力するプロセスの一種です。 contextは、関数が実行される際においての環境であり、前提条件などが含まれるものであり、関数の入力の一つと考えてもいいでしょう。 パラメータとの違いは、パラメータは可変な入力に対して、同一関数においてのcontextは一定の不変な入力となります。また、contextは関数の実行には常に存在し、明示的に指定しなくても利用できる暗黙的な入力です。

オブジェクト指向言語では、馴染みあるクラス関数というものがあります。 クラスに属する関数であり、実行にはクラスのインスタンスから行わなければなりません。 例えば、次のような定義があるとします。

class Hoge {
    fun test(input: String) {
        println("Hello from $input")
    }
}

通常であれば、次のように関数を呼び出します。

val hoge = Hoge()
hoge.test("main")

しかし、reflectionでクラス関数を実行する時には、このようになります。

val hoge = Hoge()
val method = runCatching { Hoge::class.members.find { it.name == "test" } }.getOrNull()
method?.call(hoge, "main")

気づきましたでしょうか?reflectionでクラス関数を呼び出すには、クラスのインスタンスがパラメータとして渡す必要がある。 これこそがcontextの具体的な例です。 クラス関数は、クラスインスタンスそのものがcontextであり、通常の呼び出しでは暗黙的に関数で入力として渡され、 this キーワードでアクセスできます。 そのため、reflectionでの呼び出しが最も顕著に表れるため、例として取り上げました。言い換えれば、上記のクラスは次のように書き直すことができます。

class Hoge

fun test(context: Hoge /* ここが暗黙的に渡されるcontext parameter */, input: String) {
    val this = context /* ここで暗黙的に指定されるcontextを表す変数が定義される */

    println("Hello from $input")
}

実際にKotlinコンパイラがやっていることもこれに近く、拡張メソッドの変換は全てstaticメソッドとして書き直していることが有名の例でしょう。

この概念を理解すると、Kotlinや他の言語で使えるクラス拡張の概念への理解にもつながるため、覚えておきましょう! これで関数、そして関数においてのcontextのことがわかるようになると思いますので、本題へ行きましょう!

Kotlinでの新機能:context receiverについて学ぼう

前述で、contextについて理解できたので本題について考えます。
リポジトリのメソッドで、以下の条件があるとします。

  • DBからデータを取得する処理がある
  • APIへの通信処理もある
  • 実行環境がデバッグかどうかによって通信先が変わる

最も単純なやり方は、以下のような実装になると思います。

class Repository(
    private val api: Api,
    private val environment: Environment,
) {
    suspend fun readDatabaseAndSendToServer(
        db: Database,
        input: DatabaseItem,
    ) {
        val localData = db.readLocalData()

        val isDebug = environment.isDebug
        val comparisonResult = localData.compareWithInput(input)
        api.sendDataToServer(isDebug, comparisonResult)
    }
}

では、今度はサードパーティのクラスに対して処理を実行したいとしましょう。 例えば、通信結果によって、デバッグログで通信結果を記録する場合を考えます。 しかし、ここではカスタムロガーを利用するため、AndroidのLogは使えません。 そうすると、ロガーのインスタンスを渡す必要があります。

...
    suspend fun readDatabaseAndSendToServer(
        db: Database,
        input: DatabaseItem,
        logger: Logger,
    ) {
        ...
        val apiResult = api.sendDataToServer(isDebug, comparisonResult)
        logger.debug("Repository result: $apiResult")
    }

これでも十分ですが、もしかしたらリポジトリの他のメソッドにもロガーを利用する必要があります。 その場合、ロガー自体をクラスのパラメータとして昇格させることができます。

class Repository(
    private val api: Api,
    private val environment: Environment,
    private val logger: Logger,
) {
    suspend fun readDatabaseAndSendToServer(
        db: Database,
        input: DatabaseItem,
    ) {
        val localData = db.readLocalData()

        val isDebug = environment.isDebug
        val comparisonResult = localData.compareWithInput(input)
        val apiResult = api.sendDataToServer(isDebug, comparisonResult)
        logger.debug("Repository result: $apiResult")
    }
}

これでは最低限で動くコードが実装できるでしょう。

しかし、よく考えてみてください。

クラスのコンストラクターのパラメータとして定義されるということは、明示的な依存関係を示すことになり、このリポジトリを動かすのにロガーのインスタンスが必要であると認識すると思います。しかし、クラスの設計上、機能的に必要であっても明示的に依存することはないのがほとんどの場合です。 そのため、staticのロガーが利用しやすい理由のほとんどがそうであるように、クラスが明示的にロガーに依存することは望ましくありません。

ではどうしましょうか?

ここでcontext receiverの出番です!

このリポジトリクラスを、次のように書き換えます。

class Repository(
    private val api: Api,
    private val environment: Environment,
) {
    context(Logger)
    suspend fun readDatabaseAndSendToServer(
        db: Database,
        input: DatabaseItem,
    ) {
        val localData = db.readLocalData()

        val isDebug = environment.isDebug
        val comparisonResult = localData.compareWithInput(input)
        val apiResult = api.sendDataToServer(isDebug, comparisonResult)
        debug("Repository result: $apiResult")
    }
}

違いに気づきましたか?
そう、ロガーをcontext receiverに指定することで、クラスやメソッドでの明示的なパラメータとして無くしました!

呼び出し場所では、以下のように利用することができます。

...
val repo = Repository(api, environment)
// context receiver 利用なし
repo.readDatabaseAndSendToServer(db, input, logger)
// context receiver 利用あり
with(logger) {
    repo.readDatabaseAndSendToServer(db, input)
}

ちょっと面白くないですか? context receiverを利用する方が、呼び出し時のパラメータが少なく済みますが、何か囲まないといけない状態になります。なんですかこれ、と思う人もいるでしょう。 これが、context receiverを利用する際に発生するcontextの引き渡し方です。 Kotlinではscope関数がいくつかありますが、 with を利用するのが一般的ですね。 これは、Kotlinのcoroutineなど、ファーストパーティライブラリにもよく利用されるパターンでもあります。

このように、特定なcontextを渡したいが、パラメータとして明示的に渡すには依存関係が不透明になりかねない、クラスデザイン上ではあまり望ましくない状況を回避しつつ実現する方法として context receiver が使えます。

他の利用場面として、特定の環境や場所でのみ実行させたい場合も考えられます。 context receiverはcompilerのlinter対象になりますので、指定されるcontextが利用箇所で検出できない場合は警告として出てきて、コンパイル時エラーとしても出てきます。 例えば、上記のメソッドをActivityやFragmentではなく、ViewModelからのみ実行させたい場合は、以下の変更をすることだけで十分でしょう。

    context(Logger, ViewModel)
    suspend fun readDatabaseAndSendToServer(
        db: Database,
        input: DatabaseItem,
    ) {
        ...
    }

context receiverの定義には配列が指定できるため、複数のクラスを指定できます。 メソッド内に利用されなくても、利用箇所の制約を設けるために利用することができます。 ただし、乱用にするとメソッドが実行できなくなるか、逆に見づらくなる可能性があるため、利用するにはきちんと関係性と必要性を鑑みて活用しましょう。

メリット・デメリット:利用するにあたって知って欲しいこと

前述した通り、さまざまな場面で利用できるcontext receiverですが、もちろんメリット・デメリットも存在します。

メリット

  • メソッドの環境の拡張
    それぞれのメソッドの定義上、所属するクラスの一員として機能する反面、クラスのコンテキストだけでは不足する場合はほとんどパラメータで賄うことが多いでしょう。 しかし、上述した通り、それだけでも足りない、もしくはパラメータとして定義するにはメソッドの役割を不明瞭にする可能性がある場面では、何かの拡張関数として定義するのは第一歩として利用することができると思います。 それでも、拡張関数にしても足りない、もしくは拡張関数にすることで意図しない定義になりかねない、もしくは拡張させるクラスから見て無意味な拡張になりかねない場合はcontext receiverを利用することでこれらの悩みを解消できます。
  • コードの正確性・可読性の向上
    前述したポイントにも通ずるものがありますが、コードの意図が曖昧にならないようにする方法の一つとして用いることができます。 それぞれのコードは、誰から見ても同じ意味として解釈できるようなコードになればなるほど、理想的と言われます。 完璧な世界では、コード自体がドキュメントになって、プログラミング知識なしでも理解できるほどの可読性があるものを目指し、最近のプログライング言語は日々進化します。 context receiverもその一つの方法として新たに用意されましたので、うまく活用することができれば理想なコードに近づけられると思います。
  • 関数型プログラミング的な構造の実現
    Kotlinはオブジェクト指向型プログラミング言語ですが、マルチパラダイムであり、その一つの側面は関数型プログラミングです。 関数型から見たら、context receiverはより関数型的な考えが実現しやすく、関数の役割の一つである、可変な入力に対して一定の値は必ず同じ値を出力する仕組みがより明示的に実現可能になります。

デメリット

  • まだ試験的な機能
    コンパイラオプションを明示的に指定しない限り、利用できない機能でもあります。 ですが、安定性には問題ないと見なしても良いでしょう。 なぜかというと、Javaのバイトコードに変換される際、拡張関数と同じような仕組みをとっているためほとんど拡張関数の挙動に準ずることになり、安定性が保証されやすいです。
  • 事前知識なしではわかりにくい可能性あり
    拡張関数の仕組みへの理解が曖昧、もしくはない場合は、拡張関数を拡張するような概念であるcontext receiverへの理解も難しくなり、うまく活用できない可能性があります。

これだけ抑えれば大丈夫!よく利用するパターン紹介

メリット・デメリットを知った上で、なお採用するには、具体的にどのような場面が考えられるか、例を交えて説明します。

依存関係の定義を緩める

前述にも触れましたが、入力パラメータとして引き渡すのに対して、context receiverとして渡すことでメソッドの実行に関わるが比較的な不変性が保証されるパラメータとしての立ち位置を確保できると考えられます。 次のような簡易的なリポジトリクラスがあるとします。 それぞれのメソッドにログを追加したいが、パラメータとしてロガーをつけるとメソッドの意図がぼやけますし、かといってコンストラクタパラメータとして追加しても、全てのメソッドにロガーを利用したいわけでもありません。 そのため、context receiverを活用します。

利用する際、以下のように呼び出せます。

簡易的なDI

ほとんどのプロダクション向けコードだと、ライブラリを利用するDIが標準だと思います。 だが、簡易的かつ擬似的なDIの挙動はcontext receiverで実現することができます。 同じリポジトリクラスで、今度はコンストラクタパラメータではなく、context receiverで定義を変更すると次のようになります。

今度は、次のようにメソッドを呼び出すことができます。

利用箇所を制限する

前述にも少し触れましたが、クラスメソッドでも利用箇所を制限するためにcontext receiverを定義することができます。 同じリポジトリクラスで、今度はViewModelクラスからのみ利用できるようにしたいと考えています。 その場合、context receiverにViewModelを追加することで対応できます。 定義されるcontextが全てが全てでメソッドを利用するためだけに定義する必要はないことがここでわかります。 ただし、定義が増えれば増えるほどメソッドの呼び出しに制約が増えているため乱用は禁物です。

今度は、上記のメソッドを利用する際、ViewModelクラスから呼び出す必要があるので、その通りで行います。

上記の通り、これでメソッドが呼び出すことができ、コンパイラエラーも発生しません。 逆に、ViewModelではない箇所から呼び出そうとすると、linterのエラーもそうですが、コンパイラエラーが発生しますので意図しない挙動を防ぐことができると考えます。

将来性:それはすなわち、未来のこと

さて、ここまではいくつか紹介してきた context receiver ですが、デメリットにも記述した通り、現在はまだ試験的な機能であり、さらに、すべてが暗黙的なパラメータとなることで、thisの重複により目的のメソッドが同じ名前で複数のcontextに定義されている場合、正しいcontextで実行されるかが曖昧になりがちといった難点も残ります。ただし、それらすべては現在進行形ですでにJetbrains社でも承知され、チケットとして管理される中で、後継機能として context parameters という機能が開発中です。

context parametersはcontext receiversとほとんど変わらない側面もありますが、最も大きい変更点は、各receiverに対してパラメータ名を定義でき、メソッド内に通常のパラメータとして利用できるようになります。そのため、従来の曖昧さが解決できると同時に、メリットを維持したまま非常に強力な機能へと進化することが期待されます。

Kotlinのこれからに乞うご期待!



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる