0

Slicing Swift collections

Mục tiêu của swift là trở thành một ngôn ngữ lập trình thực sự có mục đích chung, quy mô được scale từ from high-level tasks, like building UIs and scripting, cho đến low-level systems programming. Đó là một mục tiêu đầy tham vọng, và có thể nói là một mục tiêu chưa hoàn thành, nhưng có một số khía cạnh của thiết kế Swift mang lại cho nó những đặc điểm có khả năng mở rộng cao. Một khía cạnh như vậy là làm thế nào standard library hết sức cẩn thận để làm việc với các built-in collections của nó hiệu quả nhất có thể - bằng cách giảm thiểu số lượng tình huống mà các thành phần của chúng cần được copied, mutated và moved. Tuy nhiên, giống như hầu hết các tối ưu hóa, sử dụng đầy đủ các hành vi đó cũng đòi hỏi chúng ta phải viết code theo những cách cụ thể - đặc biệt là khi truy cập các sliced hoặc tập hợp con của một collection nhất định. Đó chính xác là những gì chúng ta sẽ xem xét trong bài viết này.

A slice of a binary pie

Trong Swift, một slice là một loại bộ sưu tập đặc biệt không thực sự lưu trữ bất kỳ thành phần nào của riêng nó, mà hoạt động như một proxy (hoặc view) để cho phép chúng tôi truy cập và làm việc với một tập hợp con của một bộ sưu tập khác như một ví dụ riêng biệt. Một ví dụ rất đơn giản, giả sử chúng ta có một mảng chứa mười số và chúng ta muốn trích xuất năm số đầu tiên để làm việc riêng với chúng. Điều đó có thể được thực hiện bằng cách sử dụng Range - dựa trên subscripting, như sau:

let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
let firstFive = numbers[..<5]

Thoạt nhìn, có vẻ như firstFive sẽ có cùng loại với numbers, đó làArray <Int>, nhưng thực tế nó không phải như vậy. Trong thực tế, những gì chúng ta đã thực hiện ở trên là tạo ra một slice, trong trường hợp này là kiểu ArraySlice <Int>. Thay vì sao chép năm phần tử đầu tiên vào một Array mới, standard library thay vào đó chỉ đơn giản cung cấp cho chúng ta một cái nhìn vào phạm vi các phần tử đó - có lợi ích hiệu suất đáng kể, đặc biệt là khi làm việc với các collections lớn hơn. Bằng cách không thực hiện bất kỳ sao chép hoặc cấp phát bộ nhớ bổ sung nào cho collections các phần tử, một slice có thể được tạo trong thời gian không đổi (O (1)). Điều đó không chỉ giúp tạo ra một slice nhanh hơn, mà còn giúp cắt lát nhanh hơn như thể chúng ta thực hiện nó trên collections ban đầu - mang lại cho chúng ta rất nhiều sự tự do khi sử dụng và soạn thảo các API slicing khác nhau của Swift trong thực tế .

Prefixes and suffixes

Hãy bắt đầu bằng cách xem cách chúng ta có thể sử dụng slicing để trích xuất các tiền tố và hậu tố từ một collection. Ví dụ, giả sử chúng ta đang làm việc trên một todo app, sử dụng mô hình sau để thể hiện một trong những danh sách việc cần làm:

struct TodoList {
    var name: String
    var items = [Item]()
    ...
}

Bây giờ, giả sử chúng ta đang xây dựng một tính năng cho phép người dùng nhanh chóng xem ba item đầu tiên trong bất kỳ danh sách cụ thể nào - ví dụ như trong "Today extension" trên iOS hoặc macOS. Để thực hiện điều đó, chúng ta có thể sử dụng subscripting API tương tự như chúng ta đã sử dụng khi cắt mảng số ở ví dụ trước đó, như sau:

extension TodoList {
    var topItems: ArraySlice<Item> {
        items[..<3]
    }
}

Tuy nhiên, mặc dù ở trên có vẻ rất tốt theo quan điểm cú pháp, nhưng nó lại là một triển khai khá nguy hiểm trong tình huống này. Vì chúng ta không thể biết có bao nhiêu item mà mỗi TodoList sẽ thực sự chứa, app của chúng tôi ta thể bị crash khi truy cập vào thuộc tính trên - giống như khi lấy một phần tử từ một mảng, range dựa trên subscripting cũng gây ra crash nếu sử dụng với các chỉ số ngoài giới hạn. Mặc dù chúng ta có thể thêm kiểm tra giới hạn của riêng mình vào việc triển khai, nhưng có một cách thuận tiện hơnlà sử dụng prefix như sau:

extension TodoList {
    var topItems: ArraySlice<Item> {
        items.prefix(3)
    }
}

Bây giờ API mới của chúng ta sẽ hoạt động như mong đợi, ngay cả khi TodoList chứa ít hơn 3 item và việc triển khai của chúng ta vẫn có độ phức tạp thời gian O (1) - có nghĩa là chúng ta có thể thoải mái để sử dụng nó để tính toán mà không có nguy cơ gây ra crash . Tuy nhiên, một điều mà chúng ta phải ghi nhớ khi làm việc với các lát cắt là chúng thực sự là một loại riêng biệt so với các bộ sưu tập ban đầu của chúng. Điều đó có nghĩa là chúng ta có thể chuyển qua một cá thể ArraySlice cho bất kỳ API nào chấp nhận Mảng tiêu chuẩn và ngược lại - mà không cần thực hiện chuyển đổi rõ ràng trước tiên. Điều đó thoạt nghe có vẻ như là một sự bất tiện không cần thiết, nhưng nó thực sự rất quan trọng - vì nó cho chúng ta toàn quyền kiểm soát khi một lát cắt được tách ra (và các yếu tố của nó được sao chép) khỏi bộ sưu tập ban đầu của nó. Tuy nhiên, một điều mà chúng ta phải ghi nhớ khi làm việc với các slices là chúng thực sự riêng biệt so với các colletions ban đầu của chúng. Điều đó có nghĩa là chúng ta không thể chuyển qua một instance ArraySlice cho bất kỳ API nào của standard Array và ngược lại - nếu không cần thực hiện chuyển đổi rõ ràng trước tiên. Điều đó thoạt nghe có vẻ như là một sự bất tiện không cần thiết, nhưng nó thực sự rất quan trọng - vì nó cho chúng ta toàn quyền kiểm soát khi một slice được tách ra (và các yếu tố của nó được sao chép) khỏi collection ban đầu của nó. Hãy cùng xem một ví dụ về việc thực hiện điều đó, trong đó chúng ta phân chia một Shipment thành hai Shipment riêng biệt dựa trên các index. Vì chúng ta không muốn mô hình Shipment của mình chứa ArraySlice, thay vào đó là một mảng package phù hợp, chúng ta phải chuyển đổi hai slice của chúng tôi thành các giá trị Array <Package> - như thế này:

struct Shipment {
    var destination: Address
    var packages = [Package]()
    ...
}

extension Shipment {
    func split() -> (first: Shipment, second: Shipment) {
        guard packages.count > 1 else {
            return (self, Shipment(destination: destination))
        }

        let splitIndex = packages.count / 2

        return (
            Shipment(
                destination: destination,
                packages: Array(packages.prefix(upTo: splitIndex))
            ),
            Shipment(
                destination: destination,
                packages: Array(packages.suffix(from: splitIndex))
            )
        )
    }
}

Cho đến nay, chúng ta đã tính toán các prefix và suffix của chúng ta dựa trên số lượng phần tử và index, nhưng chúng ta cũng có thể sử dụng logic hoàn toàn tùy chỉnh khi làm như vậy. Ví dụ: ở đây, chúng tôi gọi prefix bằng cách custom closure để xác định trò chơi nào trong số những người chơi hàng đầu của trò chơi đã ghi được hơn 100.000 điểm:

let qualifiedPlayers = topPlayers.prefix { $0.score > 100_000 }

Dropping elements

Một khía cạnh quan trọng của cả prefix và suffix là cả hai API đó đều không ảnh hưởng đến collection ban đầu mà chúng được gọi, và thay vào đó trả về các phiên bản mới để làm việc với các tập hợp con của các phần tử đó. Điều tương tự cũng đúng đối với họ API drop, với sự khác biệt duy nhất là chúng loại bỏ một prefix hoặc suffix nhất định từ một collection, thay vì trích xuất nó. Ví dụ, giả sử chúng ta muốn xóa bất kỳ số nào xuất hiện ở đầu chuỗi, ví dụ để chuẩn bị một chuỗi được sử dụng như một dạng định danh chuẩn hóa. Để làm điều đó, chúng ta có thể yêu cầu chuỗi trong câu hỏi bỏ tất cả các phần tử số - như thế này:

extension StringProtocol {
    func trimmingLeadingNumbers() -> SubSequence {
        drop(while: { $0.isNumber })
    }
}

Như đã đề cập trước đó, một trong những lợi ích chính của API collection dựa trên slice Swift là chúng có thể được tạo mà không gây ra bất kỳ sự sao chép không cần thiết nào. Điều đó thực sự có lợi trong các tình huống như bên dưới đây, chúng ta trimmingLeadingNumbers với một lệnh filter để cập nhật giá trị username:

func normalizeUsername(_ username: String) -> String {
    username.trimmingLeadingNumbers().filter {
        $0.isLetter || $0.isNumber
    }
}

Sử dụng phương pháp trên, chúng ta có thể xây dựng các phép biến đổi giá trị ngày càng phức tạp chỉ bằng cách kết hợp một loạt các hoạt động lại với nhau - tất cả đều theo cách thức có hiệu suất cao. Tương tự, chúng ta hãy xem cách chúng ta có thể kết hợp một biến thể thả khác - dropFirst - với prefix để dễ dàng thêm extension phân trang cho bất kỳ BidirectionalCollection nào (bao gồm các loại như Array, Range, v.v.). Bằng cách gọi dropFirst đầu tiên để xóa tất cả các thành phần trước khi trang hiện tại bắt đầu và sau đó sử dụng prefix để trích xuất một slice có cùng kích thước với kích thước trang của chúng tôi, chúng tôi có thể triển khai extension phân trang như thế này:

extension BidirectionalCollection {
    func page(withIndex pageIndex: Int, size: Int) -> SubSequence {
        dropFirst(pageIndex * size).prefix(size)
    }
}

Quay trở lại loại TodoList của chúng ta từ trước đó, sau đó chúng ta có thể wrap API ở trên một mức độ trừu tượng cao hơn một chút, cho chúng ta một phương pháp phân trang thực sự đẹp có thể được sử dụng để hiển thị bất kỳ danh sách các việc cần làm nào

extension TodoList {
    func page(at index: Int) -> ArraySlice<Item> {
        items.page(withIndex: index, size: 25)
    }
}

Lúc đầu, có vẻ như là một quyết định kỳ lạ khi trả lại ArraySlice từ API trên, thay vì chuyển đổi kết quả thành một Array thích hợp. Tuy nhiên, bằng cách đó, chúng ta đã tuân theo các quy ước giống như standard library - cho phép các call site quyết định cách thức và thời điểm chuyển đổi từng slice, điều này cho phép chúng ta thực hiện thêm nhiệm vụ mà không giảm hiệu suất.

Splitting things up

Cuối cùng, chúng ta hãy xem một biến thể thứ ba của collection slicing - splitting, đây là một kỹ thuật thường sử dụng khi làm việc với các chuỗi. Swift cung cấp hai cách split chuỗi chính - là sử dụng split method riêng của Kiểu String, hoặc dùng Foundation API (components(separatedBy:)) (được kế thừa từ Objective-C phe NSString):

let lines = text.components(separatedBy: "\n")
let lines = text.split(separator: "\n")

Mặc dù hai 2 method trên có vẻ như đang thực hiện cùng một việc, nhưng chúng thực sự khác nhau khi chúng ta bắt đầu xem xét các chi tiết. Đầu tiên, method đầu tiên cũng trả về một mảng các String, so với cái thứ hai, nó trả về một mảng các giá trị Substring - cung cấp cho method thứ hai các đặc tính có hiệu suất tốt hơn nhiều trong các tình huống mà không yêu cầu sao chép. Một sự khác biệt quan trọng khác là split cho chúng ta option để hạn chế số lần splits xảy ra, điều này có thể giúp chúng ta tăng hiệu suất trong các tình huống khi chúng ta chỉ muốn trích xuất một số lượng giới hạn từ một chuỗi lớn hơn. Ví dụ: ở đây chúng tôi trích xuất năm dòng đầu tiên từ một văn bản, bằng cách kết hợp split với dropLast () (để xóa thành phần cuối cùng, đại diện cho phần còn lại của văn bản):

let firstLines = text.split(
    separator: "\n",
    maxSplits: 5,
    omittingEmptySubsequences: true
).dropLast()

Điều mà thực sự thú vị về split không chỉ ở String API - giống như prefix, suffix và các phương thức drop khác nhau mà chúng ta đã xem xét cho đến nay, nó có thể được sử dụng với bất kỳ collection nào. Ví dụ: ở đây, cách chúng ta có thể sử dụng nó để phân chia một loạt các event phân tích thành các session, dựa trên thời điểm app ở trạng thái foreground hoặc background:

enum Event: Equatable {
    case appEnteredForeground
    case appEnteredBackground
    case impression(contentID: ContentID)
    case interaction(contentID: ContentID)
    ...
}

extension Array where Element == Event {
    func sessions() -> [ArraySlice<Event>] {
        split(omittingEmptySubsequences: true) {
            $0 == .appEnteredForeground ||
            $0 == .appEnteredBackground
        }
    }
}

Phía trên đây là một ví dụ khác khi protocol-oriented design của library Swift thực sự tỏa sáng, vì nó cho phép chúng ta truy cập vào nhiều thuật toán và phần chức năng khác nhau theo cách mà không liên kết trực tiếp với bất kỳ loại cụ thể nào. Không chỉ các API mà chúng ta đã xem trong bài viết này mà có thể được sử dụng với bất kỳ bộ sưu tập tích hợp nào, chúng cũng có thể được sử dụng với các custom. Bằng cách giới hạn số lượng sao chép ngầm và cấp phát bộ nhớ xảy ra khi làm việc với các collection, cuối cùng chúng ta sẽ kiểm soát nhiều hơn các core data structures của mình - điều này có thể giúp chúng ta tăng hiệu suất tổng thể của các ứng dụng mà chúng ta xây dựng.

Hy vọng bài viết sẽ có ích với các bạn

Reference: https://www.swiftbysundell.com/articles/slicing-swift-collections/


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí