Swift中的总编程

编辑:在这个主题上,我接受了Corecursive播客的采访。 您可以在这里找到播客插曲,以及大量其他采访https://corecursive.com/007-total-programming-using-swift-with-andre-videla

Swift是一种非常好的语言,因为它试图使程序员从一开始就做正确的事情。 它鼓励的好处之一就是整体性。

总计多少?

总体程序是一种不会卡住,崩溃或陷入无限循环的程序。 它始终会在有限的时间内正确终止。 如果您的程序是完整程序且类型正确,则不会出现程序崩溃或卡住的情况¹。 程序有多种可能是不完全的。 这里有一些例子:

  • 由于死锁或无限循环而陷入困境
  • 边缘情况不予处理,将使程序崩溃
  • 输入格式错误会使程序处于损坏状态

因此,总体上体现了“打字正确的程序不会出错”的短语²。

此属性在日常编程中非常有用,因为它可以减轻(但不能消除)对测试的需求,可以增加对代码的信心,并可以将代码库视为可信任的小块。 大多数正在使用的程序都不是完整的,例如,大多数程序旨在永久运行(Web服务器,移动应用程序,恶魔)。 但是,即使整个程序不是全部,将部分内容汇总也是很有用的。 尽管我将讨论“全部程序”,但实际上它们将是“全部功能”,因此我将可互换地使用两个词。

在这篇文章中,我将介绍空指针和未检查的异常如何针对整体性工作,然后,我将展示Swift如何通过解决这两个问题来鼓励整体性。 之后,我们将看到如何使用!默默地打破Swift的承诺! 运算符在不同的上下文中,并给出一些如何避免编写简单安全代码的示例! 。 尽管总体主题与非终止有着深深的联系,但我不会谈论它。

(注1:这一切都很好,但是Swift绝对不会检查您的程序是否完整。它只是鼓励几乎偶然地使程序完整的做法。Swift不检查整体性有很多原因,但最引人注目的是从数学上讲这是不可能的。
实际上,检查整体性意味着检查程序将始终终止(无无限循环),并终止于“良好”状态(无崩溃)。 终止问题通常被称为“停止问题”,该问题已被推广,并被艾伦·图灵(Alan Turing)使用其图灵机证明无法解决。 现有的编程语言具有有限的终止检查形式,例如Idris或Agda,但是给定了任意程序,他们无法决定它是否终止。 因此,他们将无法判断该程序是否完整。)

(脚注2:此短语的原始来源 来自Milner的 这篇论文 。此声明的上下文是该特定类型系统存在健全性证明,因此,任何类型良好的表达都是声音。)

空引用在低级语言中找到其起源,程序员在该语言中操纵指向内存³中值的指针。 这种语言大量使用指针来访问内存中的地址,这些地址保存了程序中使用的所有数据结构。

例如,在链接列表中,通常将每个节点表示为一对指针:指向当前保存的值的指针和指向列表中下一个节点的指针。

此外,这也使得在列表末尾添加元素变得更加麻烦,因为您将不得不创建一个中间节点,将最后一个节点的值放在新的中间节点中,并使最后一个节点的前任指向新的中间节点,用新值替换最后一个节点中的值,使新的中间节点指向最后一个节点。

实现最后一个元素的另一种方法是使最后一个指针“悬空”而不指向任何东西。 例如,它可以指向未在内存中初始化的特殊保留值(例如0)。 由于所有这些“悬挂”指针都是相同的,因此我们可以轻松地对其进行比较。 因为我们经常使用此类指针,所以我们给它指定了一个名称,并将其称为null

现在,我们可以通过简单地将最终空指针替换为指向新的最后一个元素的指针,并将最后一个元素的尾部指针设置为null来在列表的末尾添加元素。
这使我们的LinkedList API更简单,更优雅,但代价是安全。 实际上,取消引用这样的指针通常会导致程序崩溃,因为您正在访问未初始化的内存。

空引用继承给其他编程语言(如Java,C#和其他语言),以便以与C完全相同的方式表示诸如链表之类的数据结构。在这些语言中,指针不再是一等公民,但我们仍然可以拥有NullPointerException或尝试使用引用而没有意识到它为null NullReferenceException

(注3:空指针被Tony Hoare称为“十亿美元的错误”,他于1965年在Algol W中引入了空引用。我鼓励您在 https://www.infoq 上听听他关于空引用的论述 。 com / presentations / Null-References-The Billion-Dollar-Mistake-Tony-Hoare

Swift没有空引用。 相反,它利用其类型系统来表示诸如缺少值或可能无效的引用之类的东西。 在这两种情况下,都使用Optional类型。 它不表示“空指针”本身,因为它不是引用⁴,而是来自枚举⁵的值:

 枚举可选 {
    案一些(T)
    无案
 } 

该类型表示一个可选变量在某个变量中携带类型T的值,或者不携带任何值none包含任何值

现在,我们可以使用Optional类型为以前需要空指针的事物建模。 区别在于类型检查器将要求我们处理发现值( some )和缺少值( none )的情况。 它还可以作为准确的文档,明确指出可能会丢失某个值。

(注4:无情况或 nil 也用于对空指针建模。例如,在弱引用可能在任何时候被释放的情况下,使用 nil 表示该引用被释放且不再有效的情况

(脚注5:可选项是使用枚举实现的。枚举是代数数据类型,它们使我们能够组合现有类型(包括泛型),以构建诸如树,列表,状态或简单枚举的复杂结构。所有枚举值都是不可变的。)

关于Swift符号

Swift有很多语法糖可用于处理可选内容。 这是我们将使用的一些速记清单:

  • Optional这样的可选值将被删节为String?
  • 我们可以写nilnone none⁶
  • 我们可以使用特殊的if语句检查可选值是否包含某些内容: if let …这是一个示例:
 让礼物:字符串?  =“ someString”
让缺席:字符串?  = nilif let消息=不存在{
     //如果不包含值,则该值将绑定到
     //“邮件”标识符。 但是在这种情况下,没有任何东西
     //“不存在”,因此该分支不会被采用
 }其他{
     //由于absent = nil,因此将采用此分支
 }如果let message = present {
     //采取分支
     //我们可以使用“ message”,它将具有值“ someString”
 } 

(注6: nil 关键字可能会使来自Objective-C或Go的人们感到困惑。确实,这些语言使用 nil 表示空指针,但在Swift中,它只是语法糖,代表值 none 。)

我们已经看到了使用空指针的链接列表示例。 我们可以想象使用nil以比以前相同的方式表示链表:

  //我们必须在这里使用类而不是结构,因为结构 
 //不能递归
类Node  { 
     let元素:Elem
    让尾巴:Node ? 
 } 

像这样使用

  func countList (_列表:Node )-> Int {
    如果让休息= list.tail {
        返回1 +计数(休息)
     }其他{
        返回1
     }
 } 

但是此功能存在一个巨大的问题:它不能接受空列表。 更糟糕的是,空列表甚至无法代表! 使用空指针时,这不是问题,因为空列表是指向节点的空指针。 您可以翻译它,但是很快就会变得很不自然:

  //在这里,我们期望一个“ Node ?” 因为
 //空列表表示为nil
 func countList (_列表:Node ?)-> Int {
    如果让nonEmptyList = list { 
        返回1 + countList(nonEmptyList.tail)
     }其他{
        返回0
     }
 } 

您可以使用typealias缓解这种情况

 类型别名List  = Node ? 

但是很明显,我们正在处理太多级别的间接。 类型应该可以帮助我们,在这里它们似乎混淆了用户的重要信息。 我们想清楚地指出一个列表为空,或包含一个元素和列表的其余部分。 将nil作为参数传递不会传达这一点。

代数数据类型

代数数据类型(缩写为ADT)使我们可以通过以下两种方式组合其他类型来创建新类型:求和和乘积。 我们不会深入探讨它的理论,但是请记住,当有人说“求和类型”时,它们的意思是“使用枚举组合类型”,而当他们说“产品类型”时,它们的意思是“使用枚举类型进行组合” struct”。 作品“和”概括了“选择价值”和“产品”之间的思想,即将价值与其他价值并置。

实际上,我们可以比使用ADT的可选链接列表做得更好。 确实,我们可以定义一个包含两种情况的枚举:或者它带有一个值而列表的其余部分不包含任何值。

  //参见脚注7
枚举List  {//不包含任何内容的列表
     case空//包含元素和列表其余部分的列表    
    案例Node(Elem,List ) 
 } 

这使得列表的使用更加简化:

  func count (list:List )-> Int {//参见脚注8
    切换列表{
     case .empty:返回0
     case let .node(elem,rest):返回1 + count(list:rest)
     } 
 } 

我们已经能够完全消除对可选参数的需求,我们简化了API(不再使用可选包装),并且在类型中明确了这一点:列表为empty ,或者包含值和列表的node

(脚注7: Node 通常称为 Cons Empty 通常称为 Nil 。这是LISP的约定,在该约定中,您将使用 cons (用于构造函数)将元素和列表配对来构建列表。列表的第一个元素是称为 car ,其余称为 cdr 。在最近几年,通常将这两个称为 head tail

(注8:注意switch语句的使用。开关对于安全地销毁数据非常有用。Swift会强制您的开关是详尽无遗的,并且不会遗漏案例。如果您不想处理所有案例,请插入case语句-所有具有 default case _ 语句鼓励程序员通过检查整体性来编写整个程序,如果缺少 case _ ,则拒绝编译。)

异常也起源于底层编程。 您可以找到“类异常”行为的第一个实例是在CPU的实现内部。

CPU具有一种机制,可以在出现意外情况时中断其当前计算。 这种称为中断的机制在CPU设计和实现中无处不在。 它们对于处理IO,发信号通知硬件错误,与CPU上运行的软件(例如调度程序)进行通信等许多事情很有用。

编程语言中的异常有点像中断,它们会中断当前的执行流程,并期望可以做一些事情来处理错误。 如果未处理该异常,它将一直传播到处理为止,否则该程序将被杀死。

原则上这很好,但是在尝试编写总程序时是一个巨大的问题。 实际上,如果代码的任何部分都能够引发异常并且您不知道该异常或无法正确处理该异常,则程序将崩溃。 由于类型齐全的程序可能会因异常而崩溃,因此这会破坏整体性。

当编写全部代码时,您应该能够信任您可用的所有信息。 这包括知道您调用的函数是否可以引发异常,如果可以,则相应地管理错误。 如果您不能确定程序可能崩溃,则不能确定程序是完整的。

从不可信的异常返回到总体的一种方法是强制执行可能抛出的每个异常。 这意味着编译器必须事先知道一小段代码会抛出哪些异常,并确保调用者处理潜在的错误。 例如,Java以Exception类的形式检查了Exception 。 任何引发和扩展的Exception都必须得到捕获和处理。 不幸的是,Java还有一个RuntimeException类,它绕过了异常检查。 这意味着未标记为“可抛出”的方法仍会引发异常并崩溃。

Swift的错误处理模型没有像其他编程语言那样使用异常,即使它借用了类似的语法⁹。 可能会发出错误的函数必须在其类型签名中标记为“ throw”。

  //可能失败的状态更改
 func open(door:Door)throws->门 

如果您调用一个可能抛出的函数,则必须do / try / catch块中对其进行处理(您也可以使用try? and try!我们稍后会讨论它们)

 做{ 
    让opendor =尝试打开(门:lidingDoor)
 }抓住卡住的{ 
    打印(“门被卡住”)
 }捕获错误{
    打印(“未知错误”)
 } 

如果用户写

  let openDoor = open(door:slideDoor)//不尝试 

编译器将发出一条错误消息,提示应捕获该错误,并且不会编译该错误。

(脚注9:swift中的错误处理模型不能以与其他编程语言实现异常相同的方式工作。特别是,抛出错误不会导致 调用栈 展开 。相反,它们表示函数可以返回以下之一:两件事:一个值或一个错误本质上,这类似于返回通用 Result 类型,您可以在其他语言(例如Scala或Rust)中找到该类型。请注意,Swift没有规范的 Result 类型,大多数库都实现了它们自己的结果类型。)

(脚注10:当前标记为 throws 函数和方法 并未公开它们可能发出的错误类型。“ Typed throws”主题仍然引起Swift-evolution邮件列表用户的大量讨论。)

尽管Swift具有相当合理的默认值和非常有用的抽象来利用强大的类型系统,但它还配备了许多转义舱口,请谨慎使用。 其中大多数都是不安全的原始指针操作,例如fatalError或对fatalError调用。

但是,那些逃生舱口中的一个非常突出,因为它极其危险并且易于使用:是! 操作员。

(脚注11:即使在Swift中可以进行原始指针操作,它的使用还是很少见的。您通常会在与C进行很多交互的低级库中找到它们。大多数用户不会编写或读取此类代码)

可选展开

在可选的情况下, ! 运算符允许程序员通过提取值(如果存在)并将其崩溃而将可选值转换为非可选值(从T?T )。 当您调用一个可能由于崩溃而死的函数时,Swift不会发出任何消息! 。 例如,给定此功能:

  //安全除以0,如果我们除以0,则返回“ nil”
函数除法(_ n:Int,除数:Int)-> Int?  {
    如果除数!= 0 {
        返回n / d
     }其他{ 
        返回零 
     }
 } 

你能说出为什么编译吗

  func complexCalculation(value:Int)-> Int { 
    返回3 *值+除(17,乘以:值+ 30)!  * 42
 } 

不是这个

  func complexCalculation(value:Int)-> Int { 
    返回3 *值+除(17,乘以:值+ 30)* 42
 } 

作为提示,我将给您错误消息: 可选类型’Int?’的值 没有展开

没错,函数divide可能返回nil因此我们必须处理除法失败的情况。 如果返回nil ,则不能相加或相乘。 不幸的是,第一个函数使用!隐藏了此属性! 并假设divide永远不会返回nil 。 这样做会使依赖complexCalculation任何程序都不安全,因为它可能会因某些输入而崩溃,并且崩溃的可能性在其类型中不可见。

忽略可能的错误

抛出函数也存在类似情况。 如果用户写try! 在可抛出函数的前面,然后Swift不需要将该函数包装在do / try / catch块中,如果返回错误,则将崩溃。

  func openFile(path:String)throws-> FileHandlefunc openAndRead(){ 
    让文件=尝试!  openFile(路径:“ test.txt”)
    让流=尝试!  file.read()
 } 

如果找不到文件“ text.txt”或程序无权打开此类文件,Swift会愉快地编译该代码并在运行时崩溃。

隐藏函数可能崩溃的事实使您的程序类型检查,同时打破了“正确键入的程序不会出错”的假设

大多数时候, ! 使用运算符是为了避免重复检查,并假设nil甚至错误都不会发生。 但是,存在一些方法可以在不影响代码安全性的情况下保持代码的清晰和整洁。 此外,如果您的代码需要! 因为可以证明它不会导致nil或错误,所以您可以更新类型签名,从而避免使用!

选装件

许多以可选内容开始的人都会遇到的一个问题是,它们似乎遍布整个代码库。 这仅仅是缺乏经验的产品,带有可选编程的新范例。 确实,以前的经验已经教那些程序员积极检查空引用,但是可选类型不需要像“手动检查值是否存在”这样繁琐和“老式”的做法。 取而代之的是,只要使用mapflatmap功能对它们进行“抬高”,就可以正常执行对可选件的操作。

例如,而不是写:

  //将字串转换成JSON,如果字串无法解析,则传回nil
 func parseJSON(from string:String)-> JSON?  {…} ///将可能丢失的json解析为可能丢失的用户
 //不幸的是,在此处传递nil不会有任何意义吗?
 func parseUser(json:JSON?)->用户吗?  {
    如果让json = json { 
         //解析json
     }其他{
        返回零
     }
 } //结合一切
让parsedUser:用户?  = parseUser(parseJSON(from:userInput)) 

  //如果JSON不是可解析的用户,则将JSON解析为用户 
 //然后返回nil
 func parseUser(json:JSON)->用户?  {
      //解析json
 } //结合一切
让parsedUser:用户?  = userInput.map(parseJSON).flatMap(parseUser) 

这似乎是一个很小的更改,但它确实有助于简化功能签名,并防止了可选选项的泛滥, if let …检查的话。 而且,如果所有可选if value != nilif let …替换为if value != nilif let …它们将毫无意义。

(脚注12: 提升 是指采用现有函数并将其更改,以使其参数和返回类型更改为更通用的形式。在可选情况下, map 将具有 T -> S 签名的函数转换 T? -> S? flatmap 将具有 T -> S? 签名的函数转换 T? -> S?

错误处理

在处理可抛函数时,经常会发生错误,除了中止我们正在做的事情之外,什么都不做。 让我们重用parseJSON示例。 假设您要读取json文件并将其解析为User 。 但是读取文件可能会引发:

  //读取文件的内容并将其放在字符串中
 func readFile(filepath:String)抛出-> String {…} //我们要编写的函数
 //如何表达错误的可能性?
 func readJSON(filepath:String)->用户/ *错误类型?  * / {…} 

readJOSN将读取文件并尝试以User身份解析其内容,但是应该有一种方法可以向调用方发出信号,告知该功能可能失败。 在这种情况下,如果readJSON错误,最合理的readJSON什么? 可能将错误报告给调用者,或者忽略该错误。
如果我们想忽略它,这是我们正在寻找的签名:

  func readJSON(filepath:String)->用户?  {…} 

此函数将readFileparseJSONparseUser组合在一起,并返回文件中包含的用户。 如果出现任何问题,则不返回用户,该函数返回nil 。 让我们看看如何实现它

  func readJSON(filepath:String)->用户?  {
    做{
        让内容=尝试readFile(filepath:filepath)
        返回parseJSON(content).flatMap(parseUser)
     } {
        返回零
     }
 } 

这个实现似乎是正确的,我们已经通过将它们简单地转换为nil来处理了所有可能的错误。 但是,那真是一场仪式! 8行中的4行专用于忽略错误并返回nil。 这是另一种方式:

  func readJSON(filepath:String)->用户?  {
    返回(尝试?readFile(filepath:filepath))
         .flatMap(parseJson)
         .flatMap(parseUser)
 } 

在此示例中,我们使用了try? 与我们之前的实现完全相同的关键字:如果遇到错误,它将返回nil ,否则将正常返回。 flatMap允许我们按顺序组合parseJSONparseUser函数,而不必担心nil情况。
编写这样的事情的另一个好处是,很容易看到发生的事情和顺序:

1.尝试读取文件
2.尝试将文件解析为JSON
3.尝试以用户身份解析JSON

并且类型签名表明“如果发生任何错误,此函数将返回nil”。

我们可以使我们的程序更加简单,同时保留其相关的类型信息。 但是,这种类型签名是我们做出选择的产物。 实际上,我们决定删除对读取文件可能失败的方式的任何提及,并在这样做时隐藏了诊断错误的任何方式。 那可能就是您想要的,但是也许您想保留这些信息,以帮助此功能的用户了解发生故障的原因。 是文件句柄问题吗? 还是解析错误? 在这种情况下,您可能需要像这样更新功能:

  //这次我们都抛出并返回一个可选
 func readJSON(filepath:String)抛出->用户?  {
    返回尝试parseJSON(from:readFile(filepath:filepath))
         .flatMap(parseUser)
 } 

使用此配置,调用方可以推断出nil结果和引发error意味着不同的含义。 具体来说, nil表示解析不成功,而error表示文件存在问题。

这里的可能性是无限的,我们可以抛出parse函数,让用户捕获所有可能的parseJSONparseUser错误,我们可以利用Result类型,我们可以将所有错误封装在自定义ReadJSONError类型中, ReadJSONError 。这些变化保留了我们宝贵的类型信息,同时就代码的功能进行了不同的交流。

Swift不是一门全面的语言,永远不会。 尽管如此,Swift编译器使我们更加相信已编译的程序将表现良好,并通过强制执行一些非常简单的规则(捕获错误,检查nil ,使所有switch语句穷举等)来鼓励程序员编写总的程序。
不幸的是,Swift也有一个逃生舱口! 这有点太容易使用了,并且使所有使程序变得完整的工作都花了很多。 也许将来会有办法选择性地禁用像! 为了在更安全的环境中工作并宣传无碰撞体验。 在那之前,我们只能依靠linter工具。

最后,我只能鼓励您编写更安全的代码,在可能崩溃时进行披露,并使用出色的类型系统。 我所展示的真正令人惊奇的是,其中大多数是功能强大的类型系统的产品。 Swift在编译器中没有特殊的“空检查”阶段。 可选参数就像其他类型一样,您可以组成精确的类型,例如那些传达有关代码非常重要的行为的类型。

这篇文章的灵感来自于我最近完成的爱丁·布雷迪(Edin Brady)的《 Idris的类型驱动开发》一书。 本书介绍了使用Idris(一种纯粹的,总体的,依赖类型的编程语言)进行编程的感觉。 我鼓励任何人都看一下Idris,尽管与使用更多主流编程语言完全不同的体验,但仍有很多课程可以从中学习。