更安全、更具表现力代码的包装类型。
我们经常使用的类型过于通用,或者持有的值远远超出我们领域所需的范围。有时我们只想在类型级别区分两个看似等价的值。
电子邮件地址只不过是一个 String
,但它应该在使用方式上受到限制。虽然 User
id 可以用 Int
表示,但它应该与基于 Int
的 Subscription
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 依赖于泛型“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<User, Int>') 的值转换为预期参数类型 'Subscription.Id' (又名 'Tagged<Subscription, Int>')
我们在编译时防止了几个严重的错误!
这些类型中还潜伏着另一个错误。我们编写了一个具有以下签名的函数
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<EmailTag, String>')
如果我们想在同一种类型中标记两个字符串值怎么办?
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<User, String>
,因为编译器会将 Email
和 Address
视为相同的类型!我们需要一个新的标签,这意味着我们需要一个新的类型。我们可以使用任何类型,但 uninhabited enum 是可嵌套且不可实例化的,这在这里非常完美。
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.Email
和 User.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:)
手动实例化 tagged 类型,尽管您通常可以使用 Decodable
和 ExpressibleBy
-Literal
协议族来避免这种情况。
Tagged 使用 条件一致性,因此您不必为了安全而牺牲表现力。如果原始值是可编码或可解码的、可 Equatable、可 Hashable、可 Comparable 或可通过字面量表示的,则 tagged 值也会随之而来。这意味着我们通常可以避免不必要的(并且可能危险的)包装和解包。
如果 tagged 类型的原始值是 equatable,则它会自动成为 equatable。我们在上面的 我们的示例 中利用了这一点。
subscriptions.first(where: { $0.id == user.subscriptionId })
我们可以使用底层 hashability 来创建集合或查找字典。
var userIds: Set<User.Id> = []
var users: [User.Id: User] = [:]
我们可以直接对可比较的 tagged 类型进行排序。
userIds.sorted(by: <)
users.values.sorted(by: { $0.email < $1.email })
Tagged 类型与其包装的类型一样是可编码和可解码的。
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": "blob@pointfree.co",
"address": "1 Blob Ln",
"subscriptionId": null
}
""".utf8))
Tagged 类型继承字面量可表达性。这对于处理常量(如实例化测试数据)很有帮助。
User(
id: 1,
email: "blob@pointfree.co",
address: "1 Blob Ln",
subscriptionId: 1
)
// vs.
User(
id: User.Id(rawValue: 1),
email: User.Email(rawValue: "blob@pointfree.co"),
address: User.Address(rawValue: "1 Blob Ln"),
subscriptionId: Subscription.Id(rawValue: 1)
)
Numeric tagged 类型免费获得数学运算!
struct Product {
let amount: Cents
typealias Cents = Tagged<Product, Int>
}
let totalCents = products.reduce(0) { $0 + $1.amount }
Tagged
库还附带了一些纳米库,用于以类型安全的方式处理常用类型。
我们与之交互的 API 通常以秒或毫秒为单位返回时间戳,从 epoch 时间开始测量。跟踪单位可能很麻烦,可以通过文档或以特定方式命名字段来完成,例如 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<Int> = 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 秒和毫秒。
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
重要的是要注意,这些类型不封装货币,而只是金钱的整体和零碎单位的抽象概念。您仍然需要跟踪特定的货币,如美元、欧元、墨西哥比索,以及这些值。
为什么不使用类型别名?
类型别名只是别名:别名。类型别名可以与原始类型互换使用,并且不提供额外的安全性或保证。
为什么不使用 RawRepresentable
或其他协议?
像 RawRepresentable
这样的协议很有用,但它们不能有条件地扩展,因此您会错过 Tagged 的所有免费 特性。使用协议意味着您需要手动选择每种类型来合成 Equatable
、Hashable
、Decodable
和 Encodable
,并且要达到与 Tagged 相同的表达水平,您需要手动符合其他协议,如 Comparable
、ExpressibleBy
-Literal
协议族和 Numeric
。这需要您编写或生成大量的样板代码,但 Tagged 免费为您提供!
您可以通过将 Tagged 添加为包依赖项来将其添加到 Xcode 项目。
如果您想在 SwiftPM 项目中使用 Tagged,只需将其添加到 Package.swift
中的 dependencies
子句中即可
dependencies: [
.package(url: "https://github.com/pointfreeco/swift-tagged", from: "0.6.0")
]
这些概念(以及更多)在 Point-Free 中进行了深入探讨,Point-Free 是一个由 Brandon Williams 和 Stephen Celis 主持的视频系列,探索函数式编程和 Swift。
Tagged 最初在 第 12 集 中进行了探讨
所有模块均在 MIT 许可证下发布。有关详细信息,请参阅 LICENSE。