Object, companion object và singleton trong Kotlin
1. Tổng quan
Kotlin không có từ khóa static. Thay vào đó, ngôn ngữ cung cấp từ khóa object với ba cách dùng: object declaration, object expression và companion object.
Bài này giải thích từng cách dùng, cách Kotlin biên dịch chúng xuống bytecode JVM, cách gọi chúng từ Java, và khi nào không nên dùng. Đọc xong, bạn sẽ biết cách tạo singleton an toàn với thread, viết factory method gọn gàng và tránh được các lỗi thường gặp khi test.
2. Thiết lập
Các ví dụ dùng Kotlin 2.x trên JVM 21. Để chạy test, bạn thêm JUnit 5 và AssertJ vào pom.xml:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
</dependencies>
Project cần có sẵn kotlin-maven-plugin để biên dịch mã Kotlin.
3. Object declaration – singleton có sẵn trong ngôn ngữ
Khi khai báo object ở cấp top-level hoặc lồng trong một lớp, bạn được một singleton. Kotlin tự tạo đúng một instance của nó. Bạn không cần constructor private, không cần getInstance() và không cần double-checked locking.
import java.util.concurrent.atomic.AtomicLong
object OrderIdGenerator {
private val counter = AtomicLong(0)
fun next(): String = "ORD-${counter.incrementAndGet()}"
}
fun main() {
println(OrderIdGenerator.next())
println(OrderIdGenerator.next())
}
Bạn gọi hàm trực tiếp qua tên object: OrderIdGenerator.next(). Ở đây không có từ khóa new và cũng không có lời gọi constructor.
Biến counter dùng AtomicLong vì nhiều thread có thể gọi next() cùng lúc. Singleton chỉ đảm bảo có một instance. Nó không tự làm cho trạng thái bên trong an toàn với thread.
ORD-1
ORD-2
3.1. Khởi tạo lazy và an toàn với thread
Object được khởi tạo ở lần truy cập đầu tiên, không phải lúc chương trình khởi động:
object AppConfig {
init {
println("AppConfig được khởi tạo")
}
val currency = "VND"
}
fun main() {
println("Bắt đầu")
println(AppConfig.currency)
println(AppConfig.currency)
}
Bắt đầu
AppConfig được khởi tạo
VND
VND
Khối init chỉ chạy một lần, ngay trước lần đọc currency đầu tiên. Về mặt bytecode, Kotlin biên dịch AppConfig thành một lớp final có trường public static final AppConfig INSTANCE. Trường này được gán trong static initializer.
JVM đảm bảo static initializer của một lớp chỉ chạy một lần và an toàn khi nhiều thread cùng truy cập. Vì vậy singleton của Kotlin vừa lazy vừa thread-safe mà bạn không phải viết thêm dòng nào.
3.2. Object có thể kế thừa và implement interface
Object là một kiểu thật sự. Nó có thể implement interface và được truyền đi như mọi giá trị khác:
interface DiscountPolicy {
fun apply(amount: Long): Long
}
object NoDiscount : DiscountPolicy {
override fun apply(amount: Long): Long = amount
}
class TenPercentOff : DiscountPolicy {
override fun apply(amount: Long): Long = amount * 90 / 100
}
fun checkout(total: Long, policy: DiscountPolicy): Long = policy.apply(total)
fun main() {
println(checkout(200_000, NoDiscount))
println(checkout(200_000, TenPercentOff()))
}
200000
180000
NoDiscount không có trạng thái, nên tạo nhiều instance là lãng phí. Khai báo nó là object thể hiện rõ ý đồ: chỉ cần đúng một instance.
4. Object expression – đối tượng ẩn danh
Khi đặt object ở vị trí của một biểu thức, bạn tạo ra một đối tượng ẩn danh. Cách dùng này tương tự anonymous class trong Java:
data class Order(val id: String, val total: Long)
fun main() {
val byTotalDesc = object : Comparator<Order> {
override fun compare(a: Order, b: Order): Int = b.total.compareTo(a.total)
}
val orders = listOf(Order("A", 50), Order("B", 300), Order("C", 120))
println(orders.sortedWith(byTotalDesc).map { it.id })
}
[B, C, A]
Điểm khác biệt quan trọng: object expression không phải singleton. Mỗi lần biểu thức được thực thi, một instance mới được tạo ra. Ngoài ra, object expression được khởi tạo ngay tại chỗ, không lazy như object declaration.
Bạn cũng có thể tạo một object không có supertype để gom tạm vài giá trị trong phạm vi cục bộ:
fun main() {
val stats = object {
val count = 3
val sum = 470L
}
println("Trung bình: ${stats.sum / stats.count}")
}
Kiểu của stats chỉ truy cập được bên trong hàm hoặc trong một khai báo private. Nếu bạn trả nó ra ngoài qua một hàm public, kiểu sẽ bị ép về Any và bạn mất quyền truy cập các property. Trong trường hợp đó, hãy dùng data class.
Lưu ý: Với interface chỉ có một phương thức trừu tượng, bạn nên dùng lambda (SAM conversion) thay cho object expression, ví dụ
Comparator<Order> { a, b -> b.total.compareTo(a.total) }. Chỉ dùng object expression khi cần override nhiều phương thức hoặc cần giữ trạng thái.
5. Companion object – thay thế cho static
Đôi khi bạn cần một hàm gắn với lớp chứ không gắn với instance, ví dụ factory method. Trong Java, bạn sẽ dùng static. Trong Kotlin, bạn dùng companion object:
class Customer private constructor(val email: String, val name: String) {
companion object {
private val EMAIL_REGEX = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
fun of(email: String, name: String): Customer {
require(EMAIL_REGEX.matches(email)) { "Email không hợp lệ: $email" }
return Customer(email.trim().lowercase(), name.trim())
}
}
}
fun main() {
val customer = Customer.of("An.Nguyen@Example.com", " Nguyễn An ")
println("${customer.email} | ${customer.name}")
}
an.nguyen@example.com | Nguyễn An
Đoạn code trên hoạt động như sau:
- Constructor là
private, nên bên ngoài buộc phải tạoCustomerquaCustomer.of(...). - Companion object truy cập được constructor private của lớp chứa nó.
EMAIL_REGEXchỉ được biên dịch một lần và dùng chung cho mọi lời gọi.
Companion object vẫn là một object thật: một singleton lồng trong lớp, mặc định có tên Companion. Nó được khởi tạo khi lớp chứa nó được nạp, tương đương static initializer trong Java. Mỗi lớp chỉ có tối đa một companion object.
5.1. Đặt tên và implement interface
Vì là object, companion có thể đặt tên riêng và implement interface. Đây là điều static trong Java không làm được:
interface Parser<T> {
fun parse(raw: String): T
}
data class Money(val amount: Long, val currency: String) {
companion object Factory : Parser<Money> {
override fun parse(raw: String): Money {
val (amount, currency) = raw.trim().split(" ")
return Money(amount.toLong(), currency)
}
}
}
fun <T> parseAll(parser: Parser<T>, lines: List<String>): List<T> = lines.map(parser::parse)
fun main() {
println(parseAll(Money, listOf("150000 VND", "20 USD")))
}
[Money(amount=150000, currency=VND), Money(amount=20, currency=USD)]
Hãy để ý lời gọi parseAll(Money, ...). Khi dùng tên lớp Money như một giá trị, Kotlin hiểu đó là companion object của nó. Nhờ vậy, chính lớp Money đóng vai trò một Parser<Money>.
5.2. Extension function trên companion
Bạn có thể thêm hàm “kiểu static” cho một lớp mà không cần sửa mã nguồn của nó, miễn là lớp đó đã khai báo companion:
fun Money.Factory.zero(currency: String): Money = Money(0, currency)
fun main() {
println(Money.zero("VND"))
}
Money(amount=0, currency=VND)
Nếu companion không đặt tên, bạn viết fun Money.Companion.zero(...).
6. Gọi object và companion từ Java
Mặc định, Java nhìn thấy object và companion như những instance thông thường:
String id = OrderIdGenerator.INSTANCE.next();
Customer c = Customer.Companion.of("a@b.com", "An");
Cách gọi này dài dòng và khó đọc. Kotlin cung cấp ba công cụ để mã Java trông tự nhiên hơn:
class PaymentService {
companion object {
const val MAX_RETRY = 3
@JvmField
val SUPPORTED_CURRENCIES = listOf("VND", "USD")
@JvmStatic
fun create(): PaymentService = PaymentService()
fun legacyCreate(): PaymentService = PaymentService()
}
}
Phía Java sẽ gọi như sau:
int retry = PaymentService.MAX_RETRY;
List<String> currencies = PaymentService.SUPPORTED_CURRENCIES;
PaymentService a = PaymentService.create();
PaymentService b = PaymentService.Companion.legacyCreate();
Ý nghĩa của từng công cụ:
const valchỉ dùng được với kiểu nguyên thủy vàStringcó giá trị biết trước lúc biên dịch. Hằng số được inline vào nơi sử dụng và trở thành trườngpublic static final.@JvmFieldsinh trường static public, không sinh getter. Annotation này phù hợp với giá trị không phải hằng compile-time, nhưList.@JvmStaticsinh thêm một phương thức static trên lớpPaymentService. Hàm không có annotation này vẫn phải gọi quaCompanion.
Nếu project chỉ có mã Kotlin, bạn không cần các annotation này. Ngoại lệ là const val: nó vẫn hữu ích vì giá trị có thể dùng trong annotation, ví dụ @Retry(maxAttempts = MAX_RETRY).
7. data object cho các trạng thái không có dữ liệu
Từ Kotlin 1.9, bạn có thể khai báo data object. Cách dùng này thường thấy trong sealed hierarchy:
sealed interface PaymentResult {
data class Success(val transactionId: String) : PaymentResult
data class Failed(val reason: String) : PaymentResult
data object Pending : PaymentResult
}
fun main() {
println(PaymentResult.Pending)
println(PaymentResult.Success("TX-42"))
}
Pending
Success(transactionId=TX-42)
Nếu bỏ chữ data, toString() sẽ in ra dạng PaymentResult$Pending@1b6d3586, rất khó đọc trong log. data object còn sinh equals/hashCode nhất quán. Điều này quan trọng khi object bị deserialize thành một instance khác.
8. Viết test
Test cho factory method trong companion object:
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
class CustomerTest {
@Test
fun `of chuẩn hóa email và tên`() {
val customer = Customer.of("An.Nguyen@Example.com", " Nguyễn An ")
assertThat(customer.email).isEqualTo("an.nguyen@example.com")
assertThat(customer.name).isEqualTo("Nguyễn An")
}
@Test
fun `of ném lỗi khi email không hợp lệ`() {
assertThatThrownBy { Customer.of("khong-phai-email", "An") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("khong-phai-email")
}
@Test
fun `companion parse được nhiều dòng`() {
val result = parseAll(Money, listOf("100 VND"))
assertThat(result).containsExactly(Money(100, "VND"))
}
@Test
fun `object declaration luôn trả về cùng một instance`() {
val first: DiscountPolicy = NoDiscount
val second: DiscountPolicy = NoDiscount
assertThat(first).isSameAs(second)
}
}
Các test này chạy độc lập vì Customer.of và Money.parse không giữ trạng thái. Với OrderIdGenerator thì khác: bộ đếm sống suốt vòng đời JVM. Một test kiểm tra next() == "ORD-1" sẽ đúng hay sai tùy vào thứ tự chạy test. Đây chính là lỗi đầu tiên ở mục tiếp theo.
9. Lỗi hay gặp và khi nào không nên dùng
Object giữ trạng thái mutable. Trạng thái toàn cục khiến test phụ thuộc lẫn nhau và gây khó khăn khi chạy song song. Nếu object cần trạng thái, hãy tách thành một class và truyền instance vào nơi cần dùng (dependency injection).
Lưu ý: Object declaration không có constructor có tham số. Nếu singleton phụ thuộc vào cấu hình hoặc service khác, ví dụ
DataSourcehay URL của payment gateway, thìobjectkhông phải lựa chọn phù hợp.
Dùng object trong ứng dụng Spring. Bean trong Spring mặc định đã là singleton trong phạm vi ApplicationContext. Viết object PaymentService sẽ khiến bạn mất constructor injection, khó mock trong test và tạo ra hai cơ chế quản lý vòng đời song song. Trong trường hợp này, hãy dùng @Service class PaymentService(...). Chỉ dùng object cho phần thuần túy, không phụ thuộc gì: hằng số, hàm tiện ích, các policy không có trạng thái.
Nhầm companion object với static. Companion là một instance thật. Gọi Customer.of(...) thực chất là gọi Customer.Companion.of(...). Chi phí của việc này không đáng kể, nhưng nó giải thích vì sao Java cần @JvmStatic.
Nhét mọi hàm tiện ích vào companion. Với hàm không liên quan trực tiếp đến lớp, top-level function như fun formatVnd(amount: Long): String gọn hơn và dễ tìm hơn.
Phụ thuộc vòng trong khởi tạo. Nếu init của object A đọc object B, trong khi B lại đọc A, bạn có thể nhận về giá trị null cho một property khai báo non-null. Hãy giữ khối khởi tạo của object thật đơn giản.
10. Kết luận
Từ khóa object trong Kotlin có ba vai trò:
- Object declaration tạo singleton lazy và thread-safe nhờ cơ chế khởi tạo lớp của JVM.
- Object expression tạo đối tượng ẩn danh. Mỗi lần thực thi biểu thức là một instance mới.
- Companion object thay thế
static. Nó có thể đặt tên, implement interface và nhận extension function.
Khi làm việc với Java, bạn dùng const val, @JvmField và @JvmStatic để API trông tự nhiên. Quy tắc quan trọng nhất là giữ cho object không có trạng thái mutable. Khi cần dependency hoặc cần mock trong test, một class thông thường, hoặc một bean Spring, luôn là lựa chọn tốt hơn.