实用的Swift很简单

图片


关于函数式编程的文章写了很多关于FP方法如何改善开发的内容:易于编写,读取,流式传输,编码,测试,构建不良的体系结构 头发变得柔软如丝


缺点之一是进入门槛高。 为了理解FP,我遇到了大量的理论,函子,单子,范畴论和代数数据类型。 实际中如何应用AF尚不清楚。 此外,还提供了我不知道的语言示例-Haskell和rock。


然后,我决定从一开始就确定FP。 我发现并告诉Codefest ,FP实际上只是我们已经在Swift中使用它,并且可以更有效地使用它。


函数式编程:纯函数且无状态


确定以特定的范式编写意味着什么并非易事。 数十年来,具有不同见解的人们已经形成了范例,它们以不同的方式体现在语言中,并被工具所包围。 这些工具和方法被视为范例的组成部分,但实际上并非如此。


例如,据信面向对象的程序设计立足于三个支柱-继承,封装和多态性。 但是封装和多态性是在函数上实现的,与在对象上一样容易。 或闭包-它们诞生于纯功能语言,但由于长期迁移到工业语言,它们不再与FP关联。 Monad也进入了工业语言,但尚未在人们的脑海中失去条件的Haskell。


结果,结果是不可能清楚地确定特定范例是什么。 我再次在Codefest 2019上遇到了这个问题,所有FP专家在谈到功能范例时都说了不同的事情。


就个人而言,我喜欢Wiki中的定义:


“函数式程序设计是离散数学和程序设计范例的一个分支,其中,计算过程被解释为在对函数式数学理解中计算函数的值(与程序编程中作为子程序的函数相反)。”


什么是数学函数? 这是一个函数,其结果仅取决于所应用的数据。


四行代码中的数学函数示例如下所示:


func summ(a: Int, b: Int) -> Int { return a + b } let x = summ(a: 2, b: 3) 

用输入参数2和3调用summ函数,得到5。结果不变。 更改程序,线程,执行位置-结果将保持不变。


非数学函数是在某处声明全局变量的情况。


 var z = 5 

sum函数现在将输入参数和z的值相加。


 func summ(a: Int, b: Int) -> Int { return a + b + z } let x = summ(a: 2, b: 3) 

增加了对全局状态的依赖。 现在,人们不能明确地预测x的值。 根据调用函数的时间,它将不断变化。 我们连续调用该函数10次,每次都能得到不同的结果。


非数学函数的另一个版本:


 func summ(a: Int, b: Int) -> Int { z = b - a return a + b } 

除了返回输入参数的总和,该函数还更改全局变量z。 此功能有副作用。


函数式编程对数学函数有一个特殊的术语-纯函数。 纯函数是针对相同的一组输入值返回相同结果且没有副作用的函数。


纯功能是FP的基础,其他所有功能都是次要的。 假设遵循此范例,我们仅使用它们。 而且,如果您不使用全局或可变状态,那么它们将不在应用程序中。


功能范式中的类和结构


最初,我认为FP仅与函数有关,而类和结构仅在OOP中使用。 但是事实证明,类也适合FP的概念。 假设只有它们应该是“干净的”。


“纯”类是一个类,其所有方法都是纯函数,并且属性是不可变的。 (这是一个非正式术语,为准备报告而创造)。


看一看这个课程:


 class User { let name: String let surname: String let email: String func getFullname() -> String { return name + " " + surname } } 

它可以被视为数据封装...


 class User { let name: String let surname: String let email: String } 

和与他们一起工作的功能。


 func getFullname() -> String { return name + " " + surname } 

从FP的角度来看,使用User类与使用基元和函数没有什么不同。


声明值-用户Vanya。


 let ivan = User( name: "", surname: "", email: "ivanov@example.com" ) 

将getFullname函数应用到它。


 let fullName = ivan.getFullname() 

结果,我们得到一个新值-完整的用户名。 由于无法更改ivan属性的参数,因此调用getFullname的结果不变。


当然,细心的读者可以说:“等等,getFullname方法基于其全局值(类属性,而不是参数)返回结果。 但实际上,方法只是将对象作为参数传递到其中的函数。


Swift甚至明确支持此条目:


 let fullName = User.getFullname(ivan)() 

如果需要更改对象的某些值,例如电子邮件,则必须创建一个新对象。 这可以通过适当的方法来完成。


 class User { let name: String let surname: String let email: String func change(email: String) -> User { return User(name: name, surname: surname, email: email) } } let newIvan = ivan.change(email: "god@example.com") 

Swift中的功能属性


我已经写过很多被认为是范例一部分的工具,实现和方法实际上可以在其他范例中使用。 例如,monad,代数数据类型,自动类型推断,严格类型,从属类型以及编译期间的程序验证被视为FP的一部分。 但是我们可以在Swift中找到许多这样的工具。


强类型和类型推断是Swift的一部分。 他们不需要被理解或引入到项目中,我们只有它们。


没有依存类型,尽管我不会拒绝编译器检查字符串是否为电子邮件,数组,不为空,字典,包含苹果密钥。 顺便说一下,Haskell中也没有依赖类型。


可以使用代数数据类型,这是很酷的,但是很难理解的数学知识。 优点在于,使用它不需要在数学上加以理解。 例如,Int,enum,Optional,Hashable是代数类型。 而且,如果Int使用多种语言,而Protocol使用Objective-C,那么带有关联值的枚举,具有默认实现和关联类型的协议将无处不在。


在谈论诸如rust或haskell之类的语言时,通常会提到编译验证。 可以理解,该语言具有很高的表现力,它允许您描述所有边缘情况,以便编译器对其进行检查。 因此,如果程序被编译,那么它肯定会工作。 没有人怀疑它可能包含逻辑错误,因为您不正确地过滤了数据以显示给用户。 但这不会落空,因为您没有从数据库接收数据,服务器返回了您要为其计数的错误答案,或者用户以字符串而非数字的形式输入了出生日期。


我不能说编译快速代码可以捕获所有错误:例如,很容易防止内存泄漏。 但是强类型和Optional可以防止许多愚蠢的错误。 最主要的是限制强制提取。


Monads:不是FP范例的一部分,而是一种工具(可选)


FP和monad通常在同一应用程序中使用。 有一次,我甚至认为monad是函数式编程。 当我理解它们时(但这是不准确的),我得出了几个结论:


  • 它们很简单;
  • 他们很舒服;
  • 选择性地理解它们,足以应用;
  • 没有它们,您可以轻松做到。

Swift已经有两个标准的monad-Optional和Result。 两者都需要处理副作用。 可选功能可防止可能的损坏。 结果-来自各种异常情况。


考虑一下荒谬的例子。 假设我们具有从数据库和服务器返回整数的函数。 第二个可能返回nil,但是我们使用隐式提取来获取Objective-C时间行为。


 func getIntFromDB() -> Int func getIntFromServer() -> Int! 

我们继续忽略Optional并实现一个将这些数字相加的函数。


 func summInts() -> Int! { let intFromDB = getIntFromDB() let intFromServer = getIntFromServer()! let summ = intFromDB + intFromServer return summ } 

我们调用最终函数并使用结果。


 let result = summInts() print(result) 

这个例子行得通吗? 好吧,它肯定可以编译,但是任何人都不知道我们是否在运行时崩溃。 这段代码很好,它完美地表明了我们的意图(我们需要两个数字之和),并且不包含任何多余的内容。 但是他很危险。 因此,只有初级和有自信的人才能这样写。


更改示例以使其安全。


 func getIntFromDB() -> Int func getIntFromServer() -> Int? func summInts() -> Int? { let intFromDB = getIntFromDB() let intFromServer = getIntFromServer() if let intFromServer = intFromServer { let summ = intFromDB + intFromServer return summ } else { return nil } } if let result = summInts() { print(result) } 

这段代码很好,很安全。 使用显式提取,我们防御了可能的无效。 但这变得很麻烦,在安全检查中,已经很难分辨出我们的意图。 我们仍然需要两个数字的和,而不是安全检查。


在这种情况下,Optional具有一个映射方法,该方法继承自Haskell的Maybe类型。 我们将其应用,示例将发生变化。


 func getIntFromDB() -> Int func getIntFromServer() -> Int? func summInts() -> Int? { let intFromDB = getIntFromDB() let intFromServer = getIntFromServer() return intFromServer.map { x in x + intFromDB } } if let result = summInts() { print(result) } 

甚至更紧凑。


 func getIntFromDB() -> Int func getintFromServer() -> Int? func summInts() -> Int? { return getintFromServer().map { $0 + getIntFromDB() } } if let result = summInts() { print(result) } 

我们使用map将intFromServer转换为所需的结果,而无需提取。


我们摆脱了summInts内部的检查,但将其留在了顶层。 这是有意进行的,因为在计算链的最后,我们必须选择一种处理结果不足的方法。


弹出


 if let result = summInts() { print(result) } 

使用默认值


 print(result ?? 0) 

如果未接收到数据,则显示警告。


 if let result = summInts() { print(result) } else { print("") } 

现在,该示例中的代码不包含太多内容(如第一个示例中所示),并且是安全的(如第二个示例中所示)。


但是地图并不总是可以正常工作


 let a: String? = "7" let b = a.map { Int($0) } type(of: b)//Optional<Optional<Int>> 

如果我们传递一个要映射的函数,其结果是可选的,则会得到一个双精度的Optional。 但是我们不需要双重保护。 一个就足够了。 flatMap方法可以解决此问题,它是地图的相似之处,只是有一个区别,它部署了matryoshkas。


 let a: String? = "7" let b = a.flatMap { Int($0) } type(of: b)//Optional<Int>. 

另一个例子,其中map和flatMap使用起来不是很方便。


 let a: Int? = 3 let b: Int? = 7 let c = a.map { $0 + b! } 

如果一个函数接受两个参数并且它们都是可选的,该怎么办? 当然,FP有一个解决方案-这是一个实用的函子和烦人的事。 但是,这些工具在不使用非我们语言所用的特殊运算符的情况下显得相当笨拙,并且编写自定义运算符被认为是不好的形式。 因此,我们考虑一种更直观的方法:我们编写一个特殊的函数。


 @discardableResult func perform<Result, U, Z>( _ transform: (U, Z) throws -> Result, _ optional1: U?, _ optional2: Z?) rethrows -> Result? { guard let optional1 = optional1, let optional2 = optional2 else { return nil } return try transform(optional1, optional2) } 

它使用两个可选值作为参数,并使用带有两个参数的函数。 如果两个选项都具有值,则将函数应用到它们。
现在,我们可以使用多个选项而无需部署它们。


 let a: Int? = 3 let b: Int? = 7 let result = perform(+, a, b) 

第二个monad Result也具有map和flatMap方法。 因此,您可以以完全相同的方式使用它。


 func getIntFromDB() -> Int func getIntFromServer() -> Result<Int, ServerError> func summInts() -> Result<Int, ServerError> { let intFromDB = getIntFromDB() let intFromServer = getIntFromServer() return intFromServer.map { x in x + intFromDB } } if case .success(let result) = summInts() { print(result) } 

实际上,这就是使monad聚集在一起的能力-能够在不删除容器内值的情况下使用它。 我认为,这使代码简洁。 但是,如果您不喜欢它,只需使用显式提取,就不会与FP范式矛盾。


示例:减少脏函数的数量


不幸的是,在实际程序中,全局状态和副作用无处不在-网络请求,数据源,UI。 并且只有纯函数不能被放弃。 但这并不意味着FP对我们来说是完全不可访问的:我们可以尝试减少通常有很多脏函数的数量。


让我们看一个接近生产开发的小例子。 构建UI,特别是输入表单。 该表格有一些限制:


1)登录不少于3个字符
2)密码至少6个字符
3)如果两个字段均有效,则“登录”按钮处于活动状态。
4)字段框架的颜色反映其状态,黑色-有效,红色-无效


描述这些限制的代码可能如下所示:


处理任何用户输入


 @IBAction func textFieldTextDidChange() { // 1.     // 2.   guard let login = loginView.text, let password = passwordView.text else { // 3. - loginButton.isEnabled = false return } let loginIsValid = login.count > constants.loginMinLenght if loginIsValid { // 4. - loginView.layer.borderColor = constants.normalColor } let passwordIsValid = password.count > constants.passwordMinLenght if passwordIsValid { // 5. - passwordView.layer.borderColor = constants.normalColor } // 6. - loginButton.isEnabled = loginIsValid && passwordIsValid } 

登录完成处理:


 @IBAction func loginDidEndEdit() { let color: CGColor // 1.     // 2.   if let login = loginView.text, login.count > 3 { color = constants.normalColor } else { color = constants.errorColor } // 3.   loginView.layer.borderColor = color } 

密码完成处理:


 @IBAction func passwordDidEndEdit() { let color: CGColor // 1.     // 2.   if let password = passwordView.text, password.count > 6 { color = constants.normalColor } else { color = constants.errorColor } // 3. - passwordView.layer.borderColor = color } 

按下输入按钮:


 @IBAction private func loginPressed() { // 1.     // 2.   guard let login = loginView.text, let password = passwordView.text else { return } auth(login: login, password: password) { [weak self] user, error in if let user = user { /*  */ } else if error is AuthError { guard let `self` = self else { return } // 3. - self.passwordView.layer.borderColor = self.constants.errorColor // 4. - self.loginView.layer.borderColor = self.constants.errorColor } else { /*   */ } } } 

这段代码可能不是最好的,但总的来说它是好的并且有效。 是的,他有很多问题:


  • 4个显式摘录;
  • 4依赖于全局状态;
  • 8种副作用;
  • 非显而易见的最终状态;
  • 非线性流动。

主要问题是您不能只说出屏幕上发生的事情。 查看一种方法,我们会看到它对全局状态有何作用,但我们不知道谁,何时何地触及该状态。 结果,要了解正在发生的事情,您需要找到所有具有视图的工作点,并以什么顺序了解会产生什么影响。 牢记所有这些非常困难。


如果更改状态的过程是线性的,则可以逐步研究它,这将减轻程序员的认知负担。


让我们尝试更改该示例,使其更加实用。


首先,我们定义一个描述屏幕当前状态的模型。 这将使您确切地知道工作所需的信息。


 struct LoginOutputModel { let login: String let password: String var loginIsValid: Bool { return login.count > 3 } var passwordIsValid: Bool { return password.count > 6 } var isValid: Bool { return loginIsValid && passwordIsValid } } 

描述应用于屏幕的更改的模型。 她需要确切地知道我们将会改变什么。


 struct LoginInputModel { let loginBorderColor: CGColor? let passwordBorderColor: CGColor? let loginButtonEnable: Bool? let popupErrorMessage: String? } 

可能导致新屏幕状态的事件。 因此,我们将确切知道哪些操作会改变屏幕。


 enum Event { case textFieldTextDidChange case loginDidEndEdit case passwordDidEndEdit case loginPressed case authFailure(Error) } 

现在我们描述改变的主要方法。 此纯函数基于当前状态事件,收集屏幕的新状态。


 func makeInputModel( event: Event, outputModel: LoginOutputModel?) -> LoginInputModel { switch event { case .textFieldTextDidChange: let mapValidToColor: (Bool) -> CGColor? = { $0 ? normalColor : nil } return LoginInputModel( loginBorderColor: outputModel .map { $0.loginIsValid } .flatMap(mapValidToColor), passwordBorderColor: outputModel .map { $0.passwordIsValid } .flatMap(mapValidToColor), loginButtonEnable: outputModel?.passwordIsValid ) case .loginDidEndEdit: return LoginInputModel(/**/) case .passwordDidEndEdit: return LoginInputModel(/**/) case .loginPressed: return LoginInputModel(/**/) case .authFailure(let error) where error is AuthError: return LoginInputModel(/**/) case .authFailure: return LoginInputModel(/**/) } } 

最重要的是,这种方法是唯一被允许从事新国家建设的方法,而且它是干净的。 可以逐步研究它。 查看事件如何将屏幕从A点转换为B点。如果发生故障,那么问题就出在这里。 而且很容易测试。


添加辅助属性以获取当前状态,这是唯一依赖于全局状态的方法。


 var outputModel: LoginOutputModel? { return perform(LoginOutputModel.init, loginView.text, passwordView.text) } 

添加另一个“脏”方法来创建更改屏幕的副作用。


 func updateView(_ event: Event) { let inputModel = makeInputModel(event: event, outputModel: outputModel) if let color = inputModel.loginBorderColor { loginView.layer.borderColor = color } if let color = inputModel.passwordBorderColor { passwordView.layer.borderColor = color } if let isEnable = inputModel.loginButtonEnable { loginButton.isEnabled = isEnable } if let error = inputModel.popupErrorMessage { showPopup(error) } } 

尽管updateView方法不干净,但这是屏幕属性更改的唯一位置。 计算链中的第一项和最后一项。 如果出现问题,这就是断点所在。


仅在正确的地方开始转换。


 @IBAction func textFieldTextDidChange() { updateView(.textFieldTextDidChange) } @IBAction func loginDidEndEdit() { updateView(.loginDidEndEdit) } @IBAction func passwordDidEndEdit() { updateView(.passwordDidEndEdit) } 

loginPressed方法有点独特。


 @IBAction private func loginPressed() { updateView(.loginPressed) let completion: (Result<User, Error>) -> Void = { [weak self] result in switch result { case .success(let user): /*  */ case .failure(let error): self?.updateView(.authFailure(error)) } } outputModel.map { auth(login: $0.login, password: $0.password, completion: completion) } } 

事实是,单击“登录”按钮会启动两个计算链,这是不被禁止的。


结论


在学习FP之前,我非常强调编程范例。 对于我来说,重要的是代码遵循OOP,我不喜欢静态函数或无状态对象,没有编写全局函数。


现在在我看来,所有我认为属于范例的事物都是相当武断的。 最主要的是干净,可理解的代码。 为了实现此目标,您可以使用所有可能的方法:纯函数,类,monad,继承,组合,类型推断。 他们都相处得很好,使代码变得更好-只需将它们应用于该位置即可。


还有什么要阅读的话题


Wikipedia中函数式编程的定义
Haskell入门书
手指上的函子,单子词和适用函子的解释
Haskell关于使用Maybe的实践的书(可选)
关于Swift的功能本质的书
从Wiki定义代数数据类型
关于代数数据类型的文章
另一篇关于代数数据类型的文章
Yandex关于Swift上的函数式编程的报告
在Swift上实现Prelude标准库(Haskell)
Swift上具有功能工具的库
另一个图书馆
还有一个

Source: https://habr.com/ru/post/zh-CN455359/


All Articles