Trang chủ

Sequence và Collection

Hệ thống phân cấp, và mỗi bậc hứa hẹn điều gì

Các protocol collection là phần được thiết kế tốt nhất trong thư viện chuẩn Swift, và cũng là phần người ta dùng nhiều nhất mà chẳng bao giờ tự tuân thủ. Hiểu được hệ thống phân cấp này bạn lời hai lần: kiểu của bạn được tặng hàng trăm phương thức, và bạn cuối cùng cũng hiểu vì sao Array với String lại hành xử như vậy.

Hệ thống phân cấp

  • Sequence — duyệt được ít nhất một lần. Lời hứa chỉ có thế.
  • Collection — duyệt được nhiều lần, có chỉ mục, count hữu hạn.
  • BidirectionalCollection — lùi được, nên lastreversed() rẻ.
  • RandomAccessCollection — cộng trừ chỉ mục là O(1), nên countshuffled() rẻ.
  • MutableCollection — thay được phần tử (không chèn, không xoá).
  • RangeReplaceableCollection — chèn và xoá được; đây là chỗ ở của appendremove(at:).

Mỗi bậc là một lời hứa về chi phí, không chỉ về khả năng. Đó là điểm thiết kế đáng mang theo: RandomAccessCollection không thêm phương thức nào mà bạn không tự viết được — nó hứa rằng chúng là O(1), nhờ vậy các thuật toán generic mới chọn được chiến lược tốt hơn.

Cảnh báo

Một Sequence có thể chỉ duyệt được một lần. AsyncStream, một phản hồi mạng, Zip2Sequence dựng trên iterator — duyệt lần thứ hai có thể chẳng còn gì. Nếu thuật toán của bạn cần duyệt hai lượt, hãy ràng buộc vào Collection, đừng dừng ở Sequence.

Tuân thủ cho đúng

Để một kiểu trở thành Collection, hãy cung cấp startIndex, endIndex, index(after:) và một subscript. Bạn được tặng map, filter, reduce, first, contains, sorted, prefix, dropLast và phần còn lại.

struct RingBuffer<Element> {
    private var storage: [Element?]
    private var head = 0
    private(set) var count = 0
}

extension RingBuffer: Collection {
    var startIndex: Int { 0 }
    var endIndex: Int { count }

    func index(after index: Int) -> Int { index + 1 }

    subscript(position: Int) -> Element {
        precondition(position < count, "chỉ mục vượt phạm vi")
        return storage[(head + position) % storage.count]!
    }
}

Toàn bộ phần tuân thủ chỉ có vậy, và RingBuffer giờ chạy được với for … in, với so khớp mẫu, với sorted(), và với mọi hàm generic mà người khác đã viết cho Collection.

Để ý kiểu chỉ mục ở đây là Int, nhưng không nhất thiết phải thế — String.Index tồn tại chính vì chỉ mục của một String là độ lệch byte, chỉ có nghĩa với đúng chuỗi đã sinh ra nó. Dictionary.Index mờ đục cũng vì lý do ấy. Khi vùng lưu trữ của bạn không phải một mảng phẳng, một kiểu chỉ mục mờ mới là lựa chọn trung thực.

Tính lười

Mỗi lần nối chuỗi map/filter là một lần cấp phát mảng mới:

let result = hugeArray.map(transform).filter(isInteresting).first

Đoạn đó dựng một mảng to bằng hugeArray, rồi dựng mảng thứ hai, rồi vứt cả hai đi để lấy đúng một phần tử. lazy biến chuỗi đó thành một khung nhìn tính toán theo nhu cầu:

let result = hugeArray.lazy.map(transform).filter(isInteresting).first

Giờ transform chỉ chạy tới khi tìm được phần tử đầu tiên thoả mãn, và không cấp phát gì cả. Cái giá là tính lười lan truyền và kiểu trở nên dài dòng (LazyFilterSequence<LazyMapSequence<…>>), và nếu bạn duyệt một chuỗi lười hai lần thì phần việc cũng chạy hai lần. Hãy dùng nó khi cần thoát sớm trên dữ liệu lớn; bỏ qua nó khi dù sao bạn cũng sẽ tiêu thụ hết.

Slice dùng chung vùng nhớ

prefix, suffix, dropFirst và subscript theo khoảng đều trả về slice, mà một slice giữ cho toàn bộ vùng đệm gốc sống:

func firstLine(of file: [UInt8]) -> ArraySlice<UInt8> {
    file.prefix { $0 != 0x0A }
}

Giữ slice đó là cả tệp nằm nguyên trong bộ nhớ. Nếu slice sẽ sống lâu hơn nguồn của nó, hãy sao chép: Array(file.prefix(…)).

Cái bẫy còn lại của slice là chỉ mục. Slice giữ nguyên chỉ mục của nguồn, nên nó không bắt đầu từ 0:

let numbers = [10, 20, 30, 40]
let tail = numbers.dropFirst(2)
tail[0]                // sập — startIndex của slice là 2
tail[tail.startIndex]  // 30

Viết [0] lên bất cứ thứ gì có thể là slice là một lỗi đang chờ ngày đổi tên biến. Hãy dùng first, startIndex, hoặc duyệt.