TaggedMoney 0.5.0

TaggedMoney 0.5.0

Stephen CelisBrandon Williams维护。



🏷标记

Swift 5.1 iOS/macOS CI Linux CI @pointfreeco

一种用于更安全、更明确的代码的包装类型。

目录

动机

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

电子邮件地址不过是,但它的使用方式应该受到限制。而一个的用户id可以用来表示,但它应该与基于的订阅id区分开来。

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

问题

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 依赖于一个泛型的 "tag" 参数来使每个类型独特。在这里,我们使用了容器类型来为每个 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>
}

这可能看起来有点奇怪,因为有悬而未决的括号 (),但它总体上是很好且简洁的,而且我们获得的类型安全性远远超值。

访问原始值

Tagged 使用与 RawRepresentable 相同的接口来通过一个 rawValue 属性公开其原始值。

user.id.rawValue // Int

您还可以使用init(rawValue:)手动实例化标记类型,尽管通常您可以使用DecodableExpressibleBy-Literal协议系列来避免此操作。

功能

标记类型使用条件约束,因此您无需为了安全而放弃可读性。如果原始值是可编码或可解码的、可比较的、可比较的或可由文字表示的,则标记值将类似地遵守。这意味着我们通常可以避免不必要的(并且可能危险的)包装和解包

可等价

如果一个标记类型的原始值是可等价的,它将自动成为可等价的。我们已经在上面的例如中利用了这一点。

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

可哈希

我们可以使用底层的哈希性来创建集合或查找字典。

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

可比较

我们可以直接在可比较的标记类型上排序。

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

可编码

标记类型与其包装的类型一样可编码和可解码。

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)

可由文字表达

标记类型继承文字表现力。这对于处理常量很有帮助,例如实例化测试数据。

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)
)

数值

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

struct Product {
  let amount: Cents

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

纳米库

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美元的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,以及这些值。

常见问题解答(FAQ)

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

    类型别名正是其名:别名。类型别名可以代替原始类型使用,但并不提供额外的安全保证或保证。

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

    RawRepresentable这样的协议是有用的,但是它们不能条件性地扩展,因此您会错过Tagged所有免费的功能。使用协议意味着您需要手动将每个类型指定为生成EquatableHashableDecodableEncodable,并且要达到与Tagged相同的表现力,您还需要手动符合其他协议,如ComparableExpressibleBy系列协议和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,只需在您的Package.swift中添加一个dependencies子句即可。

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

Xcode 子项目

子模块、克隆或下载标签版本,并将 Tagged.xcodeproj 拖入您的项目。

想了解更多?

这些概念(及更多)在由 Point-Free 播客系列中进行了深入探讨,该系列由 Brandon WilliamsStephen Celis 主播,探讨了函数式编程和 Swift。

第 12 期首次探讨了 Tagged。

video poster image

许可证

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