Formatting numbers in Swift

Một phần quan trọng trong logic của bất kỳ ứng dụng cụ thể nào có thể liên quan đến việc làm việc với các con số theo cách này hay cách khác. Cho dù đó là để thực hiện tính toán layout, schedule events bằng cách sử dụng time intervals hoặc bằng cách xử lý các custom metrics và numbers của riêng chúng ta. Mặc dù làm việc với các con số là một trong những thứ mà máy tính vốn đã giỏi, nhưng đôi khi chúng ta cũng cần phải format và present một số con số của mình theo cách dễ đọc, dễ hiểu hơn, điều này thường phức tạp hơn mong đợi. Vì vậy, trong bài viết này, hãy cùng khám phá chủ đề đó và các loại số khác nhau có thể định dạng khác nhau như thế nào.

Solving the decimal problem

Ở cấp độ cơ bản nhất, việc tạo textual representation của một số nhất định chỉ đơn giản là khởi tạo một String với nó, có thể được thực hiện trực tiếp hoặc bằng cách sử dụng một string literal:

let a = String(42) // "42"
let b = String(3.14) // "3.14"
let c = "\(42), \(3.14)" // "42, 3.14"

Tuy nhiên, mặc dù cách tiếp cận đó có thể hoạt động tốt để tạo ra các mô tả đơn giản hơn về các con số nằm trong tầm kiểm soát hoàn toàn của chúng ta, chúng ta có thể sẽ cần các định dạng mạnh mẽ hơn nhiều khi xử lý các dynamic numbers. Ví dụ: ở đây, chúng ta đã xác định một Metric type cho phép chúng ta liên kết một số Double nhất định với một tên, sau đó chúng ta sử dụng khi tạo custom description cho một giá trị như vậy:

struct Metric: Codable {
    var name: String
    var value: Double
}

extension Metric: CustomStringConvertible {
    var description: String {
        "\(name): \(value)"
    }
}

Metric type ở trên có thể chứa bất kỳ giá trị Double nào, chúng ta có thể muốn định dạng nó theo cách dễ đoán hơn. Ví dụ: thay vì chỉ chuyển đổi Double thành Chuỗi, chúng ta có thể sử dụng một custom format để làm tròn nó đến hai chữ số thập phân, điều này sẽ làm cho đầu ra của chúng ta nhất quán bất kể mỗi giá trị Double cơ bản thực sự là như thế nào:

extension Metric: CustomStringConvertible {
    var description: String {
        let formattedValue = String(format: "%.2f", value)
        return "\(name): \(formattedValue)"
    }
}

Tuy nhiên, bây giờ chúng ta sẽ luôn xuất ra hai chữ số thập phân, ngay cả khi số Double của chúng ta là một số nguyên hoặc nếu nó chỉ có một chữ số thập phân - có thể không phải là những gì chúng ta đang tìm kiếm. Lấy ví dụ số 42. Chúng tôi có thể không muốn nó có định dạng là 42.00. Ý tưởng ban đầu về cách giải quyết vấn đề đó có thể là sử dụng phương pháp thủ công và cắt tất cả các số 0 ở cuối và dấu thập phân khỏi String được định dạng trước khi return- như thế này:

extension Metric: CustomStringConvertible {
    var description: String {
        var formattedValue = String(format: "%.2f", value)

        while formattedValue.last == "0" {
            formattedValue.removeLast()
        }

        if formattedValue.last == "." {
            formattedValue.removeLast()
        }

        return "\(name): \(formattedValue)"
    }
}

Đoạn code trên chắc chắn hoạt động, nhưng nó được cho là không tốt lắm và cũng tạo ra giả định rằng chúng ta sẽ luôn định dạng mỗi số theo cùng một cách cho tất cả người dùng - điều mà chúng ta không thực sự muốn làm. Bởi vì mọi người mong đợi các con số được biểu diễn theo các cách khác nhau trong văn bản khác nhau, khi hiển thị số giữa các quốc gia và locale khác nhau.

Using NumberFormatter

Thay vào đó, hãy sử dụng NumberFormatter của Foundation để giải quyết vấn đề về số thập phân của chúng ta. Cũng giống như cách một DateFormatter có thể được sử dụng để định dạng Date values theo nhiều cách khác nhau, class NumberFormatter đi kèm với một bộ công cụ định dạng khá toàn diện dành riêng cho các con số. Ví dụ: sử dụng NumberFormatter, chúng ta có thể định dạng mỗi số dưới dạng số thập phân với tối đa hai chữ số thập phân, điều này sẽ cho chúng ta kết quả mong muốn mà không cần phải thực hiện bất kỳ điều chỉnh thủ công nào. Các số như 42, 42,142,12 giờ sẽ được hiển thị giống như vậy và bất kỳ số nào sẽ vẫn được tự động làm tròn thành hai dấu thập phân:

extension Metric: CustomStringConvertible {
    var description: String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.maximumFractionDigits = 2

        let number = NSNumber(value: value)
        let formattedValue = formatter.string(from: number)!
        return "\(name): \(formattedValue)"
    }
}

Một lợi ích chính khác của việc sử dụng NumberFormatter là nó sẽ tự động tính đến Ngôn ngữ hiện tại của người dùng khi định dạng các số. Ví dụ: ở một số quốc gia, số 50932.52 dự kiến sẽ được định dạng là 50 932,52, trong khi các ngôn ngữ khác lại thích 50,932,52. Tất cả những sự phức tạp đó giờ đây đã được chúng ta xử lý hoàn toàn tự động, đây rất có thể là những gì chúng ta muốn khi định dạng các số theo hướng người dùng. Tuy nhiên, khi không phải như vậy và thay vào đó, chúng ta đang tìm kiếm sự nhất quán trên tất cả các ngôn ngữ, khi đó chúng tôi có thể gán một Locale cụ thể cho NumberFormatter của mình hoặc chúng ta có thể cấu hình nó để sử dụng các ký tự cụ thể làm decimalSeparatorgroupingSeparator - như thế này:

extension Metric: CustomStringConvertible {
    var description: String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.maximumFractionDigits = 2
        formatter.decimalSeparator = "."
formatter.groupingSeparator = ""

        ...
    }
}

Trong trường hợp này, giả sử chúng ta muốn format localized. Để hoàn tất quá trình triển khai, hãy chuyển việc tạo NumberFormatter sang một static property (sẽ cho phép chúng ta sử dụng lại cùng một phiên bản trên tất cả các giá trị Metric) và chúng ta cũng hãy giới thiệu một API chuyên dụng để tự truy xuất từng formatted value - như sau:

extension Metric: CustomStringConvertible {
    private static var valueFormatter: NumberFormatter = {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.maximumFractionDigits = 2
        return formatter
    }()

    var formattedValue: String {
        let number = NSNumber(value: value)
        return Self.valueFormatter.string(from: number)!
    }

    var description: String {
        "\(name): \(formattedValue)"
    }
}

Vì vậy, NumberFormatter cực kỳ hữu ích khi chúng ta muốn định dạng một raw numeric value thành một mô tả dễ hiểu, nhưng nó còn có thể làm được nhiều hơn thế. Chúng ta hãy tiếp tục khám phá!

Domain-specific numbers

Tùy thuộc vào loại ứng dụng mà chúng ta đang phát triển, rất có thể chúng ta cũng sẽ phải đối phó với các con số dành riêng cho từng domain cụ thể. Đó là, chúng đại diện cho một cái gì đó không chỉ là một raw numeric value. Ví dụ: giả sử chúng ta đang làm việc trên một ứng dụng mua sắm và chúng ta đang sử dụng Double được wrap trong custom Price struct để mô tả giá của một sản phẩm nhất định:

struct Product: Codable {
    var name: String
    var price: Price
    ...
}

struct Price: Codable {
    var amount: Double
    var currency: Currency
}

enum Currency: String, Codable {
    case eur
    case usd
    case sek
    case pln
    ...
}

Bây giờ, câu hỏi đặt ra là, làm thế nào để định dạng một Price instance như vậy theo cách có ý nghĩa đối với mỗi người dùng, bất kể họ đang ở quốc gia nào và họ đang sử dụng ngôn ngữ nào? Đây là một loại tình huống khác mà NumberFormatter có thể cực kỳ hữu ích, vì nó cũng bao gồm hỗ trợ đầy đủ cho định dạng tiền tệ được bản địa hóa. Tất cả những gì chúng ta phải làm là set numberStyle của nó thành currency và cung cấp cho nó code của đơn vị tiền tệ mà chúng ta đang sử dụng - như sau:

extension Price: CustomStringConvertible {
    var description: String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        formatter.currencyCode = currency.rawValue
        formatter.maximumFractionDigits = 2

        let number = NSNumber(value: amount)
        return formatter.string(from: number)!
    }
}

Ví dụ: dưới đây là cách giá 3.14 tính theo đơn vị tiền tệ Thụy Điển SEK sẽ được hiển thị ở một số ngôn ngữ khác nhau khi sử dụng phương pháp trên:

  • Thụy Điển: 3,14 kr
  • Tây Ban Nha: 3.14 SEK
  • Mỹ: SEK 3.14
  • Pháp: SEK 3,14 Bên cạnh giá, một loại giá trị số phổ biến khác mà chúng ta có thể muốn localize là measurements. Ví dụ: giả sử rằng ứng dụng mua sắm tưởng tượng mà chúng ta vừa làm việc hiện đã chuyển sang chỉ tập trung vào việc bán xe và chúng ta đã chuyển đổi Product type của mình thành một Vehicle type cụ thể hơn, bao gồm các thuộc tính như topSpeed:
struct Vehicle {
    var name: String
    var price: Price
    var topSpeed: Double
    ...
}

Hiện tại, thuộc tính topSpeed của chúng ta một lần nữa là Double và mặc dù đó chắc chắn là lựa chọn tuyệt vời cho hầu hết các raw numbers cần có độ chính xác dấu phẩy động, nhưng nó thực sự không phù hợp lắm trong trường hợp này - vì việc triển khai hiện tại không cho chúng ta biết bất cứ điều gì về đơn vị đo lường mà giá trị của chúng tôi đang sử dụng. Nó có thể là km mỗi giờ, dặm một giờ, mét mỗi giây, và vân vân. Việc thể hiện loại unit-based numeric values chính xác là những gì mà loại Measurement làm, vì vậy hãy sử dụng nó thay thế. Trong trường hợp này, chúng ta sẽ chuyên biệt hóa nó với loại UnitSpeed , giúp làm rõ ràng rằng giá trị topSpeed đại diện cho phép đo tốc độ:

struct Vehicle {
    var name: String
    var price: Price
    var topSpeed: Measurement<UnitSpeed>
    ...
}

Khi tạo các instance của Vehicle type ở trên, giờ đây chúng ta sẽ được yêu cầu luôn chỉ định đơn vị đo lường cơ bản cho thuộc tính topSpeed, đây là một điều tuyệt vời, vì điều đó giúp hạn chế đáng kể sự mơ hồ của các giá trị đó. Nhưng đó mới chỉ là bước khởi đầu, bởi vì Measurement cũng có công thức định dạng rất riêng mà giờ đây chúng ta có thể sử dụng để dễ dàng tạo mô tả đã được formatted về topSpeed của mỗi vehicle:

extension Vehicle {
    var formattedTopSpeed: String {
        let formatter = MeasurementFormatter()
        return formatter.string(from: topSpeed)
    }
}

Điều thực sự tuyệt vời là không chỉ description ở trên được localized, MeasurementFormatter cũng sẽ tự động chuyển đổi từng giá trị thành đơn vị được ưa thích bởi ngôn ngữ của người dùng hiện tại - trong trường hợp này sẽ là km/h hoặc mph. Thật là tuyệt! Tuy nhiên, có một điều chúng ta cần lưu ý khi sử dụng giá trị Measurement và đó là cách chúng được encoded và decoded theo mặc định. Khi sử dụng các compiler-generated Codable conformances, mỗi Measurement value dự kiến sẽ được decoded từ dictionary chứa một số metadata property có thể không được bao gồm trong bất kỳ JSON nào mà chúng ta đang tải xuống từ app server. Thay vào đó, chúng ta rất có thể có một đơn vị đo lường được thống nhất mà server của chúng ta đang sử dụng, có nghĩa là chúng ta phải thực hiện decode theo cách thủ công trong trường hợp này - ví dụ như sau:

extension Vehicle: Codable {
    private enum CodingKeys: CodingKey {
        case name, price, topSpeed, ...
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        // Decoding all other properties
        ...

        topSpeed = try Measurement(
            value: container.decode(Double.self, forKey: .topSpeed),
            unit: .kilometersPerHour
        )
    }
    
    // Encoding implementation
    ...
}

Vì custom Codable implementations thường khá cồng kềnh để maintain, chúng ta hãy cũng khám phá một phương pháp thay thế. Dưới đây là cách chúng ta có thể tạo property wrapper chuyên dụng cho phép chúng ta đóng gói các chuyển đổi giữa DoubleMeasurement trong một loại duy nhất:

@propertyWrapper
struct KilometersPerHour {
    var wrappedValue: Measurement<UnitSpeed>
}

extension KilometersPerHour: Codable {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let rawValue = try container.decode(Double.self)

        wrappedValue = Measurement(
            value: rawValue,
            unit: .kilometersPerHour
        )
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(wrappedValue.value)
    }
}

Lợi ích chính của phương pháp trên là, trừ khi chúng ta thực sự cần Vehicle sử dụng custom Codable implementation, giờ đây chúng ta có thể chỉ cần đánh dấu thuộc tính topSpeed của mình bằng @KilometersPerHour và một lần nữa chúng ta sẽ có thể cho phép compiler tạo ra tất cả code đó cho chúng ta:

struct Vehicle: Codable {
    var name: String
    var price: Price
    @KilometersPerHour var topSpeed: Measurement<UnitSpeed>
    ...
}

Với những điều trên, giờ đây chúng ta sẽ có được tất cả các lợi thế khi sử dụng Measurement - từ additional type safety, đến các tính năng định dạng và chuyển đổi tích hợp - trong khi vẫn có thể sử dụng các giá trị Double khi encode và decode các models của chúng ta. Hy vọng bài viết sẽ có ích với các bạn

Reference: https://www.swiftbysundell.com/articles/formatting-numbers-in-swift/


All Rights Reserved