1. Introduction

我们经常处理过于一般化的类型,或者包含的值太多,超过了域中所需要的值。在上一集关于代数数据类型的内容中,我们使用代数来帮助简化某些类型,使其成为核心内容。 然而,有时这是不可能的。有时,我们只是想在类型级别上区分两个看起来等价的值。

例如,一个电子邮件地址只是一个字符串,但它的使用方式应该受到限制。 同样,我们代码中的id可能都是整数,但是将用户id与订阅id进行比较是没有意义的。它们是完全不同的实体。

我们想要展示的是,Swift,尤其是Swift 4.1,给了我们一个不可思议的工具,将类型提升到一个更高的水平,这样它们就能更容易地相互区分。这有助于改进我们在函数和方法中构建的契约,总体上使代码更安全。

2. Decodable

让我们看一看我们可能从API或其他数据存储返回的一些示例数据。我们用JSON表示一些用户和订阅。

let usersJson = """
[
  {
    "id": 1,
    "name": "Brandon",
    "email": "brandon@pointfree.co",
    "subscriptionId": 1
  },
  {
    "id": 2,
    "name": "Stephen",
    "email": "stephen@pointfree.co",
    "subscriptionId": null
  },
  {
    "id": 3,
    "name": "Blob",
    "email": "blob@pointfree.co",
    "subscriptionId": 1
  }
]
"""

let subscriptionsJson = """
[
  {
    "id": 1,
    "ownerId": 1
  }
]
"""

我们有一对对应的结构体它们的属性名和类型都匹配。

struct Subscription {
  let id: Int
  let ownerId: Int
}

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

Swift有一对功能强大的协议,Encodable and Decodable,它们将自动为由分别符合每个协议的类型组成的结构体合成编码和解码逻辑。

我们如何使这些可解码类型? 我们可以遵循以下Subscription类型:

struct Subscription: Decodable {
  let id: Int
  let ownerId: Int
}

一切都还在继续。Swift现在已经为这个类型合成了一个初始化式:

Subscription.init(from:)
// (Decoder) throws -> Subscription

我们对User做同样的处理。

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

有了这些条件,我们就可以使用JSONDecoder来解码JSON中的值。

let decoder = JSONDecoder()
let users = try! decoder.decode([User].self, from: Data(usersJson.utf8))
let subscriptions = try! decoder.decode([Subscription].self, from: Data(subscriptionsJson.utf8))

好了! 我们不需要手动编写任何逻辑来解码数据。 编译器为我们做到了! 我们的契约是用数据类型本身编写的,这非常强大。

不过,我们的数据类型有点松散。我们使用Int和String,因为它们是可解码的,但最好在类型层面加强它们,这样我们就不会对id或电子邮件地址做奇怪的事情。

假设我们有一个可以向某个地址发送电子邮件的功能。

func sendEmail(email: String) {
  //
}

嗯,我们可以从我们的商店中获取一个用户,并给他们发送电子邮件。

let user = users[0]
sendEmail(email: user.email)

这可以编译并运行,但是没有什么可以阻止我们这样写:

sendEmail(email: user.name)

我们留下了一个运行时错误。我们将电子邮件地址表示为字符串,但不是每个字符串都是有效的电子邮件地址。

我们想要的是比String更受约束的类型。让我们将字符串包装在Email结构中,并将sendmail更改为只接受Email值作为输入。

struct Email {
  let email: String
}

我们现在可以使用这种类型来表示用户的电子邮件。

struct User: Decodable {
  let id: Int
  let name: String
  let email: Email
  let subscriptionId: Int?
}

我们可以在sendmail函数中使用这个类型。

func sendEmail(email: Email) {
  //
}

当我们试图传递字符串时,sendmail会出现编译错误。

sendEmail(email: user.name)
  • 无法将类型' String '的值转换为期望的参数类型' Email '
    Swift现在为我们提供了更严格的类型检查。

不过,我们有另一个编译器错误。

  • User”类型不符合“Decodable”协议

编译器不能再自动让User遵循Decodable,因为电子邮件是UnDecodable

这很容易解决。

struct Email: Decodable {
  let email: String
}

当我们解码用户时,我们会得到一个运行时错误。

Fatal error: ‘try!’ expression unexpectedly raised an error: Swift.DecodingError.typeMismatch(Swift.Dictionary<Swift.String, Any>, Swift.DecodingError.Context(codingPath: [_JSONKey(stringValue: “Index 0”, intValue: 0), CodingKeys(stringValue: “email”, intValue: nil)], debugDescription: “Expected to decode Dictionary<String, Any> but found a string/data instead.”, underlyingError: nil))

当Swift为结构体生成解码初始化器时,它假定数据被包装在一个以属性名作为键的容器中。这意味着JSON解码器需要一个带有电子邮件密钥的对象,而不是一个原始字符串。是这样的:

// "email": {"email": String}

我们想要把它展开,以便它与我们正在处理的JSON相匹配。我们可以编写自己的自定义初始化式来解决这个问题。解码器有几种产生解码容器的方法。 KeyedDecodingContainers解码被键控的值,就像我们的结构是由属性名键控的,所以这是它默认为我们使用的。 UnkeyedDecodingContainers用于数组。我们想要使用的是SingleValueDecodingContainer,它和它的名字一样,负责解码单个值。

struct Email: Decodable {
  let email: String

  init(from decoder: Decoder) throws {
    let container = try decoder.singleValueContainer()
    self.email = try container.decode(String.self)
  }
}

太棒了! 所有的东西都会编译、运行,并且仍然限制我们以原始字符串发送电子邮件。但是,必须提供自定义初始化器有点麻烦,我们应该能够避免使用样板文件。

3. Raw representable

让我们看看Swift附带的另一个有用的协议:RawRepresentable

RawRepresentable 是描述另一个值的包装的小协议。你可能在不知不觉中就遵从了它。

让我们来看一个例子:

enum Status: Int {
  case ok = 200
  case notFound = 404
}

创建这些类型的枚举是很常见的,以使代码更加类型安全。 它们将更一般的类型封装在一个更具体的容器中,有点像我们的Email结构体! 这些枚举自动遵循RawRepresentable,提供了与原始值之间的接口。

Status.ok.rawValue // 200
Status(rawValue: 200) // Status.ok
Status(rawValue: 600) // nil

这可能是RawRepresentable最常见的用法,但没有什么能阻止我们手动遵循。我们可以手动将Email符合RawRepresentable。我们可以让Swift写协议存根。

struct Email: Decodable, RawRepresentable {
  init?(rawValue: String) {
    self.email = rawValue
  }

  var rawValue: String {
    return self.email
  }

  typealias RawValue = String

  let email: String

  init(from decoder: Decoder) throws {
    let container = try decoder.singleValueContainer()
    self.email = try container.decode(String.self)
  }
}

现在这里有很多东西,但我们至少可以删除我们的手动Decodable初始化器:RawRepresentable为我们提供了一个免费完成相同工作的工具。

struct Email: Decodable, RawRepresentable {
  init?(rawValue: String) {
    self.email = rawValue
  }

  var rawValue: String {
    return self.email
  }

  typealias RawValue = String

  let email: String
}

这仍然是很多,但如果我们重命名电子邮件为rawValue,我们可以大大缩短内容。

struct Email: Decodable, RawRepresentable {
  let rawValue: String
}

一切运行正常! 我们可以依赖Swift为我们合成的默认的成员初始化式。 这并不是很多代码,但是它产生了很多有用的东西。仅仅通过将结构体标记为Decodable, RawRepresentable,并将其属性命名为rawValue, Swift就允许我们为应用程序的边界创建一个类型安全的包装器。

事情很短,我们可以把它变成一行代码。

struct Email: Decodable, RawRepresentable { let rawValue: String }

只需要这一行代码就可以使我们的应用程序更加类型安全。我们的JSON完全没有改变,但email属性现在完全受限,我们不会意外地编写代码,试图向用户的名称字符串发送电子邮件。

它是如此的短,我们应该能够在我们的代码库中spring这些类型,以获得更多的保护。

让我们看看另一个潜在的问题。假设我们想获取用户的订阅。

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

这是可行的,但并不能阻止我们不小心比较用户的id。

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

现在我们有一个运行时错误正在等待发生,它可能会返回错误的数据给用户。这是一个安全问题,我们应该在类型级别捕获它。

很简单,我们只需要创建另一个RawRepresentable结构体来表示订阅的id。

struct Subscription: Decodable {
  struct Id: Decodable, RawRepresentable { let rawValue: Int }

  let id: Id
  let ownerId: Int
}

我们还希望确保User使用这个类型表示它的subscriptionId

struct User: Decodable {
  let id: Int
  let name: String
  let email: Email
  let subscriptionId: Subscription.Id?
}

我们现在有几个编译器错误,这是预料之中的。第一个错误的是,因为我们没有为Subscription.Id定义相等,第二个错误的是,因为我们不想比较订阅Id和用户Id。

让我们仔细看看第一个错误。

Binary operator ‘==’ cannot be applied to operands of type ‘Subscription.Id’ and ‘Subscription.Id?’

这是一个微妙的问题,因为以下工作:

let subscription = subscriptions
  .first { $0.id == user.subscriptionId! }

这是因为Swift在==上提供了一个泛型重载,在关联的RawValue类型相等的情况下处理RawRepresentable值,但在任意一边都是可选的情况下不重载。RawRepresentable不能有条件地遵循Equatable,因为它是一个协议,协议扩展不能有继承子句。

我们可以通过让Subscription.Id遵从Equatable来解决这个问题。

struct Subscription: Decodable {
  struct Id: Decodable, RawRepresentable, Equatable { let rawValue: Int }

  let id: Id
  let ownerId: Int
}

这样我们就不用强制解包。

let subscription = subscriptions
  .first { $0.id == user.subscriptionId! }

甚至不需要写更多的代码! Swift能够为我们推导一致性,因为Int已经符合Equatable

这么多免费的能力! 我们正在创建封装了更多基本类型的新类型,并在api中创建了非常强的契约。 它变得有点嘈杂了,尽管Swift为我们写了很多代码,但我们肯定需要一遍又一遍地告诉它应该写哪些代码。There’s gotta be a better way.

4. Tagged

让我们编写自己的通用结构来包装原始值。

struct Tagged<Tag, RawValue> {
  let rawValue: RawValue
}

我们称它为Tagged,以说明一个额外的Tag泛型参数。这是一个奇怪的参数,似乎没有在类型的任何地方使用。这有时被称为“幻影类型(phantom type)”。

我们能做些什么呢? 让我们用Tagged替换我们的Subscription.Id结构体。

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

  let id: Id
  let ownerId: Int
}

我们已经用Subscription类型本身标记了这个Id类型。

好了,现在我们打破了一些东西。User不再是可解码的,因为Tagged不可解码。幸运的是,Swift提供了一个工具来解决这个问题:条件一致性(conditional conformance)。

让我们有条件地让Tagged 遵循 Decodable

extension Tagged: Decodable where RawValue: Decodable {
  init(from decoder: Decoder) throws {
    let container = try decoder.singleValueContainer()
    self.init(rawValue: try container.decode(RawValue.self))
  }
}

Correction
This implementation of init(from decoder:) incorrectly makes the assumption that a tagged value is always a “single value”, preventing tagged keyed and unkeyed container types (like structs, arrays, and dictionaries) from being decodable.
The fix is a simpler implementation that delegates to the raw value directly and was submitted as a pull request here.

很好,我们的订阅又可以解码了,但我们还有个问题。

Binary operator ‘==’ cannot be applied to two ‘Subscription.Id’ (aka ‘Tagged<Subscription, Int>’) operands

这是因为Tagged不符合Equatable。我们不再处理RawRepresentable,所以我们不再有==重载。因为我们处理的是具体类型,所以可以使用条件一致性使Tagged equatable

extension Tagged: Equatable where RawValue: Equatable {
  static func == (lhs: Tagged, rhs: Tagged) -> Bool {
    return lhs.rawValue == rhs.rawValue
  }
}

哇。所有的工作了!

let subscription = subscriptions
  .first { $0.id == user.subscriptionId }

我们返回预定的订阅! 那我们的问题逻辑呢?

let subscription = subscriptions
  .first { $0.id == user.id }

Binary operator ‘==’ cannot be applied to operands of type ‘Subscription.Id’ (aka ‘Tagged<Subscription, Int>’) and ‘User.Id’ (aka ‘Tagged<User, Int>’)

哇,Tagged解决了和RawRepresentable一样的所有问题,但用的代码更少,在某些情况下执行任务似乎更好。

让我们用用户id试一下。我们可以在User结构中定义一个带标签的id.

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

  let id: Id
  let name: String
  let email: Email
  let subscriptionId: Subscription.Id?
}

并相应地更新订阅结构体。

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

  let id: Id
  let ownerId: User.Id
}

And everything still compiles!

现在,当我们尝试比较订阅id和用户id时,我们得到一个非常具体的、可读的错误消息:

🛑 Binary operator ‘==’ cannot be applied to operands of type ‘Subscription.Id’ (aka ‘Tagged<Subscription, Int>’) and ‘User.Id’ (aka ‘Tagged<User, Int>’)

那么我们的电子邮件类型呢?

typealias Email = Tagged<_, String>

我们可以用什么标记电子邮件? 没有一个明确的关联类型,就像我们的Subscription.Id和User.Id一样。我们可以创造一个一次性的。

protocol EmailTag {}
typealias Email = Tagged<EmailTag, String>

这个很好,但是有点奇怪。使用空协议感觉很奇怪,为什么它是一个协议? 我们也可以用一个无人居住的enum

enum EmailTag {}
typealias Email = Tagged<EmailTag, String>

我们甚至可以使用结构或类,这有点奇怪,因为它们可以被实例化。让我们选择枚举,因为它们不能被实例化。我认为我们发现,以这种方式使用Tagged带有一些包袱,因为有时我们必须创建一个全新的类型来作为标签使用。

其他语言有“新类型”的概念。它能够用很少的代码创建这些更类型安全的容器。

newtype Email = String

Swift没有这个功能,但这很好,因为我们不需要创建全新的类型来使用Tagged

我们应该指出,typealias不提供这种功能。

typealias Email = String

typealias关键字为类型创建了一个完全可互换的同义词,因此Email typealias到String仍然允许任何字符串被视为电子邮件,而不提供任何额外的类型安全。

现在,代替它的是RawRepresentableTagged。我们已经看到,RawRepresentable在没有条件一致性的情况下有一点劣势,所以我们将为此付出代价,偶尔使用样板文件创建标签。令人惊讶的是,我们是如何将所有这些逻辑整合到一个单一的Tagged类型中。 我们不需要在所有地方创建新的RawRepresentable结构,也不需要列出每个我们希望它符合的Decodable, Equatable协议。Tagged会自动继承它包装的类型的所有特性。

我们的Tagged类型工作得非常好! 让我们看看实例化一个用户的人机工程学是什么样子的,它包含了很多这些新类型。

到目前为止,我们的代码的优点是我们的用户是通过JSON解码实例化的,我们不需要在任何地方手动调用初始化器。但是,如果我们确实想手动实例化一个用户呢?

User.init(
  id: User.Id.init(rawValue: 1),
  name: "Blob",
  email: Email.init(rawValue: "blob@pointfree.co"),
  subscriptionId: Subscription.Id.init(rawValue: 2)
)

哎呀,这真是一团糟。我们可以稍微清理一下,使用点前缀符号。

User(
  id: .init(rawValue: 1),
  name: "Blob",
  email: .init(rawValue: "blob@pointfree.co"),
  subscriptionId: .init(rawValue: 2)
)

手动包装所有这些类型仍然有相当多的工作。

Swift有一个完整的ExpressibleBy-Literal协议集合,它允许我们在需要另一种类型的地方传递文字值。也许我们也可以有条件地遵循这些协议。

extension Tagged: ExpressibleByIntegerLiteral where RawValue: ExpressibleByIntegerLiteral {
  typealias IntegerLiteralType = RawValue.IntegerLiteralType

  init(integerLiteral value: IntegerLiteralType) {
    self.init(rawValue: RawValue(integerLiteral: value))
  }
}

我们将IntegerLiteralType类型别名委托给RawValue的IntegerLiteralType,并且同样地将接受整型字面值的初始化式委托给它。

这样我们就可以清理很多噪音。

User(
  id: 1,
  name: "Blob",
  email: .init(rawValue: "blob@pointfree.co"),
  subscriptionId: 2
)

我们甚至可以通过遵循ExpressibleByStringLiteral来消除围绕电子邮件的干扰。

现在,我们已经展示了如何恢复实例化这些类型安全容器的人体工程学,而不必手动将它们到处包装。

5. What’s the point?

我们通常不会经常做这种事! 我们通常满足于在模型中使用string、int和其他基本类型。 他们很容易相处,也很熟悉。但现在我们在所有地方创建大量的新类型,并将所有的值包装在另一个层中。这值得吗?

这完全值得! 人们不这么做的唯一原因是,在Swift 4.1面世之前,这根本不可能! 即使是我们开始使用的RawRepresentable版本也得益于Swift 4.1的特性:自动派生Equatable。如今,Swift为我们提供了许多伟大的工具,让原本不切实际的事情成为可能。

看看我们编写的代码。

struct Tagged<Tag, RawValue> {
  let rawValue: RawValue
}
extension Tagged: Decodable where RawValue: Decodable {
  init(from decoder: Decoder) throws {
    let container = try decoder.singleValueContainer()
    self.init(rawValue: try container.decode(RawValue.self))
  }
}
extension Tagged: Equatable where RawValue: Equatable {
  static func == (lhs: Tagged, rhs: Tagged) -> Bool {
    return lhs.rawValue == rhs.rawValue
  }
}
extension Tagged: ExpressibleByIntegerLiteral where RawValue: ExpressibleByIntegerLiteral {
  typealias IntegerLiteralType = RawValue.IntegerLiteralType
  init(integerLiteral value: IntegerLiteralType) {
    self.init(rawValue: RawValue(integerLiteral: value))
  }
}

这仅仅是20行代码! 但想象一下,如果你每次想要一种具有这些品质的新发型时都要重复这样做,那就不现实了。所涉及的努力意味着在任何地方都更容易坚持我们的原始价值观。

但现在,在Swift 4.1中,我们可以在一行中完成所有这些功能,以及更多附加的一致性!

typealias Id = Tagged<User, Int>

虽然这样创建新类型在Swift中并不常见,但在Haskell和PureScript等语言中却很常见,在这些语言中创建新类型也是单行的情况。

编译器生成的代码真的很酷!我们在键路径(1,2,3)中见过,这里也一样。

这捕获的漏洞不是微不足道的! 我们在Point-Free代码库中使用了Tagged,它捕获了可能会向我们的访问者推出的bug ! 我们在任何地方都使用它! 我们使用它在我们的模型上的id,从第三方服务的id,电子邮件地址,和更多!编译器使我们处于检查状态,并帮助我们避免一些其他方面令人讨厌的错误。

这是人们今天可以使用的代码! 您可以用很少的代码使您的代码更安全、更有表现力和更易于文档化!

“幽灵类型”的概念是Tagged的概念之一,我们只是顺便简单提到过。我们将在未来更多地探索它们!