Chào mừng trở lại với series “Lộ trình học Lập trình viên iOS 2025“! Chúng ta đã cùng nhau khám phá những khái niệm nền tảng của Swift, từ những kiến thức cơ bản cho đến các phong cách lập trình khác nhau và cách quản lý bộ nhớ với ARC. Hôm nay, chúng ta sẽ đi sâu vào một chủ đề cực kỳ mạnh mẽ và linh hoạt trong Swift, đó là Closures. Hiểu và sử dụng thành thạo Closures là một bước tiến quan trọng trên con đường trở thành một lập trình viên iOS chuyên nghiệp.
Closures là một trong những tính năng cốt lõi làm cho Swift trở nên hiện đại và biểu cảm. Chúng xuất hiện ở khắp mọi nơi trong các framework của Apple như UIKit, SwiftUI, Combine, Grand Central Dispatch (GCD), và cả trong code của chính bạn. Tuy nhiên, bên cạnh sức mạnh đó, Closures cũng tiềm ẩn những thách thức, đặc biệt là về quản lý bộ nhớ. Mục tiêu của bài viết này là giúp bạn hiểu rõ Closures là gì, các cú pháp thông dụng và quan trọng nhất là cách sử dụng chúng một cách an toàn để tránh các lỗi thường gặp như Retain Cycle.
Mục lục
Closures Là Gì?
Ở dạng đơn giản nhất, Closures trong Swift tương tự như các khối (blocks) trong Objective-C hoặc lambdas trong các ngôn ngữ lập trình khác. Chúng là các khối chức năng độc lập có thể được truyền đi và sử dụng trong code của bạn.
Điểm đặc biệt của Closures so với các hàm (functions) thông thường là chúng có khả năng bắt giữ (capture) và lưu trữ tham chiếu (references) đến bất kỳ hằng số hoặc biến nào từ ngữ cảnh (context) mà chúng được định nghĩa. Điều này có nghĩa là Closures có thể truy cập và thao tác với các giá trị từ môi trường xung quanh, ngay cả khi môi trường đó không còn tồn tại nữa.
Hãy nghĩ về Closures như một “gói công việc” nhỏ gọn. Bạn định nghĩa công việc đó ở một nơi (ví dụ: trong một hàm hoặc một phương thức), gói nó lại, và sau đó có thể truyền gói công việc này cho người khác (một hàm khác, một đối tượng khác) để thực hiện nó vào một lúc nào đó trong tương lai. Cái “gói” này không chỉ chứa mã lệnh cần thực thi mà còn mang theo cả những “đồ dùng” (các biến, hằng số) mà nó cần từ nơi nó được tạo ra.
Các Biến Thể Cú Pháp Của Closures
Một trong những điều có thể gây bối rối ban đầu khi học về Closures là sự đa dạng về cú pháp của chúng. Swift cung cấp nhiều cách viết Closures, từ dài dòng tường minh cho đến ngắn gọn, tùy thuộc vào ngữ cảnh và mức độ suy luận (type inference) của trình biên dịch. Nắm vững các biến thể này sẽ giúp bạn đọc và viết code Swift hiệu quả hơn.
Cú Pháp Đầy Đủ
Đây là cú pháp đầy đủ nhất của một Closure:
{ (parameters) -> return type in
statements
}
(parameters)
: Danh sách các tham số đầu vào của Closure, giống như hàm.-> return type
: Kiểu dữ liệu trả về của Closure. Nếu Closure không trả về giá trị nào, bạn có thể dùngVoid
(hoặc bỏ qua phần này nếu kiểu trả về làVoid
).in
: Từ khóa phân tách phần đầu (parameters và return type) với phần thân (các câu lệnh).statements
: Phần thân của Closure chứa các câu lệnh cần thực thi.
Ví dụ:
let sumClosure = { (a: Int, b: Int) -> Int in
return a + b
}
print(sumClosure(5, 3)) // Output: 8
Suy Luận Kiểu (Type Inference)
Nếu ngữ cảnh sử dụng Closure đã rõ ràng về kiểu dữ liệu của tham số và kiểu trả về, Swift có thể suy luận giúp bạn, cho phép bỏ bớt phần khai báo kiểu:
let numbers = [1, 5, 3, 12, 2]
let sortedNumbers = numbers.sorted(by: { (n1: Int, n2: Int) -> Bool in
return n1 < n2
})
// Với suy luận kiểu:
let sortedNumbersInferred = numbers.sorted(by: { (n1, n2) in
return n1 < n2
})
Ở đây, phương thức sorted(by:)
mong đợi một Closure có hai tham số Int
và trả về Bool
, nên Swift tự động suy luận được kiểu dữ liệu.
Return Ngầm Định (Implicit Return)
Nếu phần thân của Closure chỉ có một biểu thức duy nhất, Closure đó có thể ngầm định trả về giá trị của biểu thức đó mà không cần dùng từ khóa return
:
// Tiếp tục ví dụ trên:
let sortedNumbersImplicitReturn = numbers.sorted(by: { (n1, n2) in
n1 < n2 // Không cần 'return'
})
Tên Tham Số Rút Gọn (Shorthand Argument Names)
Swift cung cấp tên tham số rút gọn cho các tham số của Closure theo thứ tự: $0
cho tham số đầu tiên, $1
cho tham số thứ hai, v.v. Khi sử dụng tên rút gọn, bạn cũng có thể bỏ qua phần khai báo tham số và từ khóa in
:
// Tiếp tục ví dụ trên:
let sortedNumbersShorthand = numbers.sorted(by: { $0 < $1 })
// Cực kỳ ngắn gọn!
Toán Tử Là Hàm (Operator Methods)
Nếu Closure chỉ đơn giản là áp dụng một toán tử giữa hai tham số, bạn thậm chí có thể truyền thẳng toán tử đó làm Closure:
// Tiếp tục ví dụ trên:
let sortedNumbersOperator = numbers.sorted(by: <)
// Đây là cách ngắn gọn nhất!
Trailing Closures
Nếu Closure là đối số cuối cùng (hoặc đối số duy nhất) của một hàm và nó khá dài, bạn có thể viết Closure đó bên ngoài dấu ngoặc đơn của hàm. Đây gọi là Trailing Closure:
// Ví dụ sử dụng Trailing Closure với phương thức map
let doubledNumbers = numbers.map { number in
return number * 2
}
// Hoặc ngắn gọn hơn với shorthand
let tripledNumbers = numbers.map { $0 * 3 }
Trailing Closure là cách phổ biến để làm cho code sử dụng Closure trở nên dễ đọc hơn, đặc biệt là trong các API như hoạt ảnh của UIKit:
UIView.animate(withDuration: 0.5, animations: {
// Code để thay đổi thuộc tính của View
self.myView.alpha = 0.0
}) { completed in
// Closure completion (cũng là một Trailing Closure)
if completed {
print("Animation finished!")
}
}
Khi Nào và Làm Thế Nào Để Sử Dụng Closures
Closures có rất nhiều ứng dụng trong phát triển iOS. Dưới đây là một số trường hợp phổ biến:
- Callback (Gọi lại): Đây là trường hợp sử dụng phổ biến nhất. Bạn thực hiện một tác vụ nào đó (ví dụ: tải dữ liệu từ mạng, hoàn thành hoạt ảnh) và khi tác vụ đó kết thúc, bạn muốn thực thi một đoạn code cụ thể. Thay vì chờ đợi (blocking), bạn truyền một Closure làm đối số “completion handler” hoặc “callback”. Khi tác vụ xong, nó sẽ gọi Closure này. Điều này đặc biệt hữu ích khi làm việc với các tác vụ bất đồng bộ (asynchronous tasks).
- Lưu Trữ Hành Vi (Storing Behavior): Bạn có thể lưu trữ một Closure trong một biến hoặc hằng số và gọi nó sau. Điều này cho phép bạn “đóng gói” một hành động nào đó và sử dụng lại hoặc thay đổi hành động đó một cách linh hoạt.
-
Truyền Hàm Vào Hàm Khác (Passing Functions as Arguments): Vì Closures về bản chất là các hàm không tên có thể được truyền đi, bạn có thể sử dụng chúng để truyền các đoạn logic tùy chỉnh vào các hàm hoặc phương thức thư viện. Các phương thức của Collection trong Swift (
map
,filter
,reduce
,sort
) là những ví dụ điển hình. - Xử Lý Sự Kiện (Event Handling): Trong một số trường hợp, bạn có thể sử dụng Closures để đáp ứng các sự kiện của UI thay vì dùng Target-Action hoặc Delegate (tìm hiểu thêm về Delegate pattern tại đây).
Escaping vs. Non-Escaping Closures
Khi một Closure được truyền làm đối số cho một hàm, nó có thể là non-escaping hoặc escaping. Sự khác biệt này rất quan trọng vì nó ảnh hưởng đến cách Closure bắt giữ giá trị và vấn đề quản lý bộ nhớ.
- Non-Escaping Closure (Mặc định): Closure được gọi bên trong hàm mà nó được truyền vào, trước khi hàm đó trả về. Trình biên dịch có thể thực hiện một số tối ưu hóa với non-escaping Closure. Khi một non-escaping Closure bắt giữ một giá trị từ ngữ cảnh xung quanh, Swift sẽ tạo một bản sao (copy) của giá trị đó nếu giá trị đó là một struct hoặc enum.
-
Escaping Closure: Closure được gọi sau khi hàm mà nó được truyền vào trả về. Điều này xảy ra khi Closure được lưu trữ trong một thuộc tính của đối tượng, hoặc được đưa vào hàng đợi để thực thi bất đồng bộ (ví dụ: trên một luồng khác bằng GCD hoặc async/await), hoặc được sử dụng trong một completion handler. Swift yêu cầu bạn đánh dấu các escaping Closure bằng thuộc tính
@escaping
trước kiểu dữ liệu của tham số Closure. Khi một escaping Closure bắt giữ một giá trị từ ngữ cảnh, nó sẽ tạo một tham chiếu (reference) đến giá trị đó (ngay cả đối với struct/enum) để đảm bảo giá trị đó còn tồn tại khi Closure được gọi sau này.
Ví dụ về Escaping Closure:
class DataFetcher {
var completionHandlers: [() -> Void] = []
func fetch(completion: @escaping () -> Void) {
completionHandlers.append(completion) // Lưu trữ Closure
}
func processData() {
// ... xử lý dữ liệu ...
for handler in completionHandlers {
handler() // Gọi Closure sau khi hàm fetch() đã trả về
}
completionHandlers = []
}
}
Trong ví dụ trên, Closure completion
được lưu trữ trong mảng completionHandlers
, điều này có nghĩa là nó sẽ “thoát” ra khỏi phạm vi của hàm fetch
và có thể được gọi sau này. Do đó, nó phải được đánh dấu là @escaping
.
Sử Dụng Closures Một Cách An Toàn: Quản Lý Bộ Nhớ và Capture Lists
Phần “an toàn” trong tiêu đề là cực kỳ quan trọng khi nói về Closures. Khả năng bắt giữ giá trị của Closures là nguồn gốc của sức mạnh, nhưng cũng là nguồn gốc của các vấn đề về quản lý bộ nhớ, đặc biệt là Retain Cycle (Chu Trình Tham Chiếu Mạnh).
Retain Cycle với Closures
Một Retain Cycle xảy ra khi hai hoặc nhiều đối tượng giữ tham chiếu mạnh (strong reference) đến nhau, tạo thành một vòng kín. Automatic Reference Counting (ARC) của Swift không thể giải phóng bộ nhớ cho các đối tượng này vì số lượng tham chiếu mạnh đến chúng không bao giờ về 0.
Closures có thể gây ra Retain Cycle khi một Closure bắt giữ (capture) một tham chiếu mạnh đến một đối tượng, và đối tượng đó cũng giữ một tham chiếu mạnh đến chính Closure đó (ví dụ: đối tượng lưu trữ Closure trong một thuộc tính hoặc biến).
Hãy xem xét ví dụ sau:
class HTMLElement {
let name: String
let text: String?
// Lưu trữ một Closure mà chính nó có thể tham chiếu đến `self`
// Đây là một escaping closure vì nó được lưu trữ trong thuộc tính
lazy var asHTML: () -> String = {
if let text = self.text { // Capture strong reference to self
return "<\(self.name)><\(text)</\(self.name)>" // Implicit strong capture of self
} else {
return "<\(self.name) />" // Implicit strong capture of self
}
}()
init(name: String, text: String? = nil) {
self.name = name
self.text = text
print("\(name) is being initialized")
}
deinit {
print("\(name) is being deinitialized")
}
}
Trong ví dụ này, lớp HTMLElement
có một thuộc tính asHTML
là một escaping Closure. Closure này trong phần thân của nó truy cập self.name
và self.text
. Mặc định, khi một escaping Closure bắt giữ một biến hoặc hằng số thuộc về một đối tượng, nó sẽ tạo một tham chiếu mạnh đến đối tượng đó. Vì HTMLElement
giữ một tham chiếu mạnh đến Closure asHTML
(thông qua thuộc tính lazy var asHTML
) và Closure asHTML
lại giữ một tham chiếu mạnh đến đối tượng HTMLElement
(thông qua việc truy cập self
), một Retain Cycle sẽ hình thành.
var paragraph: HTMLElement? = HTMLElement(name: "p", text: "Hello world")
print(paragraph!.asHTML()) // Vẫn hoạt động
paragraph = nil // Hy vọng đối tượng được giải phóng... nhưng không!
Bạn sẽ không thấy thông báo “p is being deinitialized” trong output, bởi vì đối tượng paragraph
và Closure asHTML
đang giữ nhau lại.
Capture Lists
Để phá vỡ Retain Cycle gây ra bởi Closures, bạn cần sử dụng Capture List. Capture List được viết ở đầu Closure, trước danh sách tham số (hoặc trước từ khóa in
nếu không có tham số). Nó xác định cách các giá trị từ ngữ cảnh xung quanh được bắt giữ bên trong Closure. Các từ khóa phổ biến trong Capture List là weak
và unowned
.
Cú pháp của Capture List:
{ [capture list] (parameters) -> return type in
statements
}
Hoặc nếu không có tham số:
{ [capture list] in
statements
}
Sử dụng weak
hoặc unowned
trong Capture List cho phép bạn bắt giữ tham chiếu đến self
(hoặc các đối tượng khác) một cách yếu (weak) hoặc không sở hữu (unowned), thay vì mạnh (strong).
class SafeHTMLElement {
let name: String
let text: String?
lazy var asHTML: () -> String = {
[weak self] in // Sử dụng [weak self] trong capture list
guard let self = self else { // Kiểm tra xem self còn tồn tại không
return ""
}
if let text = self.text {
return "<\(self.name)><\(text)</\(self.name)>"
} else {
return "<\(self.name) />"
}
}() // Lưu ý: đây vẫn là một escaping closure do lazy var
init(name: String, text: String? = nil) {
self.name = name
self.text = text
print("\(name) is being initialized")
}
deinit {
print("\(name) is being deinitialized")
}
}
Bây giờ, khi chạy đoạn code tương tự:
var safeParagraph: SafeHTMLElement? = SafeHTMLElement(name: "p", text: "Hello world")
print(safeParagraph!.asHTML())
safeParagraph = nil // Đối tượng được giải phóng!
Bạn sẽ thấy thông báo “p is being deinitialized”. Retain Cycle đã được phá vỡ.
Khi Nào Dùng weak
và Khi Nào Dùng unowned
Đây là một câu hỏi phổ biến:
-
weak
: Sử dụngweak
reference khi Closure và đối tượng mà nó bắt giữ có mối quan hệ mà vòng đời của chúng không phụ thuộc chặt chẽ vào nhau, và đối tượng có thể bị giải phóng thànhnil
trước khi Closure được gọi. Tham chiếuweak
luôn là một optional và có thể trở thànhnil
. Bạn cần kiểm tra tính hợp lệ của nó (ví dụ: dùngif let self = self
hoặcguard let self = self else { return }
) trước khi sử dụng bên trong Closure. -
unowned
: Sử dụngunowned
reference khi bạn chắc chắn rằng Closure sẽ không bao giờ được gọi sau khi đối tượng mà nó bắt giữ bị giải phóng. Mối quan hệ giữa Closure và đối tượng là “không sở hữu” (unowned) nhưng không phải là optional. Nếu đối tượng bị giải phóng và bạn cố gắng truy cập nó thông qua tham chiếuunowned
, chương trình của bạn sẽ gặp lỗi runtime (crash). Chỉ sử dụngunowned
khi bạn có sự đảm bảo mạnh mẽ về vòng đời của đối tượng, ví dụ như trong các mối quan hệ “parent-child” nơi child có unowned reference đến parent, và parent chắc chắn sẽ tồn tại lâu hơn child.
Trong hầu hết các trường hợp xử lý bất đồng bộ hoặc UI callback, nơi bạn không thể chắc chắn khi nào Closure sẽ được gọi hoặc liệu đối tượng self
có còn tồn tại hay không, sử dụng weak self
là lựa chọn an toàn và phổ biến nhất.
Dưới đây là bảng so sánh ngắn gọn:
Đặc điểm | weak | unowned |
---|---|---|
Kiểu Tham Chiếu | Yếu (Weak Reference) | Không sở hữu (Unowned Reference) |
Optional? | Có (weak self là Optional) |
Không (unowned self là Non-optional) |
Khi Nào Dùng | Khi tham chiếu có thể trở thành nil bất cứ lúc nào trước khi Closure được gọi. Vòng đời không phụ thuộc chặt chẽ. |
Khi bạn chắc chắn rằng tham chiếu sẽ không bao giờ trở thành nil trong suốt vòng đời của Closure. Vòng đời phụ thuộc chặt chẽ (ví dụ: parent-child). |
Kết quả nếu đối tượng bị giải phóng sớm | Tham chiếu trở thành nil , bạn cần kiểm tra an toàn (if let , guard let ). |
Gây ra lỗi runtime (crash) nếu cố gắng truy cập. |
Tính An Toàn | An toàn hơn trong các trường hợp không chắc chắn. | Ít an toàn hơn nếu không có sự đảm bảo mạnh mẽ về vòng đời. |
Lưu ý quan trọng: Capture List chỉ cần thiết và có tác dụng khi Closure của bạn là escaping và nó bắt giữ một tham chiếu đến một instance của lớp (ví dụ: self
). Đối với non-escaping Closures hoặc khi bắt giữ giá trị kiểu giá trị (struct, enum), bạn không cần lo lắng về Retain Cycle theo cách này.
Ví Dụ Thực Tế Về Sử Dụng Closures
Completion Handlers (Xử Lý Kết Quả Bất Đồng Bộ)
Đây là ứng dụng phổ biến nhất trong phát triển iOS:
func downloadImage(from urlString: String, completion: @escaping (UIImage?) -> Void) {
guard let url = URL(string: urlString) else {
completion(nil)
return
}
URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data, error == nil else {
completion(nil)
return
}
let image = UIImage(data: data)
// Phải dispatch về luồng chính (main thread) để cập nhật UI
DispatchQueue.main.async {
completion(image)
}
}.resume()
}
// Cách sử dụng trong ViewController (cần cẩn trọng với retain cycle nếu self được capture mạnh)
class MyViewController: UIViewController {
let imageView = UIImageView()
func loadImage() {
let imageUrl = "https://example.com/image.png"
downloadImage(from: imageUrl) { [weak self] image in // Sử dụng [weak self]
guard let self = self else { return } // Kiểm tra nil
if let image = image {
self.imageView.image = image
} else {
self.imageView.image = UIImage(systemName: "xmark.octagon.fill") // Ảnh lỗi
}
}
}
}
Trong ví dụ trên, Closure completion
được đánh dấu là @escaping
vì nó sẽ được gọi sau khi hàm downloadImage
trả về (khi tác vụ mạng hoàn thành). Bên trong Closure, chúng ta sử dụng [weak self]
để tránh Retain Cycle giữa ViewController và Closure.
Để hiểu rõ hơn về đa luồng và DispatchQueue.main.async
, bạn có thể đọc bài viết về Đa luồng trong Swift: GCD hay async/await?.
Sử Dụng Với Các Phương Thức Của Collection
Các phương thức như map
, filter
, reduce
, sort
đều nhận Closures làm đối số để tùy chỉnh hành vi của chúng. Các Closures này thường là non-escaping.
let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
// Filter: Lọc ra các số chẵn
let evenNumbers = numbers.filter { $0 % 2 == 0 }
print(evenNumbers) // Output: [2, 4, 6, 8, 10]
// Map: Nhân đôi tất cả các số
let doubledNumbers = numbers.map { $0 * 2 }
print(doubledNumbers) // Output: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
// Reduce: Tính tổng tất cả các số
let sum = numbers.reduce(0) { (currentSum, number) in
currentSum + number
}
print(sum) // Output: 55
// Sort: Sắp xếp giảm dần
let sortedDescending = numbers.sorted { $0 > $1 }
print(sortedDescending) // Output: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
Những ví dụ này cho thấy cách Closures giúp code trở nên ngắn gọn, biểu cảm và mang tính lập trình hàm (functional programming) hơn.
Kết Luận
Closures là một phần không thể thiếu trong Swift và là công cụ cực kỳ mạnh mẽ trong tay lập trình viên iOS. Chúng mang lại sự linh hoạt để đóng gói và truyền đi các khối chức năng, làm cho việc xử lý bất đồng bộ, thao tác trên collections và nhiều tác vụ khác trở nên thanh lịch hơn.
Tuy nhiên, với sức mạnh đó đi kèm trách nhiệm. Hiểu rõ sự khác biệt giữa escaping và non-escaping Closures, và đặc biệt là cách sử dụng Capture Lists (weak
và unowned
) để tránh Retain Cycle, là kiến thức bắt buộc để viết các ứng dụng iOS ổn định và hiệu quả về bộ nhớ.
Hãy dành thời gian thực hành với Closures trong các dự án của bạn. Bắt đầu với các ví dụ đơn giản, sau đó áp dụng chúng vào các tác vụ phức tạp hơn như completion handlers trong gọi API hay xử lý dữ liệu với các phương thức của Collection. Nếu gặp khó khăn, hãy luôn kiểm tra xem Closure của bạn có phải là escaping không và liệu nó có bắt giữ tham chiếu mạnh đến self
(hoặc các đối tượng lớp khác) mà có thể tạo ra chu trình không.
Nắm vững Closures là một cột mốc quan trọng trên hành trình Lộ trình học Lập trình viên iOS 2025 của bạn. Tiếp theo, chúng ta sẽ tiếp tục khám phá các chủ đề quan trọng khác. Hẹn gặp lại trong bài viết tới!