Kotlin Collection: map, filter và groupBy qua ví dụ thực tế
1. Tổng quan
Phần lớn code nghiệp vụ là biến đổi danh sách: lấy ra vài trường, lọc theo điều kiện, gom nhóm để tính tổng. Kotlin có sẵn các hàm mở rộng map, filter và groupBy trong standard library cho việc này, nên bạn không cần thêm thư viện nào.
Bài này đi qua từng hàm và các biến thể quan trọng của chúng, rồi ghép chúng thành một pipeline hoàn chỉnh. Cuối bài có test kiểm chứng và danh sách những lỗi hay gặp.
2. Dữ liệu mẫu
Mọi ví dụ trong bài dùng chung một danh sách đơn hàng:
enum class Status { NEW, PAID, CANCELLED }
data class Order(
val id: Int,
val customer: String,
val status: Status,
val total: Int, // đơn vị: nghìn đồng
val coupon: String? = null
)
val orders = listOf(
Order(1, "An", Status.PAID, 120, coupon = "SALE10"),
Order(2, "Bình", Status.NEW, 80),
Order(3, "An", Status.CANCELLED, 50),
Order(4, "Chi", Status.PAID, 200, coupon = "VIP"),
Order(5, "Bình", Status.PAID, 45),
)
orders có kiểu List<Order>, tức là một list chỉ đọc. Các hàm bên dưới không sửa list gốc. Mỗi lần gọi chúng trả về một collection mới.
3. map: biến đổi từng phần tử
3.1. map cơ bản
map nhận một lambda (T) -> R, áp lambda đó lên từng phần tử và trả về List<R>:
val ids = orders.map { it.id }
val customers = orders.map { it.customer }
println(ids)
println(customers)
[1, 2, 3, 4, 5]
[An, Bình, An, Chi, Bình]
Kết quả luôn có cùng số phần tử với list gốc và giữ nguyên thứ tự. Nếu chỉ muốn lấy giá trị không trùng, bạn nối thêm .distinct(): orders.map { it.customer }.distinct() cho ra [An, Bình, Chi].
Với một thuộc tính đơn giản, bạn có thể viết bằng property reference thay cho lambda: orders.map(Order::id).
3.2. mapNotNull: biến đổi và bỏ null
Khi lambda có thể trả về null, dùng mapNotNull để vừa biến đổi vừa loại các giá trị null:
val coupons: List<String> = orders.mapNotNull { it.coupon }
println(coupons)
[SALE10, VIP]
Kiểu trả về là List<String>, không phải List<String?>. Cách này gọn và an toàn kiểu hơn so với map { it.coupon }.filterNotNull().
3.3. mapIndexed: cần biết vị trí
val lines = orders.mapIndexed { index, order -> "${index + 1}. Đơn #${order.id} - ${order.total}k" }
lines.forEach(::println)
1. Đơn #1 - 120k
2. Đơn #2 - 80k
3. Đơn #3 - 50k
4. Đơn #4 - 200k
5. Đơn #5 - 45k
3.4. map trên Map
Với Map, gọi map sẽ trả về List. Nếu bạn muốn kết quả vẫn là Map, hãy dùng mapValues hoặc mapKeys:
val prices = mapOf("cafe" to 30, "tra" to 25)
val labels = prices.map { (name, price) -> "$name: ${price}k" } // List<String>
val withVat = prices.mapValues { (_, price) -> price * 110 / 100 } // Map<String, Int>
println(labels)
println(withVat)
[cafe: 30k, tra: 25k]
{cafe=33, tra=27}
4. filter: giữ lại phần tử thỏa điều kiện
4.1. filter và filterNot
filter nhận một predicate (T) -> Boolean và giữ lại những phần tử mà predicate trả về true:
val paid = orders.filter { it.status == Status.PAID }
val notCancelled = orders.filterNot { it.status == Status.CANCELLED }
println(paid.map { it.id })
println(notCancelled.map { it.id })
[1, 4, 5]
[2, 4, 5]
Khoan, dòng thứ hai sai. Đơn 1 không bị hủy nên phải có mặt. Kết quả đúng là:
[1, 4, 5]
[1, 2, 4, 5]
filterNot chỉ là cách viết dễ đọc hơn cho filter { !(...) }. Dùng nó khi điều kiện phủ định khiến code khó đọc.
4.2. partition: tách làm hai trong một lần duyệt
Nếu bạn cần cả nhóm thỏa và nhóm không thỏa, đừng gọi filter hai lần. partition trả về một Pair chứa hai list:
val (bigOrders, smallOrders) = orders.partition { it.total >= 100 }
println(bigOrders.map { it.id })
println(smallOrders.map { it.id })
[1, 4]
[2, 3, 5]
Destructuring val (a, b) giúp đặt tên rõ ràng cho từng nửa.
4.3. filterIsInstance: lọc theo kiểu
Khi làm việc với list hỗn hợp, filterIsInstance<T>() vừa lọc vừa smart cast:
sealed interface Event
data class OrderPlaced(val orderId: Int) : Event
data class OrderShipped(val orderId: Int, val carrier: String) : Event
val events: List<Event> = listOf(OrderPlaced(1), OrderShipped(1, "GHN"), OrderPlaced(2))
val shipped: List<OrderShipped> = events.filterIsInstance<OrderShipped>()
println(shipped.map { it.carrier })
[GHN]
Không cần ép kiểu thủ công bằng as. Kết quả đã có kiểu List<OrderShipped>.
5. groupBy: gom nhóm theo khóa
5.1. groupBy cơ bản
groupBy nhận một hàm chọn khóa và trả về Map<K, List<T>>:
val byCustomer: Map<String, List<Order>> = orders.groupBy { it.customer }
println(byCustomer.mapValues { (_, list) -> list.map { it.id } })
{An=[1, 3], Bình=[2, 5], Chi=[4]}
Map trả về là LinkedHashMap. Thứ tự các khóa theo lần xuất hiện đầu tiên trong list gốc, và thứ tự phần tử trong từng nhóm cũng được giữ nguyên.
5.2. groupBy kèm valueTransform
Overload thứ hai cho phép biến đổi giá trị ngay lúc gom nhóm, nên bạn không cần mapValues riêng:
val totalsByCustomer: Map<String, List<Int>> =
orders.groupBy(keySelector = { it.customer }, valueTransform = { it.total })
println(totalsByCustomer)
{An=[120, 50], Bình=[80, 45], Chi=[200]}
5.3. Tổng hợp sau khi gom nhóm
Mẫu hay dùng nhất là gom nhóm rồi tính một giá trị cho mỗi nhóm:
val revenueByStatus: Map<Status, Int> = orders
.groupBy { it.status }
.mapValues { (_, list) -> list.sumOf { it.total } }
println(revenueByStatus)
{PAID=365, NEW=80, CANCELLED=50}
5.4. groupingBy: tổng hợp không tạo list trung gian
groupBy luôn dựng đủ một List cho mỗi nhóm. Nếu bạn chỉ cần đếm hoặc cộng dồn, groupingBy hiệu quả hơn vì nó tích lũy trực tiếp:
val orderCount: Map<String, Int> = orders.groupingBy { it.customer }.eachCount()
val spentByCustomer: Map<String, Int> = orders
.groupingBy { it.customer }
.fold(0) { acc, order -> acc + order.total }
println(orderCount)
println(spentByCustomer)
{An=2, Bình=2, Chi=1}
{An=170, Bình=125, Chi=200}
groupingBy trả về một đối tượng Grouping, chưa tính toán gì cả. Phép tổng hợp chỉ chạy khi bạn gọi eachCount, fold, reduce hoặc aggregate.
5.5. groupBy khác associateBy thế nào
Hai hàm này hay bị nhầm với nhau:
val lastOrderByCustomer: Map<String, Order> = orders.associateBy { it.customer }
println(lastOrderByCustomer.mapValues { it.value.id })
{An=3, Bình=5, Chi=4}
associateBy tạo Map<K, T>, nghĩa là mỗi khóa ứng với một phần tử. Khi trùng khóa, phần tử sau ghi đè phần tử trước. Vì vậy chỉ dùng associateBy khi khóa là duy nhất, ví dụ associateBy { it.id }. Nếu khóa có thể trùng, hãy dùng groupBy.
6. Kết hợp thành pipeline
Các hàm trên nối được với nhau. Ví dụ sau tính doanh thu của các đơn đã thanh toán theo từng khách hàng, rồi xếp giảm dần:
fun paidRevenueRanking(orders: List<Order>): List<Pair<String, Int>> =
orders
.filter { it.status == Status.PAID }
.groupBy { it.customer }
.mapValues { (_, list) -> list.sumOf { it.total } }
.toList()
.sortedByDescending { it.second }
println(paidRevenueRanking(orders))
[(Chi, 200), (An, 120), (Bình, 45)]
Pipeline chạy qua các bước sau:
filtergiữ lại đơn 1, 4 và 5.groupBy+mapValuescho ra{An=120, Chi=200, Bình=45}.toList()đổiMapthànhList<Pair<String, Int>>để có thể sắp xếp.sortedByDescendingxếp theo doanh thu.
Lưu ý: Mỗi bước trên
Listlà eager. Nó duyệt toàn bộ dữ liệu và tạo một list mới. Với vài nghìn phần tử thì không đáng kể. Với dữ liệu rất lớn, hoặc khi bạn chỉ cần vài phần tử đầu (take,first), hãy chuyển sangasSequence()để xử lý lazy từng phần tử:
val firstTwoBigPaid = orders.asSequence()
.filter { it.status == Status.PAID }
.map { it.id }
.take(2)
.toList()
println(firstTwoBigPaid)
[1, 4]
Với Sequence, chuỗi xử lý dừng ngay sau khi lấy đủ 2 phần tử. Đơn số 5 không bị xét tới.
7. Viết test cho pipeline
Thêm JUnit 5 và AssertJ vào pom.xml. Project Kotlin của bạn cần có sẵn kotlin-maven-plugin:
<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>
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class OrderStatsTest {
@Test
fun `xep hang doanh thu chi tinh don da thanh toan`() {
val result = paidRevenueRanking(orders)
assertThat(result).containsExactly("Chi" to 200, "An" to 120, "Bình" to 45)
}
@Test
fun `groupingBy eachCount dem dung so don moi khach`() {
val count = orders.groupingBy { it.customer }.eachCount()
assertThat(count).containsExactlyEntriesOf(mapOf("An" to 2, "Bình" to 2, "Chi" to 1))
}
@Test
fun `filter khong thay doi list goc`() {
orders.filter { it.total > 100 }
assertThat(orders).hasSize(5)
}
}
containsExactly kiểm tra cả nội dung lẫn thứ tự. Điều này quan trọng vì kết quả xếp hạng phụ thuộc vào thứ tự. containsExactlyEntriesOf cũng so sánh thứ tự các entry, nên nó xác nhận luôn tính chất giữ thứ tự của LinkedHashMap.
8. Lỗi hay gặp
- Dùng
filterhai lần với điều kiện ngược nhau. Code duyệt list hai lần và dễ lệch điều kiện. Hãy dùngpartition. - Dùng
associateBycho khóa có thể trùng. Dữ liệu bị ghi đè mà không có lỗi nào báo ra. Đây là bug rất khó phát hiện. - Dùng
groupBychỉ để đếm hoặc cộng dồn. Cách này tạo list trung gian không cần thiết.groupingBy { }.eachCount()hoặc.fold()gọn và nhẹ hơn. - Gây side effect trong
map. Ví dụ gọipaymentService.charge(it)bên trongmap.mapdùng để biến đổi dữ liệu. Nếu bạn cần thực hiện hành động trên từng phần tử, dùngforEachđể ý đồ rõ ràng. - Dùng
map { }.filterNotNull(). Hãy thay bằngmapNotNull. - Dùng
Sequencecho mọi trường hợp. Với list nhỏ,Sequencethường chậm hơn vì tốn chi phí tạo iterator và lambda. Chỉ chuyển sangSequencekhi dữ liệu lớn, pipeline dài, hoặc có thao tác dừng sớm. - Gọi
groupBynhiều lần trên cùng dữ liệu trong vòng lặp. Hãy tính một lần, lưuMapvào biến rồi tra cứu.
Lưu ý: Khi dữ liệu đến từ database, việc lọc và gom nhóm nên làm bằng SQL (
WHERE,GROUP BY) thay vì tải hết lên bộ nhớ rồi gọifilter/groupBy. Các hàm collection phù hợp với dữ liệu đã nằm trong bộ nhớ và có kích thước vừa phải.
9. Kết luận
map biến đổi từng phần tử, filter giữ lại phần tử thỏa điều kiện, còn groupBy gom phần tử thành Map theo khóa. Mỗi hàm có các biến thể chuyên dụng:
mapNotNull,mapIndexed,mapValuescho việc biến đổi.filterNot,partition,filterIsInstancecho việc lọc.groupingBy,associateBycho việc gom nhóm và tạoMap.
Chọn đúng biến thể giúp code ngắn hơn và tránh được bug. Khi ghép thành pipeline, bạn nên viết test với AssertJ để khóa lại cả nội dung lẫn thứ tự kết quả. Hãy cân nhắc dùng Sequence khi dữ liệu lớn hoặc khi cần dừng sớm.