- Kotlin
Kotlin拡張関数の使い方|拡張プロパティとメンバ関数優先の注意点
Kotlinの拡張関数とは、既存のクラスを書き換えずに、そのクラスのメソッドのように呼べる関数を外側から追加する仕組みです。 fun String.addBrackets() のように「型名.関数名」で宣言するだけで、"Kotlin".addBrackets() と呼べるようになります。String や List のように自分で編集できないクラスにも使えるのが大きな魅力です。
この記事では、基本の書き方に加えて、同名のメンバ関数があると拡張関数が呼ばれない例、親クラス型の変数に入れると子クラス用の拡張が選ばれない例(静的解決)、拡張プロパティが値を持てない理由を、コンパイラが出す警告文・エラー文と実行結果つきで順に確かめます。後半には、そのまま貼って使える拡張関数10個、Java からの呼び方、置き場所と import の決め方を表にまとめました。
確認環境:Ubuntu 24.04 / Kotlin 2.4.20(コマンドラインコンパイラ kotlinc)/ OpenJDK 21.0.10。掲載したコードはすべて kotlinc ファイル名.kt -include-runtime -d ファイル名.jar でコンパイルし、java -jar ファイル名.jar で実行した結果です。
Kotlin拡張関数の早見表
| やりたいこと | 書き方 | ポイント |
|---|---|---|
| 既存の型に関数を足す | fun String.addBrackets(): String = "[$this]" |
this が呼び出し元の値(レシーバ) |
| 既存の型にプロパティを足す | val String.lastChar: Char get() = this[length - 1] |
getter が必須。初期値は書けない |
| null でも呼べるようにする | fun String?.orDash(): String = this ?: "-" |
型に ? を付けると ?. なしで呼べる |
| どの型の List にも足す | fun <T> List<T>.second(): T? = getOrNull(1) |
<T> は fun の直後に書く |
| クラス名から呼べる関数を足す | fun Member.Companion.fromCsv(line: String): Member |
クラス側に companion object が必要 |
| 別パッケージの拡張を使う | import com.example.text.mask |
関数名で import する |
| 同名の拡張を使い分ける | import com.example.log.mask as maskAll |
as で別名を付ける |
| Java から呼ぶ | StringExtKt.addBrackets("Java") |
ファイル名+Kt のクラスの static メソッド |
| 注意点 | 起きること | 対応 |
|---|---|---|
| 同名・同じ引数のメンバ関数がある | メンバ関数が必ず選ばれる(警告が出る) | 拡張側の名前を変える |
| 親クラス型の変数から呼ぶ | 中身が子クラスでも親クラス用の拡張が選ばれる | when と is で型を判定する/メンバ関数にする |
| 拡張プロパティに初期値を書く | コンパイルエラー | getter で計算する/外側の Map に預ける |
| private なメンバを使う | コンパイルエラー | 公開されているメンバだけで組み立てる |
| import を書き忘れる | unresolved reference | 関数名で import する |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Kotlin拡張関数の使い方|拡張プロパティとメンバ関数優先の注意点」
拡張関数の基本の書き方
拡張関数は、関数名の前に「追加したい型」とドットを書きます。この型をレシーバ型、関数の中で this として使える呼び出し元の値をレシーバと呼びます。
// basic.kt
fun String.addBrackets(): String = "[$this]"
fun Int.isEven(): Boolean = this % 2 == 0
fun main() {
println("Kotlin".addBrackets())
println(4.isEven())
println(7.isEven())
}
実行結果:
[Kotlin]
true
false
コンパイルと実行は次の2行です。
kotlinc basic.kt -include-runtime -d basic.jar
java -jar basic.jar
日本語を出力したときに ? と表示される端末では、java -Dstdout.encoding=UTF-8 -jar basic.jar のように出力の文字コードを指定すると、そのまま読める表示になります。
拡張関数の中で使えるもの
| 項目 | 使えるか | 補足 |
|---|---|---|
| レシーバの public なメンバ | 使える | this.length の this. は省略できる |
| レシーバの private / protected なメンバ | 使えない | クラスの外に書いた関数と同じ扱い |
| 同じファイルの private なトップレベル宣言 | 使える | 拡張関数自身がトップレベル関数のため |
| 拡張関数自身の可視性 | private internal public を付けられる |
通常の関数と同じ |
private なメンバに触れようとすると、次のエラーでコンパイルが止まります。
// private_ng.kt
class Account(private val secret: String) {
fun secretLength(): Int = secret.length
}
fun Account.peek(): String = secret
fun main() {}
private_ng.kt:6:30: error: cannot access 'val secret: String': it is private in 'Account'.
fun Account.peek(): String = secret
^^^^^^
拡張関数はクラスの中身を変えているわけではなく、「レシーバを受け取る普通の関数」を呼びやすい形にしたものと考えると、この制限が自然に理解できます。公開されている secretLength() を使えば、同じ目的を達成できます。
同名のメンバ関数があると拡張関数は呼ばれない
同じ名前・同じ引数のメンバ関数と拡張関数があるとき、呼ばれるのは必ずメンバ関数です。 コンパイルは通るため、警告を見逃すと「書いたはずの拡張が動かない」と感じる場面になります。
// shadow.kt
class Greeter {
fun hello() = println("メンバ関数の hello")
}
// 同じ名前・同じ引数の拡張関数(呼ばれない)
fun Greeter.hello() = println("拡張関数の hello")
// 引数が違えば別の関数として呼べる
fun Greeter.hello(name: String) = println("拡張関数の hello: $name")
fun main() {
Greeter().hello()
Greeter().hello("Taro")
}
コンパイル時の警告(Kotlin 2.4.20 の表示そのまま):
shadow.kt:7:13: warning: this extension is shadowed by a member: 'fun hello(): Unit' defined in 'Greeter'.
fun Greeter.hello() = println("拡張関数の hello")
^^^^^
実行結果:
メンバ関数の hello
拡張関数の hello: Taro
1行目はメンバ関数、2行目は引数つきの拡張関数が呼ばれました。ポイントを表にまとめます。
| 状況 | 呼ばれるもの | コンパイラの反応 |
|---|---|---|
| 名前も引数も同じメンバ関数がある | メンバ関数 | 警告 this extension is shadowed by a member |
| 名前は同じで引数が違う | 引数に合うほう(拡張も呼べる) | 警告なし |
| メンバ関数がない | 拡張関数 | 警告なし |
ライブラリの更新で入れ替わる場面
メンバ優先のルールは、ライブラリを更新したときにも関わります。自分で fun Foo.bar() という拡張を用意していて、あとからライブラリ側の Foo に同じ bar() がメンバとして追加されると、その時点からメンバ側が呼ばれるようになります。コンパイルは通り、上の警告が出るだけなので、ビルドログの warning に目を通す習慣が役立ちます。
対応はシンプルで、拡張関数の名前を変えるか、ライブラリ側のメンバで足りるなら拡張を削除します。名前を付ける段階で、toDisplayText() のように用途が伝わる具体的な名前にしておくと、重なる可能性が下がります。
親クラス型の変数では子クラス用の拡張が選ばれない(静的解決)
メンバ関数は「変数に入っている中身の型」で呼び先が決まります(オーバーライド)。一方、拡張関数は「変数の型」=コンパイル時に見えている型で呼び先が決まります。 これを静的解決と呼びます。
// static.kt
open class Shape
class Circle : Shape()
fun Shape.label() = "Shape 用の拡張"
fun Circle.label() = "Circle 用の拡張"
open class Animal {
open fun name() = "Animal のメンバ"
}
class Dog : Animal() {
override fun name() = "Dog のメンバ"
}
fun describe(s: Shape) = s.label()
fun main() {
val c = Circle()
val s: Shape = c // 中身は Circle、変数の型は Shape
println(c.label()) // 型 Circle として呼ぶ
println(s.label()) // 型 Shape として呼ぶ
println(describe(c)) // 引数の型は Shape
val a: Animal = Dog()
println(a.name()) // メンバ関数は中身の型で決まる
// 中身の型で分けたいときは when で判定する
val result = when (s) {
is Circle -> s.label() // ここでは s が Circle として扱われる
else -> s.label()
}
println(result)
}
実行結果:
Circle 用の拡張
Shape 用の拡張
Shape 用の拡張
Dog のメンバ
Circle 用の拡張
2行目と3行目に注目してください。中身は Circle でも、変数や引数の型が Shape なので Shape 用の拡張が呼ばれています。4行目のメンバ関数は中身の型(Dog)で決まっており、ここが拡張関数との違いです。
| 比べる点 | メンバ関数 | 拡張関数 |
|---|---|---|
| 呼び先が決まるタイミング | 実行時 | コンパイル時 |
| 何で決まるか | 中身(インスタンス)の型 | 変数・引数に書かれた型 |
| override | できる | できない(別の関数が増えるだけ) |
| 子クラスごとに動きを変えたい | 向いている | when と is で判定を足す |
中身の型で動きを変えたいときの書き方
| 方法 | 書き方 | 向いている場面 |
|---|---|---|
when と is で判定 |
when (s) { is Circle -> s.label() ... } |
クラスを編集できない/分岐が少ない |
| メンバ関数にする | open fun label() を親に、override を子に |
自分のクラスで、種類が増えていく |
| 拡張は親クラス用の1本だけにする | fun Shape.label() の中で when (this) |
呼び出し側を単純に保ちたい |
上のコードの5行目は is Circle の分岐の中で s が Circle として扱われる(スマートキャスト)ため、Circle 用の拡張が選ばれています。「拡張関数はオーバーライドの代わりにはならない」と覚えておくと、設計で迷いにくくなります。
拡張プロパティの書き方と、値を持てない理由
拡張はプロパティにも使えます。ただし、拡張プロパティは値を保存する場所(バッキングフィールド)を持てません。 クラスの中身を変えていないので、インスタンスの中に新しい入れ物を増やせないためです。
初期値を書くとエラーになる
// prop_ng.kt
class User(val name: String)
var User.nickname: String = "none"
fun main() {
println(User("Taro").nickname)
}
prop_ng.kt:4:29: error: extension property cannot be initialized because it has no backing field.
var User.nickname: String = "none"
^^^^^^
getter / setter の中で field を使おうとしても、同じ理由で通りません。
// prop_ng2.kt
class User(val name: String)
var User.nickname: String
get() = field
set(value) { field = value }
fun main() {}
prop_ng2.kt:5:13: error: unresolved reference 'field'.
get() = field
^^^^^
prop_ng2.kt:6:18: error: unresolved reference 'field'.
set(value) { field = value }
^^^^^
動く書き方3パターン
| パターン | 書き方 | 使いどころ |
|---|---|---|
| 計算して返す(読み取り専用) | val 型.名前: 型 get() = ... |
もっともよく使う形。既存の値から求める |
| レシーバの既存の状態を読み書きする | var に getter と setter を書く |
StringBuilder など変更できる型 |
| 外側の Map に預ける | WeakHashMap などに保存 |
どうしても値を持たせたいとき |
// prop.kt
import java.util.WeakHashMap
class User(val firstName: String, val lastName: String)
// 1. 計算で返す拡張プロパティ(読み取り専用)
val User.fullName: String
get() = "$lastName $firstName"
val String.lastChar: Char
get() = this[length - 1]
val <T> List<T>.lastIndexOrNull: Int?
get() = if (isEmpty()) null else size - 1
// 2. 値を持たせたいときは、外側の Map に預ける
private val nicknames = WeakHashMap<User, String>()
var User.nickname: String
get() = nicknames[this] ?: "(未設定)"
set(value) { nicknames[this] = value }
// 3. StringBuilder の最後の文字を読み書きする
var StringBuilder.lastChar: Char
get() = this[length - 1]
set(value) { setCharAt(length - 1, value) }
fun main() {
val u = User("Taro", "Yamada")
println(u.fullName)
println("Kotlin".lastChar)
println(listOf(10, 20, 30).lastIndexOrNull)
println(emptyList<Int>().lastIndexOrNull)
println(u.nickname)
u.nickname = "たろちゃん"
println(u.nickname)
val sb = StringBuilder("Kotlin?")
sb.lastChar = '!'
println(sb)
}
実行結果:
Yamada Taro
n
2
null
(未設定)
たろちゃん
Kotlin!
WeakHashMap を使うと、キーにした User がどこからも参照されなくなったときに、Map の中の項目も片付けの対象になります。ただし、値を持たせたい場面が増えてきたら、クラスを包む新しいクラス(ラッパー)を作るほうが見通しがよくなります。拡張プロパティは「既存の値から計算して返す」用途にとどめると扱いやすい道具です。
| 関数にするかプロパティにするか | 目安 |
|---|---|
| 引数がなく、呼ぶたびに同じ結果で、処理が軽い | 拡張プロパティ(user.fullName) |
| 引数がある/処理が重い/呼ぶたびに結果が変わる | 拡張関数(text.truncate(8)) |
null許容レシーバ:null でもそのまま呼べる拡張
レシーバ型に ? を付けると、値が null でも ?. なしで呼べる拡張になります。関数の中では this が null かもしれないので、?: や if で扱いを決めます。
// nullable.kt
fun String?.orDash(): String = this ?: "-"
fun String?.isFilled(): Boolean = this != null && this.isNotBlank()
fun <T> List<T>?.sizeOrZero(): Int = this?.size ?: 0
fun String.shout(): String = uppercase() + "!"
fun main() {
val name: String? = null
val city: String? = "Tokyo"
println(name.orDash()) // ?. なしで呼べる
println(city.orDash())
println(name.isFilled())
println(city.isFilled())
val items: List<Int>? = null
println(items.sizeOrZero())
// 標準ライブラリの null許容レシーバ
println(name.orEmpty().length)
println(name.isNullOrBlank())
println(name.toString())
// null を受け取らない拡張は ?. で呼ぶ
println(name?.shout())
println(city?.shout())
}
実行結果:
-
Tokyo
false
true
0
0
true
null
null
TOKYO!
標準ライブラリの orEmpty()、isNullOrBlank()、toString() も同じ仕組みで、null の変数にそのまま呼べます。name.toString() が例外にならず文字列の null を返しているのは、レシーバが Any? の拡張として用意されているためです。
一方、レシーバ型に ? が付いていない拡張を null かもしれない変数にそのまま呼ぶと、次のエラーになります。
// nullable_ng.kt
fun String.shout(): String = uppercase() + "!"
fun main() {
val name: String? = null
println(name.shout())
}
nullable_ng.kt:6:17: error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'String?'.
println(name.shout())
^
| レシーバ型 | null の変数からの呼び方 | 戻り値 | 向いている用途 |
|---|---|---|---|
String |
name?.shout() |
null なら全体が null | 値があるときだけ処理したい |
String? |
name.orDash() |
関数の中で決めた値 | null のときの代わりの値を決めたい |
「null のときにどうするか」を関数の中に1回だけ書けるのが null許容レシーバの良さです。呼び出し側に ?: "-" が何度も並ぶようなら、置き換えどきです。
ジェネリクスを使った拡張関数
型パラメータは fun の直後に書きます。レシーバ型の中で T を使うので、関数名より前に宣言が必要です。
// generic.kt
// 1. どの型の List でも使える
fun <T> List<T>.second(): T? = if (size >= 2) this[1] else null
// 2. 上限つき:比較できる型だけ
fun <T : Comparable<T>> List<T>.isSorted(): Boolean =
zipWithNext().all { (a, b) -> a <= b }
// 3. 数値の List だけに付ける
fun List<Int>.average2(): Double = if (isEmpty()) 0.0 else sum().toDouble() / size
// 4. Map にも付けられる
fun <K, V> Map<K, V>.firstKeyOf(value: V): K? =
entries.firstOrNull { it.value == value }?.key
// 5. reified で型を調べる
inline fun <reified T> List<*>.countOf(): Int = count { it is T }
fun main() {
println(listOf("a", "b", "c").second())
println(listOf(1).second())
println(listOf(1, 2, 3).isSorted())
println(listOf("b", "a").isSorted())
println(listOf(80, 90, 100).average2())
println(mapOf("apple" to 1, "banana" to 2).firstKeyOf(2))
println(listOf(1, "two", 3, 4.0).countOf<Int>())
}
実行結果:
b
null
true
false
90.0
banana
2
| 書き方 | 意味 | 例 |
|---|---|---|
fun <T> List<T>.xxx() |
要素の型を問わない | second() |
fun <T : Comparable<T>> List<T>.xxx() |
比較できる要素に限る | isSorted() |
fun List<Int>.xxx() |
Int の List だけに現れる | average2() |
inline fun <reified T> List<*>.xxx() |
関数の中で is T を使える |
countOf<Int>() |
List<Int> に限定した拡張は、List<String> の変数では候補に出てきません。「使える型にだけ補完候補として現れる」ので、メンバ関数では作りにくい絞り込みができます。
companion object への拡張:クラス名から呼べる関数を足す
Member.fromCsv(...) のように、インスタンスではなくクラス名から呼ぶ関数も外から追加できます。条件は、クラス側に companion object が宣言されていることです(中身は空で構いません)。
// companion.kt
class Member(val id: Int, val name: String) {
companion object // 中身は空でよい
}
class Config {
companion object Factory
}
class Plain
// クラス名から呼べる関数を外から足す
fun Member.Companion.fromCsv(line: String): Member {
val (id, name) = line.split(",")
return Member(id.trim().toInt(), name.trim())
}
val Member.Companion.GUEST_NAME: String
get() = "guest"
// 名前つき companion は、その名前で書く
fun Config.Factory.default(): Config = Config()
fun main() {
val m = Member.fromCsv("7, Hanako")
println("${m.id} ${m.name}")
println(Member.GUEST_NAME)
println(Config.default()::class.simpleName)
}
実行結果:
7 Hanako
guest
Config
companion object を持たないクラスに同じことを書くと、次のエラーになります。
// companion_ng.kt
class Plain
fun Plain.Companion.create(): Plain = Plain()
fun main() {}
companion_ng.kt:4:11: error: unresolved reference 'Companion'.
fun Plain.Companion.create(): Plain = Plain()
^^^^^^^^^
| クラスの状態 | 拡張の書き方 | 呼び方 |
|---|---|---|
companion object がある |
fun Member.Companion.fromCsv(...) |
Member.fromCsv(...) |
名前つき companion object Factory |
fun Config.Factory.default() |
Config.default() |
| companion がない | 書けない(unresolved reference) | トップレベル関数 fun plainOf(...) で代用する |
自分で作るクラスに、あとから生成用の関数を足してもらえるようにしたい場合は、空の companion object を1行置いておくと拡張の受け口になります。
そのまま使える拡張関数10個
1ファイルに貼ってそのまま動く形でまとめました。必要なものだけ抜き出して使えます。
| 番号 | 関数 | できること | 結果の例 |
|---|---|---|---|
| 1 | String.truncate(max) |
指定の長さで切って … を付ける | Kotlinの拡… |
| 2 | String.isDigitsOnly() |
数字だけかどうか | true |
| 3 | String?.ifNullOrBlank(default) |
null・空白なら代わりの値 | (なし) |
| 4 | String.toCamelCase() |
snake_case を camelCase に | userFirstName |
| 5 | Int.withComma() |
3桁区切り | 1,234,567 |
| 6 | Double.toPercent(digits) |
割合を%表示に | 25.6% |
| 7 | LocalDate.toSlash() |
yyyy/MM/dd 形式に | 2026/10/03 |
| 8 | List<T>.secondOrNull() |
2番目を安全に取り出す | null |
| 9 | List<T>.replaceIf(newValue) { 条件 } |
条件に合う要素を入れ替える | [1, 0, 3, 0] |
| 10 | Map<K, V>.require(key) |
無ければメッセージつきで止める | キー 'note' がありません |
// utils.kt
import java.time.LocalDate
import java.time.format.DateTimeFormatter
// 1. 長い文字列を指定の長さで切り、末尾に … を付ける
fun String.truncate(max: Int): String =
if (length <= max) this else take(max) + "…"
// 2. 数字だけでできているか
fun String.isDigitsOnly(): Boolean = isNotEmpty() && all { it in '0'..'9' }
// 3. null または空白なら代わりの値を返す
fun String?.ifNullOrBlank(default: String): String =
if (isNullOrBlank()) default else this
// 4. snake_case を camelCase にする
fun String.toCamelCase(): String =
split("_").mapIndexed { i, s ->
if (i == 0) s else s.replaceFirstChar { it.uppercase() }
}.joinToString("")
// 5. 3桁区切りにする
fun Int.withComma(): String = "%,d".format(this)
// 6. 0.256 のような割合を % 表示にする
fun Double.toPercent(digits: Int = 1): String = "%.${digits}f%%".format(this * 100)
// 7. yyyy/MM/dd 形式の文字列にする
fun LocalDate.toSlash(): String = format(DateTimeFormatter.ofPattern("yyyy/MM/dd"))
// 8. List の2番目を安全に取り出す
fun <T> List<T>.secondOrNull(): T? = getOrNull(1)
// 9. 条件に合う要素を入れ替えた新しい List を返す
fun <T> List<T>.replaceIf(newValue: T, predicate: (T) -> Boolean): List<T> =
map { if (predicate(it)) newValue else it }
// 10. Map から値を取り、無ければメッセージつきで止める
fun <K, V> Map<K, V>.require(key: K): V =
this[key] ?: throw NoSuchElementException("キー '$key' がありません")
fun main() {
println("Kotlinの拡張関数は便利です".truncate(8))
println("12345".isDigitsOnly())
println("12a45".isDigitsOnly())
val memo: String? = " "
println(memo.ifNullOrBlank("(なし)"))
println("user_first_name".toCamelCase())
println(1234567.withComma())
println(0.256.toPercent())
println(LocalDate.of(2026, 10, 3).toSlash())
println(listOf("a").secondOrNull())
println(listOf(1, -2, 3, -4).replaceIf(0) { it < 0 })
val stock = mapOf("pen" to 12)
println(stock.require("pen"))
try {
stock.require("note")
} catch (e: NoSuchElementException) {
println(e.message)
}
}
実行結果:
Kotlinの拡…
true
false
(なし)
userFirstName
1,234,567
25.6%
2026/10/03
null
[1, 0, 3, 0]
12
キー 'note' がありません
3番の ifNullOrBlank は null許容レシーバ、8〜10番はジェネリクスの応用です。isNullOrBlank() で確認したあとは this が String として扱われるため、else this がそのまま書けています。
置き場所と import の決め方
拡張関数は、ファイルのいちばん外側(トップレベル)に書くのが基本です。別のパッケージから使うときは 関数名で import します。クラスの import とは別に必要な点が、最初につまずきやすい箇所です。
// TextExt.kt
package com.example.text
fun String.mask(): String = take(2) + "*".repeat(maxOf(length - 2, 0))
// LogExt.kt
package com.example.log
fun String.mask(): String = "***"
// App.kt
package com.example.app
import com.example.text.mask // 関数名で import する
import com.example.log.mask as maskAll // 同名は as で別名にする
fun main() {
println("secret123".mask())
println("secret123".maskAll())
}
kotlinc TextExt.kt LogExt.kt App.kt -include-runtime -d app.jar
java -cp app.jar com.example.app.AppKt
実行結果:
se*******
***
import まわりで出るエラー文
import を書かずに呼んだ場合:
AppNg.kt:5:25: error: unresolved reference 'mask' on receiver of type 'String'.
println("secret123".mask())
^^^^
同名の拡張を2つとも別名なしで import した場合:
AppNg2.kt:8:25: error: overload resolution ambiguity between candidates:
fun String.mask(): String
fun String.mask(): String
println("secret123".mask())
^^^^
| エラー文 | 原因 | 直し方 |
|---|---|---|
| unresolved reference 'mask' on receiver of type 'String' | 拡張関数を import していない | import パッケージ名.mask を足す |
| overload resolution ambiguity between candidates | 同じ形の拡張を2つ import している | 片方を as 別名 にする/片方の import を外す |
置き場所の決め方
| 使う範囲 | 置き場所 | 可視性 | 例 |
|---|---|---|---|
| 1つのファイルの中だけ | そのファイルのトップレベル | private |
private fun Int.double() |
| 1つのクラスの中だけ | クラスの中に書く(メンバ拡張) | private |
private fun String.withTitle() |
| モジュール全体 | StringExt.kt のような専用ファイル |
internal |
型ごと・用途ごとにファイルを分ける |
| ほかのモジュールにも公開 | 専用パッケージ(com.example.ext など) |
public |
名前の重なりに気を配る |
範囲の狭いものから順に選ぶと、補完候補が増えすぎず、名前が重なる場面も減らせます。ファイル名は StringExt.kt、DateExt.kt のようにレシーバ型ごとに分けると、探すときに迷いません。
クラスの中に書く拡張と、関数型としての拡張
拡張関数はクラスの中にも書けます。その場合、クラスの private なメンバ(下の例では title)と、レシーバ(this)の両方に手が届きます。また、Int.() -> Int のようなレシーバつきの関数型としても扱えます。
// member.kt
class Report(private val title: String) {
// このクラスの中だけで使える拡張関数。private な title にも届く
private fun String.withTitle(): String = "[$title] $this"
fun line(text: String): String = text.withTitle()
}
// ファイル内だけで使う拡張関数
private fun Int.double(): Int = this * 2
// 関数型としての拡張(レシーバつきラムダ)
val twice: Int.() -> Int = { this * 2 }
fun buildText(block: StringBuilder.() -> Unit): String =
StringBuilder().apply(block).toString()
fun main() {
println(Report("月次").line("集計が完了しました"))
println(21.double())
println(5.twice())
println(buildText {
append("A")
append("B")
})
// 関数参照としても渡せる
println(listOf("a", "bb").map(String::length))
}
実行結果:
[月次] 集計が完了しました
42
10
AB
[1, 2]
buildText { append("A") } のように、波かっこの中でレシーバのメソッドを直接呼べる書き方は、apply や buildString などでおなじみの形です。拡張関数の考え方が、そのままラムダにも広がっています。
Java から拡張関数を呼ぶ方法
Kotlin の公式ドキュメント(Calling Kotlin from Java)には、パッケージ直下に書いた関数は、拡張関数も含めて「ファイル名+Kt」という名前の Java クラスの static メソッドにコンパイルされると書かれています。実際に Java から呼んで確かめます。
// StringExt.kt
package com.example.ext
fun String.addBrackets(): String = "[$this]"
fun String?.orDash(): String = this ?: "-"
val String.lastChar: Char
get() = this[length - 1]
fun <T> List<T>.second(): T? = getOrNull(1)
// DateExt.kt
@file:JvmName("DateUtils")
package com.example.ext
import java.time.LocalDate
fun LocalDate.isWeekend(): Boolean = dayOfWeek.value >= 6
// Main.java
import com.example.ext.StringExtKt;
import com.example.ext.DateUtils;
import java.time.LocalDate;
import java.util.List;
public class Main {
public static void main(String[] args) {
// 拡張関数は「レシーバを第1引数に取る static メソッド」として見える
System.out.println(StringExtKt.addBrackets("Java"));
System.out.println(StringExtKt.orDash(null));
// 拡張プロパティは getXxx(レシーバ)
System.out.println(StringExtKt.getLastChar("Java"));
System.out.println(StringExtKt.second(List.of("a", "b")));
// @file:JvmName で付けたクラス名
System.out.println(DateUtils.isWeekend(LocalDate.of(2026, 10, 3)));
}
}
kotlinc StringExt.kt DateExt.kt -d out
javac -cp out -d out Main.java
java -cp out:(kotlinc のフォルダ)/lib/kotlin-stdlib.jar Main
実行結果:
[Java]
-
a
b
true
javap でクラスの中身を見ると、レシーバが第1引数になっていることがはっきりわかります。
Compiled from "StringExt.kt"
public final class com.example.ext.StringExtKt {
public static final java.lang.String addBrackets(java.lang.String);
public static final java.lang.String orDash(java.lang.String);
public static final char getLastChar(java.lang.String);
public static final <T> T second(java.util.List<? extends T>);
}
| Kotlin での宣言 | Java からの呼び方 |
|---|---|
fun String.addBrackets()(StringExt.kt) |
StringExtKt.addBrackets("Java") |
fun String?.orDash() |
StringExtKt.orDash(null) |
val String.lastChar |
StringExtKt.getLastChar("Java") |
fun <T> List<T>.second() |
StringExtKt.second(list) |
@file:JvmName("DateUtils") を付けたファイルの fun LocalDate.isWeekend() |
DateUtils.isWeekend(date) |
Java 側から読みやすい名前にしたいときは、ファイルの先頭(package より前)に @file:JvmName("DateUtils") を書きます。拡張関数の正体が「レシーバを第1引数に取る static メソッド」だと知っておくと、メンバ優先や静的解決のルールも納得しやすくなります。static メソッドはオーバーライドできず、呼び先はコンパイル時に決まるからです。
拡張関数にするか、メンバ関数にするかの判断表
| 状況 | おすすめ | 理由 |
|---|---|---|
| String・List など編集できないクラスに足したい | 拡張関数 | クラスを書き換えずに済む |
| 自分のクラスで、private な値を使う処理 | メンバ関数 | 拡張からは private に届かない |
| 子クラスごとに動きを変えたい | メンバ関数(open / override) | 拡張は変数の型で決まる |
| 特定の画面やファイルでだけ使う変換 | private な拡張関数 | クラス本体を小さく保てる |
| null のときの扱いをまとめたい | null許容レシーバの拡張 | ?. と ?: の繰り返しを減らせる |
List<Int> など特定の型引数のときだけ使いたい |
拡張関数 | メンバでは型引数ごとに出し分けにくい |
| クラス名から呼ぶ生成関数を外から足したい | companion への拡張 | Member.fromCsv() の形で呼べる |
導入前のチェックリスト
| 確認すること | 確認方法 |
|---|---|
| 同じ名前・同じ引数のメンバ関数がないか | コンパイル時の warning(shadowed by a member)を見る |
| 親クラス型の変数から呼ぶ場面がないか | 子クラス用の拡張を作るなら when と is の判定を足す |
| 拡張プロパティに値を持たせようとしていないか | getter で計算する形にする |
| 名前が一般的すぎないか | toDisplayText() など用途が伝わる名前にする |
| 公開範囲は必要な分だけか | まず private、次に internal を検討する |
| Java からも呼ぶか | @file:JvmName で読みやすいクラス名にする |
よくある質問
Q. 拡張関数で既存のメソッドを上書きできますか? A. できません。同じ名前・同じ引数のメンバ関数があるときは、必ずメンバ関数が呼ばれます。コンパイル時に「this extension is shadowed by a member」という警告が出るので、拡張側の名前を変えるのが近道です。引数が違う場合は別の関数(オーバーロード)として呼べます。
Q. 拡張プロパティに値を保存できないのはなぜですか? A. 拡張はクラスの中身を変えずに「外側の関数」として追加されるため、インスタンスの中に新しい保存場所を作れないからです。初期値を書くと「extension property cannot be initialized because it has no backing field」というエラーになります。getter で計算して返すか、値を持たせたいときは外側の Map やラッパークラスを使います。
Q. 子クラス用の拡張関数を作ったのに、親クラス用が呼ばれます。
A. 拡張関数は、変数や引数に書かれた型で呼び先が決まるためです。val s: Shape = Circle() のように親の型で受けると、Shape 用の拡張が選ばれます。中身の型で分けたい場合は when (s) { is Circle -> ... } で判定するか、メンバ関数(open / override)にします。
Q. 別のファイルに書いた拡張関数が「unresolved reference」になります。
A. パッケージが違う場合は、クラスとは別に拡張関数そのものを import com.example.text.mask のように関数名で import します。同じパッケージ内であれば import は不要です。
まとめ
- 拡張関数は
fun 型.関数名()で宣言し、既存のクラスを書き換えずにメソッドのように呼べる - 同名・同じ引数のメンバ関数があるとメンバが必ず優先され、コンパイラが「this extension is shadowed by a member」と警告する
- 呼び先は変数の型で決まる(静的解決)ので、親クラス型の変数からは親クラス用の拡張が呼ばれる
- 拡張プロパティは値を持てないため、getter で計算するか外側の Map に預ける
String?のような null許容レシーバにすると、null でも?.なしで呼べて、代わりの値を1か所で決められる- companion への拡張は、クラス側に
companion objectがあればMember.fromCsv()の形で足せる - Java からは「ファイル名+Kt」クラスの static メソッドとして、レシーバを第1引数に渡して呼ぶ
参考・出典
- Kotlin 公式ドキュメント「Extensions」 https://kotlinlang.org/docs/extensions.html
- Kotlin 公式ドキュメント「Calling Kotlin from Java」 https://kotlinlang.org/docs/java-to-kotlin-interop.html
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Kotlin拡張関数の使い方|拡張プロパティとメンバ関数優先の注意点」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。