CodeGym /Các khóa học /Kotlin SELF /Khả năng truy cập và phạm vi: private/public

Khả năng truy cập và phạm vi: private/public

Kotlin SELF
Mức độ, Bài học
Có sẵn

1. Vì sao cần phạm vi (scope)

Khi bạn viết một chương trình nhỏ khoảng 20 dòng, thường thì mọi thứ nằm hết trong main, và có vẻ như không cần quy tắc đặc biệt nào: “mình vẫn thấy toàn bộ mã”. Nhưng ngay khi bạn bắt đầu tách logic ra thành các hàm (và điều đó là đúng), trong file sẽ xuất hiện ngày càng nhiều tên: printMenu, readChoice, sumExpenses, formatMoney và còn nữa. Lúc này bộ não của bạn bắt đầu hoạt động như trình biên dịch: “cái nào mình được phép dùng? nó được khai báo ở đâu? tại sao ở đây lại không thấy?”.

Để bộ não không bị quá tải (và để trình biên dịch không “chiêu đãi” bạn bằng Unresolved reference), trong Kotlin có hai cơ chế để giữ trật tự:

  1. Visibility modifiers (public, private) trả lời câu hỏi: ai có thể sử dụng khai báo này từ bên ngoài?
  2. Scope (phạm vi) trả lời câu hỏi: tại vị trí nào trong mã nguồn tên này tồn tại và có thể truy cập?

Những từ này nghe có vẻ giống nhau, nhưng ý nghĩa khác nhau. Hãy tách chúng ra rõ ràng.

Khả năng truy cập: public và private

Khi chúng ta nói về “khả năng truy cập”, ta đang bàn về ranh giới truy cập. Nó không còn là câu chuyện biến được khai báo ở đâu, mà là ai có quyền gọi/đổi tới nó. Hãy tưởng tượng file của bạn là một “cửa hàng các hàm” nhỏ. Một số hàm là qua trưng bày: người dùng vào và thấy chúng. Còn một số hàm là phòng hậu cần: chỉ nhân viên mới vào, khách hàng không nên vào đó (và thực ra sẽ có hại nếu vào nhầm).

Trong phạm vi bài giảng này, chúng ta chỉ cần hai modifier:

  • public — có thể truy cập “từ bên ngoài”. Ở mức file (top-level) thì đây là giá trị mặc định, nghĩa là thường bạn không cần viết public.
  • private — chỉ truy cập được bên trong chính file này (nếu đó là hàm hoặc biến top-level).

Điều này đặc biệt hữu ích khi bạn có các hàm trợ giúp: chúng cần thiết để triển khai kịch bản, nhưng bạn không muốn ai đó vô tình bắt đầu gọi chúng như “API chính thức”.

Trong Kotlin, cách tiếp cận rất phổ biến đối với visibility là giấu các hàm phụ để nhập liệu bằng private, để không làm bẩn “qua trưng bày công khai” của file bị loạn: bạn có thể gặp cách làm này kể cả trong các mẫu (template) thực hành, nơi người ta viết private fun readInt() = ....

Ví dụ nhỏ: hàm công khai và trợ giúp private

fun main() {
    runApp()
}

private fun runApp() {
    printHeader()
    println("Ung dung da khoi chay")
}

private fun printHeader() {
    println("=== BudgetBuddy ===") // === BudgetBuddy ===
}

Ở đây main() mặc định là public (không viết nhưng ngầm hiểu), còn runApp() và printHeader() là chi tiết nội bộ. Nếu file là một phần của dự án lớn, chúng ta sẽ không muốn ai đó “bên ngoài” gọi printHeader() trực tiếp.

2. Phạm vi (scope): nơi tên tồn tại

Bây giờ đến scope — đây không còn là “có được phép không”, mà là “có thấy/tồn tại không”. Kể cả khi cái gì đó là public, nó cũng không tự nhiên có thể dùng ở mọi nơi trên đời: tên vẫn chỉ truy cập được trong một phạm vi mã nguồn cụ thể.

Suy nghĩ thường gặp nhất của người mới là: “Mình đã khai báo biến rồi, sao mình không thể dùng nó ở bên dưới?” Trả lời: vì bạn khai báo nó bên trong một khối, mà khối kết thúc thì biến “chết” (đừng lo, đó là cái chết bình thường và thực ra còn rất hữu ích).

Trong Kotlin, scope thường được xác định bởi cặp ngoặc nhọn { ... }. Và đây không phải ngoặc trang trí, mà chính là ranh giới “tuổi thọ” của các tên.

Ví dụ: biến chỉ sống bên trong if

fun main() {
    val x = 10

    if (x > 0) {
        val msg = "x la so duong"
        println(msg) // x la so duong
    }

    println(msg) // Loi bien dich: msg o day da khong con ton tai
}

msg được khai báo bên trong khối if. Sau dấu đóng ngoặc đóng }, tên msg biến mất: bạn không thể truy cập nó, kể cả khi bạn rất muốn.

Nguyên tắc tương tự với while và for

fun main() {
    var sum = 0

    for (i in 1..3) {
        val add = i * 10
        sum += add
    }

    println(sum) // 60
    println(add) // Loi: add chi thay ben trong than vong lap
}

Biến add chỉ tồn tại bên trong thân vòng lặp. Điều này giúp bạn tránh việc vô tình sử dụng biến “tạm thời” ở nơi mà nó không còn ý nghĩa.

3. Một số nuance hữu ích

private/public và scope không phải là một

Rất dễ bị nhầm, nên hãy củng cố bằng một so sánh dễ hiểu. Hãy tưởng tượng bạn có chìa khóa và tường.

Scope là tường của căn phòng. Nếu bạn ra khỏi phòng, bạn không thấy những đồ vật nằm bên trong.
Visibility (private/public) là chìa khóa của cánh cửa. Kể cả khi đồ vật nằm trong phòng, bạn vẫn có thể không có quyền vào đó.

Tức là:

  • tên có thể nằm trong phạm vi (nó “sống ở đây”), nhưng lại không truy cập được vì private (ví dụ: từ file khác);
  • tên có thể là public, nhưng vẫn không tồn tại ở vị trí mã nguồn hiện tại (nếu bạn đã ra khỏi khối).

Để không phải nhớ theo kiểu “như một no bot”, hãy nhìn bảng tổng hợp.

Chủ đề đang bàn Câu hỏi Ví dụ Cái gì “điều khiển”
Phạm vi (scope) “Tên tồn tại ở đâu?”
{
    val t = 1
}
Ngoặc nhọn, cấu trúc mã nguồn
Khả năng truy cập (visibility) “Ai có thể sử dụng?”
private fun helper()
Các modifier private/public

Hàm cục bộ và phạm vi

Trong bài trước, bạn đã gặp hàm cục bộ: đó là các hàm được khai báo bên trong một hàm khác. Đây là lúc để hiểu chúng qua góc nhìn scope: hàm cục bộ chỉ thấy bên trong hàm bên ngoài. Bên ngoài nó, nó không tồn tại.

Điều này thường rất tiện: bạn không làm file bị chật chội bởi cả chục “helperSomething”, nếu chúng chỉ cần cho một kịch bản.

Ví dụ: hàm cục bộ để định dạng tiền

fun main() {
    printReceipt(totalCents = 2599)
}

fun printReceipt(totalCents: Int) {
    fun formatMoney(cents: Int): String {
        val dollars = cents / 100
        val rest = cents % 100
        return "$dollars.${rest.toString().padStart(2, '0')}"
    }

    println("Tong cong: ${formatMoney(totalCents)}") // Tong cong: 25.99
}

formatMoney là “nhân viên nội bộ”. Nó chỉ tồn tại bên trong printReceipt. Bên ngoài không thể gọi (và đây là điểm cộng: ít cách để vô tình làm hỏng thứ gì đó).

4. Che khuất tên: shadowing

Nếu bạn thích tìm “bẫy”, bạn có lẽ sẽ tự hỏi: nếu trong cùng phạm vi có nhiều biến trùng tên thì sẽ ra sao?

Bạn có thể khai báo một biến có cùng tên với một biến ở mức cao hơn. Điều này gọi là che khuất tên (shadowing). Trình biên dịch thường cho phép, nhưng độ dễ đọc gần như luôn giảm.

Nguy hiểm lớn nhất không phải là “chương trình không chạy”, mà là về sau bạn tự đọc mã và tự hỏi: “thế x này là x nào?”

Ví dụ: trùng tên ở các khối khác nhau

fun main() {
    val count = 10

    run {
        val count = 3
        println(count) // 3
    }

    println(count) // 10
}

Bên trong khối run { ... }, count là một count khác. Biến bên ngoài không biến mất, nhưng nó bị “che” bên trong khối. Về kỹ thuật, đôi khi cần; nhưng trong dự án học tập và ứng dụng, thường chỉ là nhầm lẫn ngẫu nhiên.

5. Dự án BudgetBuddy và trật tự về visibility

Giờ chúng ta sẽ áp dụng private/public và scope một cách thực tế. Giả sử chúng ta có một ứng dụng console đơn giản BudgetBuddy: lưu chi tiêu trong mảng, thêm các khoản mới và in thống kê. Chúng ta làm tất cả trong một file, để không bị cuốn sang chủ đề cấu trúc dự án và package.

Thống nhất về style trước: từ bên ngoài chỉ cần điểm vào main(). Mọi thứ còn lại là chi tiết triển khai, vậy hợp lý nhất là làm chúng private, để file trông như một “mini-module” gọn gàng.

Bước 1: qua trưng bày của file — chỉ có main()

fun main() {
    runBudgetBuddy()
}

private fun runBudgetBuddy() {
    println("=== BudgetBuddy ===") // === BudgetBuddy ===
}

Ngay từ bước này, file đã dễ đọc: main chạy kịch bản, còn kịch bản được giấu vào bên trong.

Bước 2: thêm state và minh họa scope của biến

Chúng ta sẽ lưu chi tiêu trong IntArray, mỗi số là số tiền tính theo cent (để khỏi phải dùng Double và vấn đề độ chính xác).

fun main() {
    runBudgetBuddy()
}

private fun runBudgetBuddy() {
    val expenses = IntArray(5)
    var size = 0

    println("Them mot so chi tieu test...")
    expenses[0] = 199
    expenses[1] = 550
    size = 2

    println("So ban ghi: $size") // So ban ghi: 2
}

Ở đây expenses và size chỉ sống bên trong runBudgetBuddy. Điều đó bình thường: đây là state nội bộ của kịch bản.

Bước 3: tách thành hàm và giấu các helper

Thêm menu và chọn lệnh. Lưu ý: chúng ta không làm “sản phẩm hoàn hảo”, chúng ta đang luyện visibility và phạm vi tên.

fun main() {
    runBudgetBuddy()
}

private fun runBudgetBuddy() {
    println("=== BudgetBuddy ===") // === BudgetBuddy ===

    while (true) {
        printMenu()
        val cmd = readln().trim()

        if (cmd == "exit") return
        println("Lenh: $cmd") // Lenh: ...
    }
}

private fun printMenu() {
    println()
    println("Nhap lenh: add / list / exit")
}

printMenu() là helper “thuần”. Không ai ngoài file cần đến nó, nên private phù hợp tuyệt đối.

Nhân tiện, thói quen đánh dấu các hàm như vậy là private cũng hữu ích kể cả trong bài “một file”: bạn không để file biến thành đống “các hàm public ngẫu nhiên”. Cách làm dùng private cho các hàm phụ trợ thường được dùng trong template nhập liệu (ví dụ, private fun readInt()), để tránh tạo quá nhiều khai báo public.

Bước 4: khối và scope bên trong if/else if

Và ở đây thấy rõ khối và scope hoạt động ra sao.

fun main() {
    runBudgetBuddy()
}

private fun runBudgetBuddy() {
    val expenses = IntArray(10)
    var size = 0

    while (true) {
        printMenu()
        val cmd = readln().trim()

        if (cmd == "add") {
            val added = addExpense(expenses, size)
            size += added
        } else if (cmd == "list") {
            printExpenses(expenses, size)
        } else if (cmd == "exit") {
            return
        } else {
            println("Lenh khong hop le: $cmd")
        }
    }
}

private fun printMenu() {
    println()
    println("Nhap lenh: add / list / exit")
}

Hãy nhìn kỹ: biến cmd sống bên trong thân vòng lặp while. Còn biến added chỉ sống bên trong khối if (cmd == "add") { ... }. Điều này rất tốt: chúng ta sẽ không thể vô tình dùng added trong nhánh "list".

Bây giờ thêm các hàm (ngắn gọn, rõ ràng):

private fun addExpense(expenses: IntArray, size: Int): Int {
    if (size >= expenses.size) {
        println("Mang da day, khong the them nua")
        return 0
    }

    print("Nhap so tien theo cent: ")
    val cents = readln().trim().toInt()
    expenses[size] = cents
    return 1
}

private fun printExpenses(expenses: IntArray, size: Int) {
    println("Cac khoan chi:")
    for (i in 0 until size) {
        println("- ${expenses[i]} cents")
    }
}

Hãy chú ý một chi tiết nhỏ nhưng quan trọng: size trong addExpense là tham số. Tham số hoạt động giống như val: bạn không thể gán lại chúng. Nhưng expenses[size] = cents thì được, vì đó là thay đổi một phần tử của mảng, không phải gán lại tham số.

Nhưng vì sao thay đổi này lại thấy được trong runBudgetBuddy? Hãy nhớ lại val alias = original trong bài giảng về mảng: phép gán không sao chép mảng, mà đặt thêm một cái tên thứ hai cho chính mảng đó. Với đối số của hàm cũng vậy: khi gọi addExpense(expenses, size), tham số expenses nhận một tham chiếu tới chính mảng đã được tạo trong runBudgetBuddy. Vì vậy, phần tử được ghi bên trong hàm cũng “xuất hiện” ở bên ngoài. Còn size chỉ là một con số: hàm nhận bản sao giá trị của nó và không thể thay đổi biến size trong runBudgetBuddy. Đó là lý do addExpense trả về 1 hoặc 0, còn việc tăng size do chính runBudgetBuddy đảm nhận: size += added.

Bước 5: giấu phần định dạng và tránh che khuất tên

Làm phần định dạng tiền thành một hàm private. Có thể làm thành hàm cục bộ, nhưng nó sẽ dùng ở nhiều nơi, nên hãy để private ở mức file.

private fun formatMoney(cents: Int): String {
    val dollars = cents / 100
    val rest = cents % 100
    return "$dollars.${rest.toString().padStart(2, '0')}"
}

private fun printExpenses(expenses: IntArray, size: Int) {
    println("Cac khoan chi:")
    for (i in 0 until size) {
        println("- ${formatMoney(expenses[i])}")
    }
}

Ở đây cần nhấn mạnh: rest là tên bên trong formatMoney. Nó không “rò rỉ” ra bên ngoài. Và thế là rất tốt: càng ít tên sống lâu, bạn càng ít vô tình đạp phải chúng ở nơi khác.

Sơ đồ nhỏ: cách đọc file với các helper private

Khi bạn bố trí file sao cho chỉ có main() “lối ra bên ngoài”, còn lại là chi tiết, việc đọc sẽ thành tuyến tính: “khởi động → kịch bản → helper”.

flowchart TD
    A["public main()"] --> B["private runBudgetBuddy()"]
    B --> C["private printMenu()"]
    B --> D["private addExpense(...)"]
    B --> E["private printExpenses(...)"]
    E --> F["private formatMoney(...)"]

File như vậy dễ bảo trì hơn: bạn thấy ngay hàm nào là “chính”, hàm nào là phụ trợ.

6. Lỗi thường gặp

Lỗi 1: nhầm lẫn private với scope, và mong rằng private sẽ “giấu biến khỏi khối bên cạnh”.
Đôi khi bạn cảm thấy nếu đặt cái gì đó là private, thì nó sẽ biến mất “ở đâu đó gần đây”. Nhưng private/public là về truy cập giữa các file (và các phần bên ngoài khác của dự án), không phải về các khối { ... }. Chính scope mới giới hạn khối: biến bên trong if không thấy bên ngoài không phải vì nó “private”, mà vì khối đã kết thúc.

Lỗi 2: khai báo biến trong khối, rồi cố gắng dùng nó sau khi khối kết thúc.
Tình huống kinh điển: bạn đọc input trong if, tạo val value, rồi ở dưới muốn in ra. Trình biên dịch sẽ nói đúng: Unresolved reference. Cách giải quyết thường rất đơn giản: khai báo biến trước khối, còn bên trong khối thì gán giá trị. Khi đó tên sẽ sống lâu hơn, và bạn chủ động chọn “tuổi thọ” của nó.

Lỗi 3: che khuất tên (shadowing) “cho tiện”, rồi về sau thành nhầm lẫn.
Người mới đôi khi khai báo lại val x bên trong khối vì “mình thấy dễ hơn”. Đúng, dễ bây giờ — nhưng 15 phút sau bạn sẽ tự hỏi x nào đang được dùng ở dòng cụ thể. Tốt hơn là đặt tên khác: userChoice, menuChoice, newChoice — dài hơn một chút, nhưng bộ não không phải làm thám tử.

Lỗi 4: để mọi thứ là public chỉ vì “thế là chạy”.
Nếu bạn để tất cả hàm top-level là public, file sẽ thành tập hợp các “lời hứa bên ngoài” ngẫu nhiên. Kể cả khi dự án hiện tại nhỏ, thói quen giấu helper bằng private giúp kỷ luật hóa thiết kế: có kịch bản đầu vào và có “nhà bếp” bên trong. Kết quả là mã dễ đọc hơn và khó bị vô tình dùng nhầm “hàm không nên gọi”.

Lỗi 5: tạo quá nhiều biến có scope quá lớn “phòng trừ”.
Đôi khi để chống lỗi visibility, người mới khai báo hết mọi thứ ngay đầu hàm: var a, var b, var c — rồi gán giá trị dần dần trong quá trình. Cách này chạy, nhưng làm mã “đục”: không rõ khi nào biến thực sự cần, và nó đang ở trạng thái nào. Dễ chịu hơn là đặt biến gần nơi sử dụng nhất có thể, và để scope giới hạn tuổi thọ của chúng.

1
Nhiệm vụ
Kotlin SELF, mức độ, bài học
Đã khóa
Bếp ẩn
Bếp ẩn
1
Nhiệm vụ
Kotlin SELF, mức độ, bài học
Đã khóa
Phạm vi
Phạm vi
1
Nhiệm vụ
Kotlin SELF, mức độ, bài học
Đã khóa
In hóa đơn
In hóa đơn
1
Nhiệm vụ
Kotlin SELF, mức độ, bài học
Đã khóa
Phiếu giảm giá không che khuất biến
Phiếu giảm giá không che khuất biến
1
Khảo sát/đố vui
, cấp độ , bài học
Không có sẵn
Ham va cau truc cua chung
Ham va cau truc cua chung
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION