Extension Function trong Kotlin: Thêm Hàm Cho Lớp Có Sẵn

1. Tổng quan

Khi làm việc với các lớp có sẵn như String, List hay thậm chí là class từ thư viện bên thứ ba, bạn thường muốn thêm vài hàm tiện ích nhưng không thể sửa source code gốc. Kotlin giải quyết vấn đề này bằng extension function — cơ chế cho phép “gắn” thêm hàm vào một lớp mà không cần kế thừa hay dùng pattern Decorator.

Bài viết này giúp bạn hiểu cách extension function hoạt động, cách áp dụng vào các tình huống thực tế trong dự án Spring Boot, và những cạm bẫy cần tránh.

2. Extension Function Là Gì?

Extension function là hàm được định nghĩa bên ngoài class nhưng có thể gọi như thể nó là method của class đó. Cú pháp chung:

fun ClassName.functionName(params): ReturnType {
    // dùng "this" để tham chiếu đến instance
}

Ví dụ đơn giản: thêm hàm isValidEmail() cho String.

fun String.isValidEmail(): Boolean {
    return this.matches(Regex("^[\\w.+-]+@[\\w-]+\\.[a-zA-Z]{2,}$"))
}

fun main() {
    val email = "user@example.com"
    println(email.isValidEmail())
}

Dòng fun String.isValidEmail() khai báo String là receiver type. Bên trong hàm, this trỏ đến chuỗi gọi hàm. Kết quả in ra:

true

Về bản chất, Kotlin compile extension function thành static method, nhận instance làm tham số đầu tiên. Vì vậy extension function không thực sự sửa đổi class gốc — nó chỉ là cú pháp đường (syntactic sugar) giúp code gọn hơn.

3. Áp Dụng Vào Domain Object

Giả sử bạn có class Order trong hệ thống bán hàng, muốn thêm hàm tính tổng tiền sau thuế mà không muốn nhét logic này vào class gốc (có thể do class nằm ở module khác, hoặc bạn muốn tách biệt business logic).

data class Order(
    val id: Long,
    val subtotal: Double,
    val taxRate: Double
)

fun Order.totalWithTax(): Double {
    return subtotal + (subtotal * taxRate)
}
fun main() {
    val order = Order(id = 1, subtotal = 100.0, taxRate = 0.1)
    println(order.totalWithTax())
}

Hàm totalWithTax() đọc subtotal và taxRate thông qua this (có thể bỏ this. vì Kotlin tự suy ra). Kết quả:

110.0

Cách tiếp cận này giúp tách rời data (trong Order) và behavior (trong extension function), rất hợp với phong cách functional hoặc khi bạn làm việc với DTO sinh tự động (ví dụ từ OpenAPI hoặc Protobuf).

4. Extension Function Trên Generic Type

Extension function cũng hoạt động tốt với generic, giúp viết hàm tiện ích cho List<T>, Collection<T>…

fun <T> List<T>.secondOrNull(): T? {
    return if (this.size >= 2) this[1] else null
}

fun main() {
    val customers = listOf("Alice", "Bob", "Charlie")
    println(customers.secondOrNull())
}

List<T>.secondOrNull() là generic extension function, hoạt động với mọi kiểu T. Kết quả:

Bob

Lưu ý: Kotlin standard library đã có sẵn rất nhiều extension function hữu ích như firstOrNull(), associateBy(), groupBy(). Trước khi tự viết, hãy kiểm tra kotlin.collections xem đã có chưa — tránh “phát minh lại bánh xe”.

5. Dùng Extension Function Thay Cho Utility Class

Trong Java, bạn hay viết các class dạng StringUtils, DateUtils với static method. Kotlin khuyến khích thay thế bằng extension function vì cú pháp gọi tự nhiên hơn.

So sánh:

// Java style
String formatted = DateUtils.formatToVietnamese(someDate);
// Kotlin style với extension function
fun LocalDate.toVietnameseFormat(): String {
    val formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy")
    return this.format(formatter)
}

val formatted = someDate.toVietnameseFormat()

Cách viết Kotlin đọc tự nhiên hơn: “ngày này, hãy định dạng theo kiểu Việt Nam” thay vì “DateUtils, hãy định dạng ngày này”. Khi dùng trong Spring Boot service, bạn có thể gom các extension function liên quan vào một file riêng, ví dụ DateExtensions.kt:

// DateExtensions.kt
package com.nonladev.util

import java.time.LocalDate
import java.time.format.DateTimeFormatter

fun LocalDate.toVietnameseFormat(): String =
    this.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))

fun LocalDate.isWeekend(): Boolean {
    val day = this.dayOfWeek
    return day == java.time.DayOfWeek.SATURDAY || day == java.time.DayOfWeek.SUNDAY
}

Import các hàm này vào nơi cần dùng bằng import com.nonladev.util.toVietnameseFormat.

6. Extension Function Có Nullable Receiver

Một điểm mạnh khác: bạn có thể viết extension function cho nullable type, giúp xử lý null an toàn mà không cần if-check lặp lại.

fun String?.orDefault(default: String): String {
    return this ?: default
}

fun main() {
    val customerName: String? = null
    println(customerName.orDefault("Khách vãng lai"))
}

Vì receiver là String?, hàm này gọi được cả khi biến là null. Bên trong, toán tử Elvis ?: trả về default nếu this là null. Kết quả:

Khách vãng lai

Đây là pattern rất hữu dụng khi làm việc với dữ liệu từ external API — nơi field có thể null nhưng bạn muốn logic hiển thị luôn có giá trị mặc định.

7. Viết Test Cho Extension Function

Extension function là static method ẩn danh, nên test chúng y hệt như test một hàm thuần túy — không cần mock gì cả.

Thêm dependency JUnit 5 và AssertJ (nếu dùng Maven cho module Kotlin):

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.25.3</version>
    <scope>test</scope>
</dependency>

Test cho Order.totalWithTax():

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class OrderExtensionsTest {

    @Test
    fun `totalWithTax should add tax amount to subtotal`() {
        val order = Order(id = 1, subtotal = 200.0, taxRate = 0.05)

        val result = order.totalWithTax()

        assertThat(result).isEqualTo(210.0)
    }
}

Test này không cần instance hóa gì đặc biệt — tạo Order, gọi hàm, assert kết quả. Đơn giản vì extension function không giữ state riêng, chỉ thao tác trên receiver.

8. Lỗi Thường Gặp Và Khi Nào Không Nên Dùng

Extension function không override được method thật sự. Nếu class gốc đã có method cùng tên cùng signature, method của class luôn được ưu tiên — compiler sẽ cảnh báo “extension is shadowed”.

class Greeter {
    fun greet() = "Hello from member function"
}

fun Greeter.greet() = "Hello from extension function"

fun main() {
    println(Greeter().greet())
}

Kết quả luôn là:

Hello from member function

Extension function được resolve tĩnh (static dispatch), không theo đa hình runtime. Nếu bạn gọi extension function thông qua biến có kiểu khai báo là lớp cha nhưng instance thực tế là lớp con, Kotlin dùng extension function của lớp cha — khác với member function dùng dynamic dispatch.

Lưu ý: Đừng lạm dụng extension function để thêm quá nhiều hàm cho các class lõi như String hay Any. Code dễ trở nên khó tìm nguồn gốc hàm, nhất là khi nhiều file cùng extension một class. Hãy tổ chức theo package rõ ràng và đặt tên file dạng XxxExtensions.kt.

Không nên dùng extension function khi:

  • Bạn cần thêm state (property có backing field) — Kotlin không cho phép extension property có backing field thật sự.
  • Logic cần truy cập private member của class gốc — extension function chỉ thấy được public/internal API.
  • Hàm có ý nghĩa nghiệp vụ cốt lõi của domain object — lúc này nên đưa vào class chính để đảm bảo tính đóng gói (encapsulation).

9. Kết luận

Extension function là công cụ mạnh giúp Kotlin code gọn và tự nhiên hơn, đặc biệt hữu ích khi bạn cần thêm hành vi cho class có sẵn hoặc muốn tách biệt behavior khỏi data model. Tuy nhiên, hãy nhớ: nó chỉ là cú pháp đường ở compile-time, không thay thế được kế thừa thật sự hay khả năng truy cập private state. Dùng đúng chỗ — cho utility logic, formatting, hay xử lý null an toàn — bạn sẽ có codebase Kotlin sạch và dễ đọc hơn rất nhiều so với cách viết XxxUtils kiểu Java truyền thống.

kotlinextension-functionjavaspring-bootbest-practices