在Swift中避免原始痴迷

确保您的代码代表要解决的问题称为域建模 ,这是软件工艺的重要组成部分。 这意味着您应该创建代表问题的类( 或结构! ),而不是使用字典或元组之类的结构来存储信息。 创建这些域概念的好处是,您可以创建更丰富的API,并减少开发人员理解一段代码所花费的精力。

领域建模的反面-使用基元表示复杂的想法-被称为基元痴迷,是一种代码味道。 例如,通过将网站存储为String来表示网站的URL。 与String相比,URL具有更多信息和特定属性(例如,方案,查询参数,协议),并且通过将其存储为字符串,您将无法在没有其他代码的情况下访问这些特定于URL项(域概念)。

作为域建模的一部分,您要针对两件事:

使代码尽可能说明性,而无需文档

充当其自己的文档的代码是软件开发的重要内容之一。 如果代码以这种方式进行自我记录,则说明文档将永远不会过时,因为它是代码本身的一部分。 您不必每次更改代码都记得要更新文档,这很容易忽略。

防止以对业务无济于事的方式滥用您的代码

好的代码经常被忽略的一件事是,它很难做错事情。 需要URL的函数应将URL作为参数,而不是字符串。 如果该函数的用户可以传递String可能会更容易,但该函数现在必须确保String实际上也是URL。 通过强迫用户执行此操作,您将给他们带来一些负担,同时还可以防止他们不仅滥用您的API,而且可以滥用它。

让我们专注于这段代码,看看如何更好地对此建模:

  func showDetailsForEmail(withId:String){ 
//推送新的视图控制器
}

使用类型别名

typealias是一个关键字(在Objective-C和Swift中都可用),用于显示代码中的其他内容可以轻松表示的位置:

  typealias EmailId =字符串 
  func showDetailsForEmail(withId:EmailId){ 
//推送新的视图控制器
}

Typealiases很好地解释了在这种情况下可以使用什么,它们满足了第一个目标(使您的代码更具解释性)。 在这里,通过查看功能签名,您可以看到发送到该功能才能使其正常工作所需的内容-电子邮件的ID。 以前,当函数只需要一个String ,该字符串应代表什么以及如何创建一个字符串就不太明显了。 将一个完全不相关的String传递给方法,这太容易了,这不是您想要的。

不幸的是,类型别名未能满足第二个要求-防止滥用API。 类型别名是另一个对象的“昵称”。 它们不是单独的类型,因此不会阻止在其位置使用“昵称”类型。 即使上面的函数需要一个EmailId ,您仍然可以在其位置传递一个String (或什至其他类型的别名):

  typealias EmailId =字符串 
typealias SMSId =字符串

func showDetailsForEmail(withId:EmailId){
//推送新的视图控制器
}

让smsOne:SMSId =“来自:alice:to:bob”
showDetailsForEmail(withId:smsOne)//编译,即使这不正确

从开发人员的角度来看,这段代码没有意义,但是编译器无法知道。 这样的错误可能会在运行时被发现,可能是由于稍后的崩溃或只是出于奇怪的行为(如果您不小心放入了SMS ID,它将尝试向您显示来自此SMS对话的电子邮件!)。 如果您可以告诉编译器和开发人员,当您要求提供EmailId时明确表示了什么,则可以在编译时捕获错误。

消除原始的困扰

确保您最终不会将这些字符串之一的错误“含义”错误地传递给方法的最简单方法是为两种不同类型创建两个不同的对象,从而使它们不再是低类型化别名:

  struct EmailId { 
let rawValue:字符串
}

struct UserId {
let rawValue:字符串
}
  showDetailsForEmail(withId emailId:EmailId){ 
//推送新的视图控制器
}

让joe = UserId(rawValue:“ joeBloggs”)
showDetailsForEmail(withId:joe)//现在无法编译

这将无法编译并显示以下错误:

 无法将类型UserId的值转换为EmailId 

哪一个完美! 虽然为看起来像字符串的对象创建两个单独的对象似乎有些开销,但重要的是要意识到并非如此。 在这种情况下,将UserId传递给showDetailsForEmail()方法没有任何意义,因此您应该阻止开发人员执行此操作。 在设置这些要求的企业眼中, UserIdEmailId显然是两个不同的事物,因此在代码中清楚地表明这一点使您更接近于匹配业务规则。 此外,在编译时捕获错误比在运行时捕获错误要快得多。

测试您的域结构

创建对象来包装域项目的一个常见烦恼是,它会给您在测试中创建的数据增加很多样板:

 让testEmail = Email(id:EmailId(rawValue:“从:alice:to:bob”),消息:“…”) 

确保测试具有表现力,快速且易于理解非常重要。 幸运的是,Swift可以使您的生活更轻松。

Swift有一套协议,可以用来说“这个对象可以仅由原始对象构造”。 这些是ExpressibleBy * Literal协议(在Swift 2及以下版本中,称为* LiteralConvertible),其中*可以是某些内置类型,例如String,Int,Double或Array。 实现这些协议仅需要实现一些初始化程序:

  struct EmailId:ExpressibleByStringLiteral { 

let rawValue:字符串
  public init(stringLiteral value:String){ 
self.rawValue =值
}

public init(extendedGraphemeClusterLiteral value:String){
self.rawValue =值
}

公共初始化(unicodeScalarLiteral值:字符串){
self.rawValue =值
}

}

然后,您可以传递字符串文字,而不必自己创建此结构,这意味着您不必一直担心创建包装结构的样板:

 让firstEmailId = EmailId(rawValue:“来自:jane:to:kathy”) 
让secondEmailId:EmailId =“来自:daniel:to:erica”

与现在相比,这可以使您的测试设置更容易,而不是:

 让testEmail = Email(id:EmailId(rawValue:“从:sue:to:terry”),消息:“…”) 

您可以简单地写:

 让testEmail = Email(id:“从:sue:to:terry”,消息:“…”) 

您仍将创建一个EmailId ,但是您减少了一些样板代码。
真正重要的一点是,这仅适用于字符串文字 (即“引号”中包含的文字 )。 因此,以下代码将不会编译,因为stringIdString (分配了字符串文字后,立即将其分配为String ):

 让stringId =“任何” 
let testEmail = Email(id:stringId,message:“…”)//编译失败,因为stringId是String

这就是您想要的,因为这会阻止您传递随机字符串。
这些字面可转换协议对于测试确实有用,因为它们使您可以编写更少的测试样板。 它们的用途在生产中受到限制,因为在此示例中,您不太可能从字符串文字创建EmailId ,它更有可能从JSON传递(因此是String )。 此外,如果在生产环境中使用它,您将失去拥有结构所获得的所有安全性–您可以像键入别名一样键入任何String

结论

域建模对于使新手和有经验的开发人员都易于理解的代码至关重要。 通过使代码更具解释性,您可以使开发人员更容易理解和重构。 通过确保防止滥用代码,可以使重构和重用代码更加安全。 类型系统是功能强大的工具,可以帮助您改进代码,并且还可以帮助您理解需要编写的业务规则。 但是在Swift中,通过实现ExpressibleLiteral协议,类型不必在测试时成为负担。

知道这些协议还有其他好的用途吗? 通知我! 如果您喜欢这篇文章,请给它推荐😁。

奖金回合

在我的另一篇文章中了解有关此概念的更多信息,时间始终是您领域的一部分。