TaggedTime0.5.0

TaggedTime0.5.0

Stephen CelisBrandon Williams维护。



🏷Tagged

Swift 5.1 iOS/macOS CI Linux CI @pointfreeco

一种用于更安全、更具表现力的代码的包装类型。

目录

动机

我们经常处理过于通用或包含过多不必要值的类型。有时我们只是在类型级别上区分看似等效的两个值。

电子邮件地址只是一个 String,但它应该在其使用方式上有所限制。而一个 User id 可以用 Int 表示,但它应该与基于 IntSubscription id 区分开。

Tagged 可以通过轻松地将基本类型包装在更具体的上下文中,在编译时帮助解决严重的运行时错误。

问题

Swift 有着极其强大的类型系统,然而,我们仍然普遍像这样来建模大多数数据

struct User {
  let id: Int
  let email: String
  let address: String
  let subscriptionId: Int?
}

struct Subscription {
  let id: Int
}

我们使用 相同的类型 来建模用户和订阅 id,但我们的应用程序逻辑不应该将这些值互换使用!我们可能会编写一个功能来获取订阅

func fetchSubscription(byId id: Int) -> Subscription? {
  return subscriptions.first(where: { $0.id == id })
}

这样的代码非常常见,但允许存在严重的运行时错误和安全问题!以下代码可以编译、运行,甚至可以直观地阅读

let subscription = fetchSubscription(byId: user.id)

此代码将无法找到用户的订阅。更糟糕的是,如果用户ID和订阅ID重叠,它将显示错误订阅给错误的用户!甚至可能暴露敏感数据,如账单详情!

解决方案

我们可以使用Tagged来简洁地区分类型。

import Tagged

struct User {
  let id: Id
  let email: String
  let address: String
  let subscriptionId: Subscription.Id?

  typealias Id = Tagged<User, Int>
}

struct Subscription {
  let id: Id

  typealias Id = Tagged<Subscription, Int>
}

Tagged依赖于泛型“标签”参数来使每个类型唯一。在这里,我们使用了容器类型来唯一标记每个ID。

现在我们可以更新fetchSubscription,使其接受Subscription.Id,而不是之前接受的任何Int

func fetchSubscription(byId id: Subscription.Id) -> Subscription? {
  return subscriptions.first(where: { $0.id == id })
}

我们不会意外传递一个用户ID而不是预期的订阅ID。

let subscription = fetchSubscription(byId: user.id)

🛑不能将类型为'User.Id'(即'Tagged')的值转换为预期的参数类型'Subscription.Id'(即'Tagged')

我们在编译时阻止了几处严重的错误!

在这些类型中还有另一个隐藏的错误。我们已经编写了一个具有以下签名的函数

sendWelcomeEmail(toAddress address: String)

它包含将电子邮件发送到电子邮件地址的逻辑。不幸的是,它接受任何字符串作为输入。

sendWelcomeEmail(toAddress: user.address)

这可以编译和运行,但user.address指的是我们的用户的计费地址,而不是他们的电子邮件!我们的用户都没有收到欢迎邮件!更糟糕的是,用无效数据调用此函数可能导致服务器波动和崩溃。

Tagged可以再次拯救这一天。

struct User {
  let id: Id
  let email: Email
  let address: String
  let subscriptionId: Subscription.Id?

  typealias Id = Tagged<User, Int>
  typealias Email = Tagged<User, String>
}

现在我们可以更新sendWelcomeEmail并获得另一个编译时保证。

sendWelcomeEmail(toAddress address: Email)
sendWelcomeEmail(toAddress: user.address)

🛑不能将类型为'String'的值转换为预期的参数类型'Email'(即'Tagged')

处理标签冲突

如果我们想在同一类型内标记两个字符串值怎么办?

struct User {
  let id: Id
  let email: Email
  let address: Address
  let subscriptionId: Subscription.Id?

  typealias Id = Tagged<User, Int>
  typealias Email = Tagged<User, String>
  typealias Address = Tagged</* What goes here? */, String>
}

我们不应用Tagged,因为编译器会将EmailAddress视为相同的类型!我们需要一个新的标记,这意味着我们需要一个新的类型。我们可以使用任何类型,但没有元素的枚举可以嵌套并不可实例化,这在这里是完美的。

struct User {
  let id: Id
  let email: Email
  let address: Address
  let subscriptionId: Subscription.Id?

  typealias Id = Tagged<User, Int>
  enum EmailTag {}
  typealias Email = Tagged<EmailTag, String>
  enum AddressTag {}
  typealias Address = Tagged<AddressTag, String>
}

我们现在已经区分了User.EmailUser.Address,但代价是每个类型多了一行,但事情被非常明确地记录下来。

如果我们想省去这行额外的内容,我们可以利用元组标签是编码在类型系统中的,并且可以用作区分两个看似等效的元组类型的事实。

struct User {
  let id: Id
  let email: Email
  let address: Address
  let subscriptionId: Subscription.Id?

  typealias Id = Tagged<User, Int>
  typealias Email = Tagged<(User, email: ()), String>
  typealias Address = Tagged<(User, address: ()), String>
}

这看起来可能有点奇怪,因为有悬垂的括号,但它总体上既简洁又好,而且我们获得的安全性是值得付出的。

访问原始值

标记类型使用与 RawRepresentable 相同的接口来暴露其原始值,通过 rawValue 属性。

user.id.rawValue // Int

您还可以使用 init(rawValue:) 手动实例化标记类型,尽管通常可以通过使用 DecodableExpressibleByLiteral 协议系列来避免这样做。

特性

标记类型使用 条件遵从,您不必为安全性牺牲可表达性。如果原始值是可编码的、可解码的、相等的、可散列的或可由字面值表达,标记值也会遵循此规则。这意味着我们通常可以避免不必要的(并且可能危险)的 封装和解封装

Equatable

如果原始值是 Equatable,标记类型会自动实现 Equatable。我们就在上面的例子中利用了这一点。

subscriptions.first(where: { $0.id == user.subscriptionId })

Hashable

我们可以利用底层的可散列性来创建集合或查找字典。

var userIds: Set<User.Id> = []
var users: [User.Id: User] = [:]

Comparable

我们可以直接对可比较的标记类型进行排序。

userIds.sorted(by: <)
users.values.sorted(by: { $0.email < $1.email })

Codable

标记类型与它们所包装的类型一样,是可编码和可解码的。

struct User: Decodable {
  let id: Id
  let email: Email
  let address: Address
  let subscriptionId: Subscription.Id?

  typealias Id = Tagged<User, Int>
  typealias Email = Tagged<(User, email: ()), String>
  typealias Address = Tagged<(User, address: ()), String>
}

JSONDecoder().decode(User.self, from: Data("""
{
  "id": 1,
  "email": "[email protected]",
  "address": "1 Blob Ln",
  "subscriptionId": null
}
""".utf8)

ExpressiblyBy-Literal

带有标记的类型继承了字面表达的特性。这对于处理常量很有帮助,比如实例化测试数据。

User(
  id: 1,
  email: "[email protected]",
  address: "1 Blob Ln",
  subscriptionId: 1
)

// vs.

User(
  id: User.Id(rawValue: 1),
  email: User.Email(rawValue: "[email protected]"),
  address: User.Address(rawValue: "1 Blob Ln"),
  subscriptionId: Subscription.Id(rawValue: 1)
)

Numeric

数值标记类型可以免费获得数学运算!

struct Product {
  let amount: Cents

  typealias Cents = Tagged<Product, Int>
}
let totalCents = products.reduce(0) { $0.amount + $1.amount }

小库(Nanolibraries)

《Tagged》库还附带了一些小库,用于以类型安全的方式处理常见类型。

TaggedTime

我们与之交互的API通常会返回从纪元时间测量的秒或毫秒的时间戳。跟踪这些单位有时会很混乱,可能是通过文档完成,也可能是通过特定的方式命名字段,例如 publishedAtMs。不小心混淆单位可能会导致逻辑严重不准确。

通过导入 TaggedTime,您将获得访问两个泛型类型 Milliseconds<A>Seconds<A> 的权限,这将允许编译器为您解决这些差异。

struct BlogPost: Decodable {
  typealias Id = Tagged<BlogPost, Int>

  let id: Id
  let publishedAt: Seconds<Int>
  let title: String
}

现在单位在类型中自动具有文档说明,您永远不会意外地将秒与毫秒进行比较

let futureTime: Milliseconds = 1528378451000

breakingBlogPost.publishedAt < futureTime
// 🛑 Binary operator '<' cannot be applied to operands of type
// 'Tagged<SecondsTag, Double>' and 'Tagged<MillisecondsTag, Double>'

breakingBlogPost.publishedAt.milliseconds < futureTime
// ✅ true

在我们的博客文章中了解更多信息:[Tagged Seconds and Milliseconds](https://www.pointfree.co/blog/posts/6-tagged-seconds-and-milliseconds)。

TaggedMoney

API还可以以两种标准单位发送回金额:整美元或美分(美元的1/100)。跟踪这种区别也可能很混乱并且容易出错。

导入 TaggedMoney 库将为您提供两种泛型类型,Dollars<A>Cents<A>,在编译时确保这两个单位保持分离。

struct Prize {
  let amount: Dollars<Int> 
  let name: String
}

let moneyRaised: Cents<Int> = 50_000

theBigPrize.amount < moneyRaised
// 🛑 Binary operator '<' cannot be applied to operands of type
// 'Tagged<DollarsTag, Int>' and 'Tagged<CentsTag, Int>'

theBigPrize.amount.cents < moneyRaised
// ✅ true

需要注意的是,这些类型并不封装 货币,而仅仅是货币的整数部分和分数部分的抽象概念。您仍然需要跟踪特定货币,如 USD、EUR、MXN,与这些值一起。

常见问题问答

  • 为什么不使用类型别名呢?

    类型别名就是别名。类型别名可以与原始类型互换使用,并不会提供额外的安全保证或保证。

  • 为什么不使用 RawRepresentable 或其他协议呢?

    RawRepresentable 之类的协议很有用,但它们不能有条件地扩展,因此您会错过 Tagged 所提供的所有免费 功能。使用协议意味着您需要手动选择每种类型以生成 EquatableHashableDecodableEncodable,并且为了达到与 Tagged 相同的表达能力,您需要手动遵守其他协议,如 ComparableExpressibleBy-Literal 协议家族和 Numeric。这是大量的样板代码,您需要编写或生成,但 Tagged 可以免费提供这些!

安装

Carthage

如果您使用 Carthage,您可以将以下依赖项添加到您的 Cartfile

github "pointfreeco/swift-tagged" ~> 0.5

CocoaPods

如果您的项目使用 CocoaPods,只需将以下内容添加到您的 Podfile

pod 'Tagged', '~> 0.5'
pod 'TaggedMoney', '~> 0.5'
pod 'TaggedTime', '~> 0.5'

SwiftPM

如果您想在使用 SwiftPM 的项目中使用 Tagged,只需要将 dependencies 子句添加到您的 Package.swift 文件中即可。

dependencies: [
  .package(url: "https://github.com/pointfreeco/swift-tagged.git", from: "0.5.0")
]

Xcode 子项目

将 Tagged 作为一个子模块、克隆或者下载,然后将 Tagged.xcodeproj 拖拽到您的项目中。

想了解更多?

这些概念(还有很多)在由 Point-Free 探索的函数式编程和 Swift 系列视频中进行了深入的探讨,该系列视频由 Brandon WilliamsStephen Celis 主持。

Tagged 首次出现在 第12集

video poster image

许可

所有模块都在 MIT 许可下发布。有关详细信息,请参阅 LICENSE