Trang chủ

Giá trị và tham chiếu

Sao chép, copy-on-write, và cái giá của việc dùng chung

Quyết định thiết kế trung tâm của Swift là: gần như mọi thứ đều là giá trị. Int, String, Array, Dictionary, mọi struct và mọi enum — gán một cái là bên nhận có bản sao của riêng nó. Class là ngoại lệ: gán một class là bạn có thêm một cái tên trỏ vào cùng một đối tượng.

Hệ quả người ta hay nhắc là “struct thì sao chép, class thì dùng chung”. Hệ quả thật sự quan trọng lại nằm ở chỗ lập luận: với một giá trị, không phần nào khác của chương trình có thể đổi thứ bạn đang cầm. Không ai có tham chiếu để thò tay vào.

struct Point { var x: Int, y: Int }

var a = Point(x: 0, y: 0)
var b = a
b.x = 10
print(a.x)  // 0 — không thể chạm tới `a` thông qua `b`

Đổi struct thành class thì a.x in ra 10. Mọi hàm nhận a trước đó giờ đang giữ một thứ khác với thứ nó được trao, mà chẳng có gì trong hệ thống kiểu đánh dấu khả năng ấy.

Phép sao chép không như bạn tưởng

“Sao chép mảng là sao chép từng phần tử” sẽ khiến Swift không dùng được — truyền một mảng triệu phần tử vào hàm hoá ra là memcpy một triệu phần tử. Thực tế không phải vậy. Array, String, DictionarySetkiểu giá trị bọc quanh một tham chiếu. Bản thân struct chỉ là phần đầu mỏng chứa con trỏ tới vùng đệm trên heap. Sao chép struct là sao chép con trỏ và tăng bộ đếm tham chiếu.

Vùng đệm chỉ bị nhân đôi khi có ai đó ghi vào một bản sao — copy-on-write.

var first = [1, 2, 3]
var second = first          // không cấp phát; cả hai trỏ chung một vùng đệm
second.append(4)            // giờ mới nhân đôi, vì vùng đệm đang được dùng chung

Bạn cũng cài được cơ chế đó, và với kiểu nào ôm dữ liệu lớn thì nên làm:

struct Pixels {
    private final class Buffer {
        var data: [UInt8]
        init(data: [UInt8]) { self.data = data }
    }

    private var buffer: Buffer

    init(data: [UInt8]) { buffer = Buffer(data: data) }

    var data: [UInt8] { buffer.data }

    mutating func setPixel(at index: Int, to value: UInt8) {
        if !isKnownUniquelyReferenced(&buffer) {
            buffer = Buffer(data: buffer.data)
        }
        buffer.data[index] = value
    }
}

isKnownUniquelyReferenced chính là toàn bộ cơ chế. Nếu giá trị này đang giữ tham chiếu duy nhất tới vùng đệm thì sửa tại chỗ. Nếu còn ai khác đang nhìn thì nhân đôi trước đã. Để ý lớp buffer là finalprivate: tính duy nhất chỉ có nghĩa khi không lớp con nào và không code bên ngoài nào giữ được một tham chiếu mà bạn không lường trước.

Cảnh báo

isKnownUniquelyReferenced luôn trả về false với đối tượng Objective-C. Một lớp bọc copy-on-write quanh lớp con NSObject nhập từ Objective-C sẽ sao chép ở từng lần ghi một.

mutating là viết đè, không phải sửa tại chỗ

Một phương thức mutating trên struct không sửa giá trị tại chỗ theo cách phương thức của class làm. Nó nhận giá trị dưới dạng inout, mà inout nghĩa là “chép vào, rồi chép ngược ra” — xét về mặt ngữ nghĩa. Trình tối ưu thường biến việc đó thành cập nhật tại chỗ, nhưng ngữ nghĩa mới là thứ ngôn ngữ bảo đảm, và nó giải thích những luật trông rất khó hiểu:

struct Counter {
    var count = 0
    mutating func increment() { count += 1 }
}

let fixed = Counter()
// fixed.increment()   // lỗi: `fixed` là `let`

Lời gọi bị từ chối không phải vì phương thức chạm vào bộ nhớ, mà vì gọi nó đồng nghĩa với gán một Counter mới ngược lại cho fixed.

Đây cũng là lý do phương thức mutating không dùng được qua một existential đang nằm trong let, và vì sao nó không thể để self thoát ra ngoài vào một closure.

Chọn cái nào

Mặc định hãy dùng struct. Dùng class khi rơi vào một trong các trường hợp sau:

  • Danh tính có ý nghĩa. Hai giá trị User giống hệt nhau từng trường là cùng một người dùng; hai đối tượng URLSession thì không thể thay cho nhau dù giống nhau đến đâu.
  • Thứ đó đúng là trạng thái chia sẻ có thể thay đổi — một bộ đệm, một pool kết nối, một thứ mà nhiều phần chương trình cố ý cùng quan sát.
  • Bạn cần kế thừa, hoặc Objective-C bắt buộc phải là class.

Lỗi tôi gặp nhiều nhất lại ngược với thứ các quy tắc phong cách hay cảnh báo. Nó không phải “lạm dụng class”. Nó là một struct ôm bên trong một tham chiếu class rồi âm thầm mang ngữ nghĩa tham chiếu vào bằng cửa sau:

final class Session { var token: String? }

struct Client {          // nhìn như một giá trị
    let session: Session // thật ra thì không
}

Sao chép một Client là bạn có hai client dùng chung một token. Kiểu nói rằng đây là giá trị; hành vi nói rằng đây là tham chiếu. Nếu đó đúng là điều bạn muốn, hãy khai báo hẳn thành class để người đọc nhìn thấy.