Kotlin Annotations (註解)

Annotations (註解) 是一種將 metadata (元數據) 附加到程式碼上的機制。這些數據本身不直接影響程式碼的執行邏輯,但可以被編譯器 (Compiler) 或執行期 (Runtime) 的工具讀取,用來進行程式碼檢查、生成 boilerplate code、或是改變程式執行的行為(透過 Reflection)。

如果你寫過 Java,對 @Override@Deprecated 或 Spring Framework 的 @Autowired 一定不陌生,這些都是 Annotations。

宣告註解 (Declaring Annotations)

在 Kotlin 中,使用 annotation class 關鍵字來定義一個註解:

annotation class Fancy

定義好後,就可以將它標記在類別、函式、參數或變數上:

@Fancy
class Foo {
    @Fancy
    fun baz(@Fancy args: Int) { ... }
}

元註解 (Meta-Annotations)

在定義自定義註解時,我們通常需要說明這個註解「可以用在哪裡」以及「存活多久」。這時就需要用到 元註解 (Meta-Annotations),也就是「標記註解的註解」。

最常用的元註解有以下幾個:

@Target

指定你的註解可以標記在哪些程式碼元素上(例如:只能用在類別,或是只能用在函式)。

@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.VALUE_PARAMETER)
annotation class CustomMarker

常見的 AnnotationTarget 包括:

  • CLASS:類別、介面、物件
  • FUNCTION:函式 (不含建構式)
  • PROPERTY:屬性
  • FIELD:欄位 (Backing field)
  • VALUE_PARAMETER:參數
  • CONSTRUCTOR:建構式

@Retention

指定註解的存活週期,決定該註解會保留到哪個階段。

@Retention(AnnotationRetention.RUNTIME)
annotation class Inspectable
  • SOURCE:只保留在原始碼中,編譯時會被丟棄。適用於編譯器檢查工具(如 @Suppress)。
  • BINARY:保留在編譯後的 class 檔案中,但執行期 (Runtime) 無法透過 Reflection 讀取
  • RUNTIME:保留在 class 檔案中,且執行期可透過 Reflection 讀取
在 Kotlin 中,@Retention 的預設值是 RUNTIME。這點與 Java 不同(Java 預設是 CLASS,即 BINARY)。

@Repeatable

允許在同一個元素上重複使用該註解。

@MustBeDocumented

表示這個註解應該被包含在生成的 API 文件中。

註解的建構式與參數

註解可以擁有建構式並接收參數,讓我們在使用時傳遞額外的資訊。

annotation class Special(val why: String)

@Special("Example")
class Foo

註解的參數型別是有限制的,只能是以下幾種:

  • 對應 Java 的基本型別 (Int, Long, Double, Boolean 等)
  • String
  • Classes (KClass)
  • Enums
  • 其他 Annotation
  • 上述型別的 Array
註解參數不能是 Nullable 的型別,因為 JVM 不支援將 null 存為註解屬性的值。

參數使用範例

annotation class ReplaceWith(val expression: String)

annotation class Deprecated(
    val message: String,
    val replaceWith: ReplaceWith = ReplaceWith("") // 預設值
)

@Deprecated("Use newFunction instead", ReplaceWith("newFunction()"))
fun oldFunction() { ... }

如果是 Class 作為參數,需要使用 ::class

annotation class TestRunner(val kClass: KClass<*>)

@TestRunner(String::class)
fun testString() { ... }

註解的使用處目標 (Use-site Targets)

這是在 Kotlin 中非常重要的一個概念。

當你在 Kotlin 宣告一個屬性 (Property) 時,編譯器通常會產生多個 Java 元素:一個 backing field、一個 getter,如果是 var 還會有一個 setter。

如果你在屬性上加註解,編譯器怎麼知道這個註解是要加在 field、getter 還是 setter 上?這時候就需要 Use-site Targets

語法格式為:@target:AnnotationName

class Example(@field:Ann val foo: String,    // 標記在 Java field
              @get:Ann val bar: String,      // 標記在 Java getter
              @param:Ann val quux: String)   // 標記在 Java 建構式參數

常見的目標清單:

  • field:Backing field
  • get:Property getter
  • set:Property setter
  • param:建構式參數
  • setparam:Setter 的參數
  • delegate:委派屬性的儲存欄位 (Delegate field)
  • file:檔案級別 (通常寫在檔案最上方,package 宣告之前)

實務範例:JUnit 與 Dependency Injection

在使用 Java 的框架時,這非常常見。例如 JUnit 的 @Rule 必須標記在 public field 上,但 Kotlin 的屬性預設是 private field 加上 getter/setter。如果不指定 target,JUnit 會找不到該 field。

import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder

class MyTest {
    // 錯誤寫法:JUnit 會報錯,因為註解預設可能不在 field 上或是 field 是 private
    // @Rule val tempFolder = TemporaryFolder() 

    // 正確寫法:強制註解在 field 上,並透過 @JvmField 讓 field 變為 public
    @get:Rule 
    val tempFolder = TemporaryFolder()
}

(註:上述 JUnit 4 範例中,通常配合 @JvmField 會更簡單,直接 @JvmField @Rule val ...)

另一個常見例子是將整個檔案標記名稱(更改生成的 Facade Class 名稱):

@file:JvmName("StringUtil")

package com.example.utils

fun join(strings: List<String>) { ... }

這樣 Java 呼叫時就會是 StringUtil.join() 而不是預設的 FileNameKt.join()

常見內建註解

Kotlin 標準庫提供了一些好用的註解:

@Deprecated

用來標示某個功能已過時。最強大的是它支援 ReplaceWith,讓 IDE 可以提供「一鍵快速修正 (Quick Fix)」,自動將舊程式碼替換成新寫法。

@Deprecated(
    message = "Use newLog instead", 
    replaceWith = ReplaceWith("newLog(msg)")
)
fun log(msg: String) { ... }

fun newLog(msg: String) { ... }

@JvmStatic

用在 objectcompanion object 的成員上,告訴編譯器產生真正的 static method 或 field,方便 Java 呼叫。

@Throws

Kotlin 沒有 Checked Exceptions,所以不需要宣告 throws。但如果你的 Kotlin 函式會拋出例外,且需要讓 Java 呼叫端知道必須 catch 這個例外,就需要加上 @Throws

@Throws(IOException::class)
fun readFile(path: String) { ... }

透過 Reflection 讀取註解

通常我們會定義註解,然後透過 Reflection API 在 Runtime 讀取它們來做一些依賴注入或路由控制。

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Runner

class Tests {
    @Runner
    fun test1() = println("Test 1")
    
    fun test2() = println("Test 2")
}

fun main() {
    val tests = Tests()
    // 取得所有方法
    val kClass = tests::class
    for (func in kClass.members) {
        // 檢查是否有 @Runner 註解
        if (func.annotations.any { it is Runner }) {
            func.call(tests)
        }
    }
}

這個範例展示了如何打造一個超迷你的測試框架,只執行被 @Runner 標記的函式。

小提醒

  • 註解雖然強大,但過度依賴反射 (Reflection) 讀取註解可能會影響效能,尤其是在 Android 等資源受限的平台上。
  • 使用像是 Dagger / Hilt 或 Room 這類框架時,它們通常使用 KAPT (Kotlin Annotation Processing Tool)KSP (Kotlin Symbol Processing) 在「編譯時期」處理註解並生成程式碼,這樣就不會有 Runtime 效能損耗。