Chào các bạn, bài viết mình sẽ giới thiệu về giá trị và tham chiếu trong ngôn ngữ Swift

Chúng ta sẽ tìm hiểu các phần:

- Các khái niệm chính về giá trị và các loại tham chiếu
- Sự khác biệt giữa hai loại
- Cách chọn sử dụng

Bắt đầu

Đầu tiên, tạo một new playground. Trong Xcode, chọn File -> New -> Playground và đặt tên cho playground là ReferenceTypes.

Lưu ý: Bạn có thể chọn bất kỳ nền tảng nào vì hướng dẫn này không liên quan đến nền tảng và chỉ tập trung vào các khía cạnh ngôn ngữ Swift.

Loại tham chiếu so với loại giá trị

Có điều gì khác biệt cốt lõi giữa hai loại tham chiếu và giá trị? Giải thích nhanh đó là các loại tham chiếu chia sẻ một bản sao dữ liệu của chúng trong khi các loại giá trị giữ một bản sao duy nhất của dữ liệu của chúng.

Swift đại diện cho một loại tham chiếu như một lớp. Điều này tương tự như Objective-C, nơi mọi thứ kế thừa từ NSObject được lưu trữ dưới dạng kiểu tham chiếu.

Có nhiều loại giá trị trong Swift, chẳng hạn như struct, enum và tuples. Bạn có thể không nhận ra rằng Objective-C cũng sử dụng các loại giá trị theo số chữ như NSInteger hoặc thậm chí các cấu trúc C như CGPoint.

Reference Types

Các kiểu tham chiếu bao gồm các thể hiện được chia sẻ có thể được truyền qua và được tham chiếu bởi nhiều biến. Điều này được minh họa với ví dụ sau

Thêm một class vào playground của bạn:

// Reference Types:
class Dog {
  var wasFed = false
}

Class trên đại diện cho một con chó và một biến được khai báo để biết rằng liệu con chó đã được cho ăn hay chưa.

Tạo một thể hiện mới của lớp Dog của bạn bằng cách thêm vào như sau:

let dog = Dog() //Điều này chỉ đơn giản là chỉ đến một vị trí trong bộ nhớ lưu trữ Dog.

 Để thêm một đối tượng khác để giữ một tham chiếu đến cùng một con chó, hãy thêm vào như sau:

let puppy = dog

Bởi vì dog là một tham chiếu đến một địa chỉ bộ nhớ, puppy chỉ đến cùng một dữ liệu trong bộ nhớ.

Cho puppy của bạn ăn bằng cách đặt wasFed thành true:

puppy.wasFed = true

Hiện tại, dog và puppy đều chỉ đến cùng một địa chỉ bộ nhớ.


Do thay đổi một thể hiện có thể ảnh hưởng đến cái khác vì cả hai đều tham chiếu cùng một đối tượng. Để kiểm tra xem điều này có đúng không bằng cách xem các giá trị thuộc tính trong playground của bạn:

dog.wasFed     // true
puppy.wasFed   // true

Các loại giá trị

Các loại giá trị được tham chiếu hoàn toàn khác với các loại tham chiếu.

Thêm một bài tập thao tác với Int sau đây để các bạn hiểu hơn về giá trị.

// Value Types:
var a = 42
var b = a
b += 1

a    // 42
b    // 43

Bạn mong đợi a và b bằng nhau? Không, rõ ràng khi in giá trị thì a bằng 42 và b bằng 43. Nếu bạn đã khai báo chúng là loại tham chiếu, cả a và b sẽ bằng 43 vì cả hai sẽ trỏ đến cùng một địa chỉ bộ nhớ.

Điều tương tự cũng đúng với bất kỳ loại giá trị khác không phải số. Trong playground, tạo một cấu trúc Cat sau:

struct Cat {
  var wasFed = false
}

var cat = Cat()
var kitty = cat
kitty.wasFed = true

cat.wasFed        // false
kitty.wasFed      // true

Điều này cho thấy một sự khác biệt, nhưng quan trọng, giữa các loại tham chiếu và giá trị: Biến kitty nhận được một bản sao giá trị của mèo thay vì tham chiếu.

Mutability (khả năng thay đổi)

var và let khác nhau cho các loại tham chiếu và các loại giá trị. Lưu ý rằng bạn đã xác định dog và pupply là hằng số cho phép, nhưng bạn vẫn có thể thay đổi thuộc tính wasFed. Làm thế nào mà có thể?

Đối với các loại tham chiếu, let có nghĩa là tham chiếu phải không đổi. Nói cách khác, bạn không thể thay đổi thể hiện các tham chiếu, nhưng bạn có thể tự thay đổi thể hiện.

Đối với các loại giá trị, let có nghĩa là thể hiện phải không đổi. Không có thuộc tính nào của cá thể sẽ thay đổi, bất kể thuộc tính được khai báo với let hay var.

Nó dễ dàng hơn nhiều để kiểm soát khả năng biến đổi với các loại giá trị. Để đạt được các hành vi bất biến và khả năng biến đổi tương tự với các loại tham chiếu, bạn phải thực hiện các biến thể lớp bất biến và biến đổi như NSString và NSMutableString.

What Type Does Swift Favor? (Swift ưa thích tham chiếu hay giá trị)

Kết quả tìm kiếm nhanh thông qua Thư viện tiêu chuẩn Swift cho các phiên bản công khai của enum, struct và class trong Swift 1.2, 2.0 và 3.0 cho thấy sự thiên vị theo hướng của các loại giá trị:

Swift 1.2:

  • struct: 81
  • enum: 8
  • class: 3

Swift 2.0:

  • struct: 87
  • enum: 8
  • class: 4

Swift 3.0:

  • struct: 124
  • enum: 19
  • class: 3

Điều này bao gồm các loại như String, Array và Dictionary, tất cả đều được triển khai dưới dạng cấu trúc.

Which to Use and When (Tham chiếu và giá trị nên được sử dụng khi nào)

Bây giờ bạn đã biết sự khác biệt giữa hai loại, khi nào bạn nên chọn loại này hơn loại kia?

Một tình huống khiến bạn không có sự lựa chọn. Nhiều Cocoa APIs yêu cầu các lớp con NSObject, điều này buộc bạn phải sử dụng lớp. Ngoài ra, bạn có thể sử dụng các trường hợp từ Apple's Swift blog trong mục How to Choose? để quyết định nên sử dụng kiểu giá trị struct hay enum hay kiểu tham chiếu lớp. Chúng ta sẽ xem xét kỹ hơn những trường hợp này trong các phần sau.

When to Use a Value Type (Khi nào nên sử dụng loại giá trị)

Chúng ta sử dụng loại giá trị khi

- Sử dụng loại giá trị khi so sánh dữ liệu cá thể với == có ý nghĩa.

- Bạn muốn bản sao có trạng thái độc lập

- Khi dữ liệu sẽ được sử dụng trong mã trên nhiều luồng

 

When to Use a Reference Type (Khi nào nên sử dụng loại tham chiếu)

Mặc dù các loại giá trị khả thi trong vô số trường hợp, các loại tham chiếu vẫn hữu ích trong nhiều tình huống.

- Sử dụng loại tham chiếu khi so sánh danh tính cá thể với === có ý nghĩa.

- Sử dụng loại tham chiếu khi bạn muốn tạo trạng thái chia sẻ, có thể thay đổi.

Mixing Value and Reference Types (Kết hợp 2 loại giá trị và tham chiếu)

Bạn có thể thường xuyên gặp phải các tình huống trong đó các loại tham chiếu cần chứa các loại giá trị và ngược lại. Để xem một số các biến chứng này, dưới đây là một ví dụ về từng kịch bản.

- Các loại tham chiếu chứa các thuộc tính của loại giá trị
Nó khá phổ biến đối với một loại tham chiếu để chứa các loại giá trị. Một ví dụ sẽ là class Person

struct Address {
  var streetAddress: String
  var city: String
  var state: String
  var postalCode: String
}

Trong ví dụ này, tất cả các thuộc tính của Address cùng nhau tạo thành một địa chỉ vật lý duy nhất.

class Person {          // Reference type
  var name: String      // Value type
  var address: Address  // Value type

  init(name: String, address: Address) {
    self.name = name
    self.address = address
  }
}

Để xác minh hành vi, hãy thêm phần sau vào cuối playground của bạn:

// 1
let kingsLanding = Address(
  streetAddress: "1 King Way", 
  city: "Kings Landing", 
  state: "Westeros", 
  postalCode: "12345")
let madKing = Person(name: "Aerys", address: kingsLanding)
let kingSlayer = Person(name: "Jaime", address: kingsLanding)

// 2
kingSlayer.address.streetAddress = "1 King Way Apt. 1"

// 3
madKing.address.streetAddress  // 1 King Way
kingSlayer.address.streetAddress // 1 King Way Apt. 1

Đầu tiên, bạn đã tạo hai đối tượng Person mới từ cùng một thể hiện Address.

Tiếp theo, bạn đã thay đổi địa chỉ của một người.
Cuối cùng, bạn xác minh rằng hai địa chỉ là khác nhau. Mặc dù mỗi đối tượng được tạo bằng cùng một địa chỉ, việc thay đổi một đối tượng không ảnh hưởng đến đối tượng kia.

- Các loại giá trị chứa thuộc tính loại tham chiếu

Mọi thứ rất đơn giản khi tham chiếu chứa thuộc tính của các loại giá trị. Vậy điều ngược lại có dễ dàng hơn?

Thêm mã sau vào playground của bạn để thể hiện loại giá trị có chứa loại tham chiếu:

struct Bill {
  let amount: Float
  let billedTo: Person
}

Mỗi bản sao của Bill là một bản sao duy nhất của dữ liệu, nhưng nhiều phiên bản Bill sẽ chia sẻ đối tượng bills To Person. Điều này thêm một chút phức tạp trong việc duy trì ngữ nghĩa giá trị của các đối tượng của bạn. Chẳng hạn, làm thế nào để bạn so sánh hai đối tượng Bill vì các loại giá trị phải tương đương?

Sử dụng toán tử nhận dạng === kiểm tra xem hai đối tượng có cùng tham chiếu chính xác không, có nghĩa là hai loại giá trị chia sẻ dữ liệu. Đó chính xác là những gì bạn don lồng muốn khi theo ngữ nghĩa giá trị.

vậy, bạn có thể làm gì?

Nhận ngữ nghĩa giá trị từ các loại hỗn hợp

Bạn đã tạo Bill như một cấu trúc vì một lý do và làm cho nó dựa vào một thể hiện được chia sẻ có nghĩa là struct của bạn là một bản sao hoàn toàn độc đáo. Điều đó đánh bại phần lớn mục đích của một loại giá trị!

Để hiểu rõ hơn về vấn đề, hãy thêm đoạn mã sau vào cuối playground của bạn:

// 1
let billPayer = Person(name: "Robert", address: kingsLanding)

// 2
let bill = Bill(amount: 42.99, billedTo: billPayer)
let bill2 = bill

// 3
billPayer.name = "Bob"

// Inspect values
bill.billedTo.name    // "Bob"
bill2.billedTo.name   // "Bob"

Đầu tiên, bạn đã tạo một Person mới dựa trên name và address

Tiếp theo, bạn đã khởi tạo một Bill mới bằng cách sử dụng trình khởi tạo mặc định và tạo một bản sao bằng cách gán nó cho một hằng số mới.

Cuối cùng, bạn đã thay đổi tên đối tượng Person và bạn mong muốn in ra một người là Bob, một người là Robert

Tại đây, bạn có thể khiến Bill sao chép một tài liệu tham khảo duy nhất mới trong init (số tiền: biltTo :). Tuy nhiên, bạn phải viết phương thức sao chép của riêng mình, vì Person không phải là NSObject và không có phiên bản riêng.

Thêm phần sau vào phần dưới cùng của việc triển khai Bill:

init(amount: Float, billedTo: Person) {
  self.amount = amount
  // Create a new Person reference from the parameter
  self.billedTo = Person(name: billedTo.name, address: billedTo.address)
}

Nhìn vào hai dòng in ở cuối playground của bạn và kiểm tra giá trị của từng phiên bản của Bill. Bạn sẽ thấy rằng mỗi cái đều giữ giá trị ban đầu của nó ngay cả sau khi thay đổi tham số truyền vào:

bill.billedTo.name    // "Robert"
bill2.billedTo.name   // "Robert"

Một vấn đề lớn với thiết kế này là bạn có thể truy cập vào bill từ bên ngoài cấu trúc. Điều đó có nghĩa là một thực thể bên ngoài có thể thay đổi nó.

bill.billedTo.name = "Bob"

Vấn đề ở đây là ngay cả khi cấu trúc của bạn là bất biến, bất kỳ ai có quyền truy cập vào nó đều có thể thay đổi dữ liệu cơ bản của nó.

Using Copy-on-Write Computed Properties

Các loại giá trị Swift gốc có một tính năng tuyệt vời được gọi là copy-on-write. Khi được chỉ định, mỗi tham chiếu trỏ đến cùng một địa chỉ bộ nhớ. Nó chỉ có một khi các tham chiếu sửa đổi dữ liệu cơ bản mà Swift thực sự sao chép thể hiện ban đầu và thực hiện sửa đổi.

Bạn có thể áp dụng kỹ thuật này bằng cách đặt bill thành private.

Xóa các dòng kiểm tra ở cuối sân chơi:

// Remove these lines:
/*
bill.billedTo.name = "Bob"

bill.billedTo.name
bill2.billedTo.name
*/

Bây giờ, thay thế triển khai Bill hiện tại của bạn bằng mã sau:

struct Bill {
  let amount: Float
  private var _billedTo: Person // 1

  // 2
  var billedToForRead: Person {
    return _billedTo
  }
  // 3
  var billedToForWrite: Person {
    mutating get {
      _billedTo = Person(name: _billedTo.name, address: _billedTo.address)
      return _billedTo
    }
  }

  init(amount: Float, billedTo: Person) {
    self.amount = amount
    _billedTo = Person(name: billedTo.name, address: billedTo.address)
  }
}

Bạn đã tạo một biến private  _billsTo để giữ một tham chiếu đến đối tượng Person.

Tiếp theo, bạn đã tạo một thuộc tính được tính billsToForRead để trả về biến riêng cho các hoạt động đọc.

Cuối cùng, bạn đã tạo một thuộc tính được tính hóa đơn được tính hóa đơn, nó sẽ luôn tạo một bản sao Person mới, duy nhất cho các hoạt động ghi.

Nếu bạn có thể đảm bảo rằng người gọi của bạn sẽ sử dụng cấu trúc của bạn chính xác như bạn dự định, phương pháp này sẽ giải quyết vấn đề của bạn. 

Defensive Mutating Methods

Bạn sẽ phải thêm một chút mã bảo vệ ở đây. Để giải quyết vấn đề này, bạn có thể ẩn hai thuộc tính mới từ bên ngoài và tạo các phương thức để tương tác với chúng đúng cách.

Thay thế việc triển khai Bill của bạn bằng cách sau:

struct Bill {
  let amount: Float
  private var _billedTo: Person

  // 1
  private var billedToForRead: Person {
    return _billedTo
  }

  private var billedToForWrite: Person {
    mutating get {
      _billedTo = Person(name: _billedTo.name, address: _billedTo.address)
      return _billedTo
    }
  }

  init(amount: Float, billedTo: Person) {
    self.amount = amount
    _billedTo = Person(name: billedTo.name, address: billedTo.address)
  }

  // 2
  mutating func updateBilledToAddress(address: Address) {
    billedToForWrite.address = address
  }

  mutating func updateBilledToName(name: String) {
    billedToForWrite.name = name
  }

  // ... Methods to read billedToForRead data
}

Đây là những gì bạn đã thay đổi ở trên:

Bạn đã đặt cả hai thuộc tính thành private để người gọi không thể truy cập trực tiếp vào các thuộc tính.

Bạn đã thêm updateBiltToAddress và updateBillsToName để thay đổi tham chiếu Person với một địa chỉ hoặc tên mới. Cách tiếp cận này khiến người khác không thể cập nhật hóa đơn không chính xác, vì bạn đã ẩn tài sản cơ bản.

Khai báo các phương thức này là mutating nghĩa là bạn chỉ có thể gọi chúng khi bạn khởi tạo đối tượng Bill bằng cách sử dụng var thay vì let.

A More Efficient Copy-on-Write

Điều cuối cùng cần làm là cải thiện hiệu quả của mã của bạn. Bạn hiện đang sao chép loại tham chiếu Person mỗi khi bạn viết cho nó. Một cách tốt hơn là chỉ sao chép dữ liệu nếu nhiều đối tượng giữ tham chiếu đến nó.

Thay thế việc thực hiện billsToForWrite bằng cách sau:

private var billedToForWrite: Person {
  mutating get {
    if !isKnownUniquelyReferenced(&_billedTo) {
      _billedTo = Person(name: _billedTo.name, address: _billedTo.address)
    }
    return _billedTo
  }
}

isKnownUniquelyReferenced (_ :) kiểm tra xem không có đối tượng nào khác giữ tham chiếu đến tham số truyền vào. Nếu không có đối tượng nào khác chia sẻ tham chiếu, thì ở đó, bạn không cần phải tạo một bản sao và bạn trả lại tham chiếu hiện tại. Điều đó sẽ giúp bạn tiết kiệm một bản sao và nó bắt chước chính những gì Swift làm khi làm việc với các loại giá trị.

Để thấy điều này trong thực tế, sửa đổi billsToForWrite để phù hợp với những điều sau đây:

private var billedToForWrite: Person {
  mutating get {
    if !isKnownUniquelyReferenced(&_billedTo) {
      print("Making a copy of _billedTo")
      _billedTo = Person(name: _billedTo.name, address: _billedTo.address)
    } else {
      print("Not making a copy of _billedTo")
    }
    return _billedTo
  }
}

Ở đây, bạn chỉ cần thêm ghi nhật ký để bạn có thể thấy khi nào một bản sao được thực hiện.

Ở cuối playground của bạn, hãy thêm đối tượng Bill sau để kiểm tra:

var myBill = Bill(amount: 99.99, billedTo: billPayer)

Tiếp theo, cập nhật hóa đơn bằng cách sử dụng updateBillsToName (_ :) bằng cách thêm phần sau vào cuối playground của bạn:

myBill.updateBilledToName(name: "Eric") // Not making a copy of _billedTo

Vì myBill được tham chiếu duy nhất, sẽ không có bản sao nào được tạo. Bạn có thể xác minh điều này bằng cách tìm trong khu vực console:

Bạn thực sự thấy kết quả in hai lần. Điều này là do thanh bên kết quả của playground tự động giải quyết đối tượng trên mỗi dòng để cung cấp cho bạn bản xem trước. Điều này dẫn đến một quyền truy cập vào billsToForWrite từ updateBillsToName (_ :) và một quyền truy cập khác từ thanh bên kết quả để hiển thị đối tượng Person.

Bây giờ thêm phần dưới đây vào định nghĩa của myBill và phía trên lệnh gọi updateBiltToName để kích hoạt một bản sao:

var billCopy = myBill

Bây giờ bạn sẽ thấy trong trình console rằng myBill thực sự đang tạo một bản sao của _billsTo trước khi thay đổi giá trị của nó!

Trong hướng dẫn này, bạn đã học được rằng cả hai loại giá trị và tham chiếu đều có một số chức năng rất cụ thể mà bạn có thể tận dụng để làm cho mã của bạn hoạt động theo cách có thể dự đoán được. Bạn cũng đã học được cách sao chép khi ghi giữ các loại giá trị biểu diễn bằng cách chỉ sao chép dữ liệu khi cần. Cuối cùng, bạn đã học được cách tránh sự nhầm lẫn của việc kết hợp các loại giá trị và tham chiếu trong một đối tượng.