🏷 Tagged
一个用于更安全、更明确代码的包装类型。
目录
动机
我们经常与类型打交道,这些类型过于通用或持有的值过多,超出了我们的领域所需。有时我们只想在类型级别区分两个看似等效的值。
电子邮件地址不过是String
,但它应该在如何使用方面受到限制。而且,虽然用户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依赖于一个通用的“标签”参数来使每个类型独一无二。在这里,我们使用容器类型来唯一地标记每个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
,因为编译器会将Email
和Address
视为同一类型!我们需要一个新的标签,这意味着我们需要一个新的类型。我们可以使用任何类型,但对于一个空类型枚举,它是可嵌套和不可实例化的,这对于这里来说是完美的。
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>
}
这看起来可能有些奇怪,因为悬挂着()
,但除此之外,它是简洁且简洁的,而且我们获得的安全性和实用性是值得的。
访问原始值
标记使用与RawRepresentable
相同的接口来公开其原始值,通过rawValue
属性
user.id.rawValue // Int
您还可以使用init(rawValue:)
手动实例化标记类型,尽管您通常可以使用Decodable
和ExpressibleByLiteral
协议系列来避免此操作。
特色功能
标记使用条件遵守,这使得您不必为安全性牺牲可表达性。如果原始值可编码或可解码、相等、可哈希、可比较或可通过文字表示,标记值也会如此。这意味着我们通常可以避免不必要的(并且可能是危险的)包装和解包装操作。
相等性
如果一个标记类型的原始值是相等的,那么标记类型会自动具有相等性。我们在上面的示例中利用了这一点。
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&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 的免费 功能。使用协议意味着您需要手动选择将每种类型注册为合成 `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 Williams 和 Stephen Celis 主持。
Tagged 首次在 第12集 中进行探讨。
许可证
所有模块均在 MIT 许可证下发布。详细信息请参阅 LICENSE。