标记 0.5.0

Tagged 0.5.0

Stephen CelisBrandon Williams维护。



Tagged 0.5.0

🏷Tagged

Swift 5 iOS/macOS CI Linux CI @pointfreeco

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

目录

动机

我们经常与类型打交道,这些类型过于通用或持有的值过多,超出了我们的领域所需。有时我们只想在类型级别区分两个看似等效的值。

电子邮件地址不过是String,但它应该在如何使用方面受到限制。而且,虽然用户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协议系列来避免此操作。

特色功能

标记使用条件遵守,这使得您不必为安全性牺牲可表达性。如果原始值可编码或可解码、相等、可哈希、可比较或可通过文字表示,标记值也会如此。这意味着我们通常可以避免不必要的(并且可能是危险的)包装和解包装操作。

相等性

如果一个标记类型的原始值是相等的,那么标记类型会自动具有相等性。我们在上面的示例中利用了这一点。

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

阅读更多关于我们博客的文章:标记秒和毫秒

标记货币

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

导入 标记货币 库,您可以访问两种泛型类型,即 Dollars&ACents&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 的免费 功能。使用协议意味着您需要手动选择将每种类型注册为合成 `Equatable`、`Hashable`、`Decodable` 和 `Encodable`,并且为了达到与 Tagged 相同的表述层次,您需要手动符合其他协议,例如 `Comparable`、`ExpressibleBy-Literal` 协议家族和 `Numeric`。这需要编写或生成大量的样板内容,但 Tagged 会免费提供这一切!

安装

Carthage

如果您使用 Carthage,您可以在您的 Cartfile 中添加以下依赖项:

github "pointfreeco/swift-tagged" ~> 0.4

CocoaPods

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

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

SwiftPM

如果您想在使用 SwiftPM 的项目中使用 Tagged,只需在您的 Package.swift 中添加一个 dependencies 子句即可。

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

Xcode 子项目

将 Tagged 克隆下来,并将 Tagged.xcodeproj 拖入您的项目中。

想要了解更多?

这些概念(以及更多)在由 Point-Free 探索的函数式编程和 Swift 系列视频中得到了充分的探讨,该系列视频由 Brandon WilliamsStephen Celis 主持。

Tagged 首次在 第12集 中进行探讨。

video poster image

许可证

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