LoginSignup
6
3

More than 1 year has passed since last update.

[Kotlin] invoke演算子と多重定義、ラムダ式、関数オブジェクト

Last updated at Posted at 2021-09-22

Kotlinにおいて、invoke演算子 (invoke operator) とは、関数オブジェクトの本体部分です。
Kotlinプログラマーが扱う関数オブジェクトは大抵ラムダ式で表されていて、invoke演算子は関数オブジェクトを引数にとる関数を書いたときに使う以外ほとんどお目にかからないと思いますが、本稿では他の記事との絡みもあって、演算子多重定義などのニッチな話題を書いてみます。

まずはとりあえず、関数オブジェクトとラムダ式について、馴染みのない方向けに説明します。今回のコードを検証したKotlinのバージョンは1.5.31です。

関数オブジェクトとラムダ式

「ラムダ式」とは、簡潔な書式で記述された「関数オブジェクト」です。「関数オブジェクト」は、その名の通り関数の機能を持つオブジェクトです。すでにラムダ式に慣れている方は、読み飛ばして2つ先のinvoke演算子に行っていただいて結構です。

以下のコードにて { から } までの部分がラムダ式です。このように他の関数の引数として記述されるのが一般的です。

printResults(5.0, 3.0) { n, x, y -> (x.pow(n) + y.pow(n)).pow(1 / n.toDouble()) }

ラムダ式を引数として受け取る側の関数は、たとえば以下のように書かれます。

fun printResults(xMax: Double, yMax: Double, norm: (Int, Double, Double) -> Double) {
    val y = 0.1
    while (y < yMax) {
        val x = 0.1
        while (x < xMax) {
            println("norm($x, $y) = ${norm(2, x, y)}")
        }
    }
}

関数 printResults は3つの引数を受け取ります。ここで、ラムダ式も引数なので、

printResults(5.0, 3.0, { n, x, y -> (x.pow(n) + y.pow(n)).pow(1 / n.toDouble()) })

() の中に収めなければならないように思われますが、Kotlinでは関数の最後の引数がラムダ式だった場合に限って () の外に記述することが文法上認められています。そして、最後の引数のラムダ式はむしろ () の外に記述することが推奨されています。また、関数の引数がラムダ式1つだけの場合、以下のように () を省略した記法が可能です。

setListener { number -> print(number) } // { print(it) }
(0..5).forEach { print(it) } // 'argument ->' omitted

ラムダ式の中の引数が1つのとき、 argument -> の部分を省略することができ、その場合には it が引数変数となります。

上のコードの forEach は組み込み関数で、 Iterable 型のインスタンスをreceiverとする拡張メンバ関数として定義されています。Kotlinには、 forEach のような組み込み関数が多数用意されています。

ラムダ式の引数の型

ラムダ式を引数として受け取る側の関数において、その引数が

norm: (Int, Double, Double) -> Double

と定義されていることにより、ラムダ式の引数の型が (Int, Double, Double) であること、戻り値の型が Double であることが決まります。この組み合わせにより、ラムダ式は以下で表せる関数を関数オブジェクト化したのと同じものになります。

fun anonymousFunction(n: Int, x: Double, y: Double) {
    return (x.pow(n) + y.pow(n)).pow(1 / n.toDouble())
}

引数として渡すだけでなく、以下のように書いて関数オブジェクトをインスタンス化して使うこともできます。

val functionObject: (Int, Double, Double) -> Double =
    { n, x, y -> (x.pow(n) + y.pow(n)).pow(1 / n.toDouble()) })
val distance = functionObject(2, 1.5, 2.5)

ただし、以下のように書くことはできません。この書き方ではラムダ式の引数の型が決まらないためです。

{ n, x, y -> (x.pow(n) + y.pow(n)).pow(1 / n.toDouble()) }(2, 1.5, 2.5) // compile error!!

以下のようにしてラムダ式の引数の型を指定することは可能ですが、

val distance = { n: Int, x: Double, y: Double ->
    (x.pow(n) + y.pow(n)).pow(1 / n.toDouble()) }(2, 1.5, 2.5) // OK! But...

ラムダ式を即値として使うよりも以下のように書くほうが簡単なので、通常は即値としてラムダ式を使う必要はなく、ラムダ式内で引数の型の指定が必須になることもあまりないでしょう。

val distance = (1.5.pow(2) + 2.5.pow(2)).pow(1 / 2.0)

invoke演算子

ラムダ式で作った関数オブジェクトは、以下のように使えます(再掲)。

val functionObject: (Int, Double, Double) ->
    Double = { n, x, y -> (x.pow(n) + y.pow(n)).pow(1 / n.toDouble()) })
val distance = functionObject(2, 1.5, 2.5)

しかし「関数オブジェクト」といえども functionObject はインスタンスなのになぜ関数っぽく functionObject(2, 1.5, 2.5) と書けるのでしょうか? それは、引数列を囲っている () が、独自の計算をする演算子として暗黙のうちに定義されているからです。

() 演算子は、以下のようにして invoke という別名を使って、名前のあるクラスに定義を与えることもできます。ラムダ式は、invoke演算子が以下のように定義されたクラスを無名化したもの、ともいえます。

public operator fun invoke(n: Int, x: Double, y: Double): Double {
    return (x.pow(n) + y.pow(n)).pow(1 / n.toDouble())
}

演算子の多重定義

invoke演算子と似た例としてget演算子があります。 List, Map などのKotlinの組み込みコレクションクラスが

val list = listOf("Rock", "Paper", "Scissors")
val listItem = list[1]
val map = mapOf("one" to 1, "two" to 2, "three", to 3)
val mappedValue = map["two"]

[] でくくった引数で指し示された値を返すことができるのは、以下のような書式でget演算子が定義されているためです( get[] の別名)。

Collection.kt
// List<out E> // E is a generic type of List
public operator fun get(index: Int): E

invoke演算子やget演算子の定義は、対象のクラス各々に使われる ()[] の演算子にそれぞれ個別の定義を与えています。これを演算子の多重定義 (Operator overloading) と呼びます。

() 演算子や [] 演算子は、以下のように別名を使って記述することもできます。

val distance = functionObject.invoke(2, 1.5, 2.5)

val list = listOf("Rock", "Paper", "Scissors")
val listItem = list.get(1)
val map = mapOf("one" to 1, "two" to 2, "three", to 3)
val mappedValue = map.get("two")

Kotlinにおいて、関数の機能を持つオブジェクト、という関数オブジェクトの定義をより具体的に表すと、「invoke演算子が実装されているオブジェクト」ということになります。invoke演算子の実装法には、ラムダ式を使って暗黙的に実装する方法と、 () 演算子を多重定義する書式を使って明示的に実装する方法の2通りがある、ということです。

名前つき関数オブジェクトクラス

上のコードで示した、ラムダ式を引数として受け取る側の関数(再掲)は、以下のように、(Int, Double, Double) -> Double という型の関数オブジェクトを引数で受け取ります。

fun printResults(xMax: Double, yMax: Double, norm: (Int, Double, Double) -> Double) {
    // ...
}

これは、 norm が演算子 invoke(Int, Double, Double) -> Double の実装を備えていることを示しています。

そして、invoke演算子はラムダ式でなくても、名前のあるクラスや、スーパークラスを継承した無名クラスに前述の演算子多重定義構文を使って実装できます。これを使って、大抵のインスタンスは関数オブジェクトになる(関数オブジェクトの機能を兼ねる)ことができます。

FunctionClass.kt
class FunctionClass(private val n: Int) {
    operator fun invoke(x: Double, y: Double): Double =
        (x.pow(n) + y.pow(n)).pow(1 / n.toDouble())
}

以下のように使えます。

val functionObject = FunctionClass(2)
val distance = functionObject(1.5, 2.5)

ただし、この関数オブジェクトを他の関数の呼び出しに使おうとするときには注意が必要です。
ラムダ式を引数として受け取る側の関数

fun printResults(xMax: Double, yMax: Double, norm: (Double, Double) -> Double) {
    // ...
}

これを以下のように呼び出そうとすると、コンパイルエラーになります。

val printResults(5.0, 3.0, functionObject)

FunctionClass を直接使っている場合には operator fun invoke(x: Double, y: Double): Double にアクセスできても、関数の引数に使うと、この場合には FunctionClass(Double, Double) -> Double 型でないとみなされ、コンパイルできません。

この用途で使えるようにするには、 FunctionClass(Double, Double) -> Double 型であることを定義で明示する必要があります。

FunctionClass.kt
class FunctionClass(private val n: Int) : (Double, Double) -> Double {
    override fun invoke(x: Double, y: Double): Double =
        (x.pow(n) + y.pow(n)).pow(1 / n.toDouble())
}

(Double, Double) -> Double を継承していることから、 operator fun invoke(x: Double, y: Double): Double の定義がすでに存在しているため、 operator fun invoke(x: Double, y: Double): Double の実装に override 修飾子が必要になっています。その代わり、 override operator fun で演算子多重定義を記述するときは、 operator を省略して override fun と書けます。

このようにして既存クラスに関数オブジェクトの機能を兼ねさせることもできますし、以下のように拡張関数、もとい拡張演算子を既存のクラス(finalクラスであっても)に付け加えることもできます。

operator fun String.invoke(): String {
    return "$this.invoke()"
}

val invokedString = "String"()()()

コーディングルールとinvoke演算子

関数オブジェクト functionObject を使うとき、以下ではインスタンスでなく関数と見間違えるかもしれないし、

functionObject(1, '2', "3")

以下だと今度は普通のインスタンス関数みたい。

functionObject.invoke(1, '2', "3")

コーディングルールを厳格に定めることに慣れている開発者や開発チームは、面倒でも関数オブジェクトの呼び出し書式もあらかじめ決めておくと無難かもしれません。invoke演算子の主旨を考えると上の文法を使うべきかもしれませんが、上下を適宜使い分けてもよい、と筆者は思います。ただし、使い分け方は明確になっているほうがよいでしょう。

演算子多重定義の文法

以下のように、複数の関数オブジェクト型をクラスに与えることは(現状では)できないようです。

class FunctionClass(private val n: Int) : (Double, Double) -> Double, () -> String {
    // compile error!!
}

複数の型を与えなければ、以下のように引数の異なる複数のinvoke演算子を定義することは可能のようです

class FunctionClass(private val n: Int) : (Double, Double) -> Double {
    override fun invoke(x: Double, y: Double): Double =
        (x.pow(n) + y.pow(n)).pow(1 / n.toDouble())

    operator fun invoke(): String = "$this.invoke()"
}

このあたりの文法はかなり複雑のようで、ちょっとした変化でコンパイルできたりできなかったりと変わります。複数の関数定義をもつ関数オブジェクト、なるものが欲しくなるケースは滅多にないでしょうし、本稿でもこれ以上の深入りは避けます。きわどい書き方もほどほどに。最後は重箱の隅つつきになってしまいました。

偽コンストラクタ?

正直なところ、筆者も () 演算子の多重定義を使いたくなるケースは限られていると思います。

しかし最近になって、別のテーマで記事を書いていて、演算子多重定義の思いもよらない使い方があることを知りまして、しかもそのテーマの記事を書いていたらとてつもなく長大になってしまったため、invoke演算子の記事をこちらに切り出しました。本稿だけですでに長いですね(汗)。ここまでお付き合いいただき、ありがとうございました。

関連記事

参考文献

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3