结束战争还是继续战争?让我们将函数式编程引入 OOP 代码库
随着时间的推移,我越来越热衷于函数式编程。即使在面向对象编程 (OOP) 代码库中工作,我也会尝试运用一些小概念来简化代码,并更容易预测结果。作为一名 Ruby 程序员,我也很喜欢函数式代码易于编写单元测试的特点。
本文旨在分享我对函数式编程概念的看法,并提出一种在现有 OOP 代码中使用函数式概念的方法。希望我们不再争论哪种范式更好,而是开始构思好的想法,从而每次都能写出越来越好的代码!
目录
- 一开始是函数式编程
- 进入OOP,这个范式是什么?
- 什么是类以及我们如何以不同的方式思考它
- 变异还是不变异:什么是不变性
- 床下的怪物:有哪些副作用
- 隔离一切:什么是纯函数
- 不使用完整 Haskell 即可使用函数模式
- 结论
一开始,有函数式编程
函数式编程范式诞生于 1958 年,当时第一门 Lisp 语言问世(美好的旧时光)。它的根源可以追溯到 Alonzo Church 的 lambda 演算。函数式编程的核心原则是尽量减少代码库对状态的依赖。
与允许状态但强调封装的面向对象编程 (OOP) 不同,函数式程序员致力于优先编写无状态的组件。这种方法鼓励创建独立于外部状态变量的代码。
此外,即使引入了状态,也必须考虑不变性、函数的纯度,甚至避免副作用。所有这些概念将在文章中进一步讨论。
进入OOP,这个范式是什么?
OOP,或者更广为人知的名字是面向对象编程,是一种可以追溯到 1967 年的范式。其最杰出的代表是 Simula、Smalltalk 和 Java。其背后的思维过程是通过强制封装实践来减少“全局”状态的数量,将状态以及任何修改它们的行为归入一个通用的“实体”或“对象”之下。
事实上,“面向对象编程”这个名称多年来一直被广泛讨论。OOP 的创始人之一 Alan Key 实际上更倾向于关注该范式的消息传递功能。这意味着我们应该强调封装,并允许对象之间进行状态和行为的通信。或许在另一个世界,我们本可以拥有“面向消息的编程”。然而,OOP 这个名称多年来一直沿用至今,如今我们终于迎来了它!
我不知道你是怎么想的,但是考虑范式的另一个可能名称的这个简单过程让我的思绪疯狂起来,重新思考一些概念,实际上简化了我构建软件的方式。
什么是类以及我们如何以不同的方式思考它
我想大家都上过那种经典的讲座,讲的是“动物”类,里面又包含一个“狗”类,对吧?你可能也听过类似的话(至少我听过)。
类是现实世界中实体的蓝图,描述其特征和行为。
虽然并非错误,但我建议稍微修改一下措辞,以便更清楚地解释封装的含义,就像我之前提到的那样。让我们考虑以下新的引文:
类是一种封装状态和操作该状态的行为的方式。
这个简单的词语变化确实让我的思维发生了巨大的转变。我不再试图将类视为现实世界的实体,而是开始简单地将其视为另一种将具有相似上下文的状态分组在一起并公开函数来操作这些状态的方式。我希望这个小小的改变也能帮助你回顾自己的概念!
以这种方式抽象这个概念的重要性在于,在阅读具有不同结构(例如模块)的语言的代码时变得熟练。我们可以观察到这段用 TypeScript 编写的 OOP 代码:
class Github {
private _url: string;
private _repo: string;
private _username: string;
constructor(url: string, repo: string, username: string) {
this._repo = repo
this._username = username
this._url = url
}
public createRepo(name: string): void {
// TODO: do stuff here using the provided state in _url, _repo and _username
}
}
完全等同于这个 elixir 代码,尽管 elixir 代码使用“模块”而不是“类”:
defmodule Github do
@url ""
@repo ""
@username ""
defstruct url: @url, repo: @repo, username: @username
def new(url, repo, username) do
%Github{url: url, repo: repo, username: username}
end
def create_repo(%Github{repo: repo, username: username}, name) do
# TODO: do stuff here using the provided state in url, repo, and username
end
end
接下来我们将研究一些功能概念并进一步讨论这些范式的合并,开始吧!
变异还是不变异:什么是不变性
现在我们开始接触第一个真正的函数式概念,而且是一个非常重要的概念(一切都计划好了)!为了正确理解不变性,让我们回顾一下编程中处理值的方式:
通常,我们将值绑定到变量,以便稍后对其进行操作,对吗?例如,
# Bounding values to variables
name = 'Cherry'
age = 23
# Operating on it and bounding to another variable
year_born = Time.now.year - age
# Printing it
puts "#{name} has #{age} years old and was born at #{year_born}"
对于这些变量,通过更新其值来更改原始变量是很常见的,但这里缺少的部分是:修改变量是一种破坏性操作。
但是……为什么呢?好吧,让我们想象一下,多个操作(函数或代码块)在不同时刻和频率修改同一个变量。在这种情况下,我们会产生很多问题,例如:
-
1.无法重新排序操作或根本改变它们:当我们有如此多的依赖代码时,重新排序或更改代码甚至会很困难,因为所有内容都与特定的更改顺序相关。
-
2. 理解代码功能需要一定的脑力投入:虽然这只是我个人的观点,但我认为这是一个普遍接受的观点。高度可变的代码很容易变得混乱,难以理解数据流,需要借助调试器之类的工具来逐步完成转换。
-
3. 测试时的困难:模拟函数转换的特定状态非常困难,这会逐渐扩展您的单元测试,直到它们不再是单元。
不变性可以定义为一种避免改变(或变异)程序中任何变量的做法。虽然根据语言的不同,我们可能需要做出让步并改变一些受控变量,但总的来说,我们可以从中吸取的教训是:
我们应该不惜一切代价避免改变没有定义范围的变量。
这句话的意思是,在函数内部创建一个作用域变量并在那里修改它是可以的。但是,一旦你将这个可变变量传递给另一个函数,修改同一变量的目标数量就会增加,你的控制权就会逐渐丧失。这正是我们想要避免的情况!
床下的怪物:有哪些副作用
每当有人提出这个话题时,它总是会引起很大的热议。我可能无法涵盖这个主题的所有细节,但我一定会向你解释它们是什么,以及我如何在自己的软件中管理副作用,好吗?
那么,副作用是指所有通过调用协议(HTTP、WebSocket、GraphQL 等)或操作标准输入/输出与外部资源(或“外部世界”)交互的计算。是的,我知道,即使是我们这些无害的程序,print
在这里也难辞其咎。😔
但与可变性不同的是,我们不应该尽可能地避免使用副作用,而应该将其隔离到专门处理副作用的特定函数中。这样,我们就将代码划分为“不产生任何副作用的函数”和“产生副作用的函数”。但究竟为什么要担心这种划分呢?
每当我们向“外部世界”触发任何操作时,我们就会失去对特定计算结果的控制(例如,执行 HTTP 调用时,服务器可能宕机,甚至根本不存在)。其他问题包括测试难度增加以及代码可预测性的降低。
由于我们无法编写任何没有副作用的现实世界软件,因此通常的建议是将其聚集成小函数,并针对错误进行适当的抽象,从而单独处理这些函数。这样,我们就可以只测试我们完全控制的函数,并模拟所有产生副作用的函数。
例如,考虑以下执行 HTTP 请求的函数和转换从其返回的数据的小函数。
require 'faraday'
module MyServiceModule
# This function perform side effects
def perform_http_request
conn = Faraday.new(url: "fakeapi.com")
begin
response = conn.get
{ok: true, data: response.body}
rescue => e
{ok: false, error: e}
end
end
# These functions doens't perform any side effects
def upcase_name(name)
return '' unless name.is_a?(String)
name.upcase
end
def retrieve_born_year(age)
return 0 unless age.is_a?(Integer)
Time.now.year - age
end
end
明白我之前说的“围绕错误的抽象”了吧?这正是上面代码示例中实现的,我们针对哈希进行了抽象,而不是让异常冒泡。
在明确定义这些函数的“副作用”和“无副作用”之后,我们就可以很容易地预测代码中会发生什么,并且也更容易测试,如下所示:
require 'minitest/autorun'
class TestingStuff < Minitest::Test
def test_upcase_name
assert_equal MyServiceModule.upcase_name "cherry", "CHERRY"
assert_equal MyServiceModule.upcase_name "kalane", "KALANE"
assert_equal MyServiceModule.upcase_name "Thales", "THALES"
end
def test_retrieve_born_year
Time.stub :now, Time.new(2024, 3, 5) do
assert_equal MyServiceModule.retrieve_born_year 23, 2001
assert_equal MyServiceModule.retrieve_born_year 20, 2004
assert_equal MyServiceModule.retrieve_born_year 14, 2010
end
end
end
这个策略真的很棒,因为你甚至不需要担心测试时的副作用,只需为代码的转换部分编写断言,就能得到更好的测试,真正验证代码库中的重要部分!是不是很棒?
隔离一切:什么是纯函数
现在是时候总结一下到目前为止学到的知识了。在前面的例子中,我们观察到代码被分为“副作用”和“无副作用”。我们还看到了这些函数如何更容易测试,以及我们的主要转换业务逻辑应该保持隔离。你想知道这些函数叫什么吗?它们就是纯函数!
让我们研究纯函数的适当形式定义并逐步探索这个概念。
纯函数是遵循不变性、不执行任何副作用并且在给定相同参数的情况下返回相同输出的函数。
基本上,纯函数遵循我们之前提到的所有原则,而且它们对于相同的参数总是产生相同的返回值。让我们来看看之前的函数。
def upcase_name(name)
return '' unless name.is_a?(String)
name.upcase
end
upcase_name('cherry') # => Will be *always* CHERRY
使用纯函数,我们可以轻松定义多个断言,因为我们不受任何需要大量模拟的上下文的约束。我们只需将所需的参数以静态值的形式传递即可!
由于纯函数非常小且可组合,它们的数量增长非常快。为了解决这个问题,像 Elixir 这样的函数式语言提供了类似管道的组合运算符,这使得按顺序执行多个纯函数变得非常容易。
"cherry "
|> trim
|> upcase # => "CHERRY"
管道运算符源自 Bash 等函数。您可以在此处阅读更多相关信息:[ https://dev.to/cherryramatis/linux-filters-how-to-streamline-text-like-a-boss-2dp4#what-is-a-pipeline ]
不使用完整 Haskell 即可使用函数模式
我一直害怕学习函数式范式,因为社区用现成的语句和宏大的概念,让每个想学习小技巧的人都觉得它很复杂。在掌握了许多函数式语言并尽可能多地学习之后,我的目标变成了简化这些概念,最重要的是,即使在 OOP 代码中也要提倡使用函数式概念。
应用纯函数(或者纯方法,如果你喜欢的话)、不变性和副作用分离可以使你的 OOP 代码看起来更简洁、更解耦。你不需要知道什么是 monad,也不用知道如何在 Haskell 中手动编写编译器;你只需使用简单有效的函数式概念,继续使用你的 Ruby on Rails 即可!
我希望通过这篇小文章(以及本系列中的其他文章),您可以改进您的代码库,使其具有可组合性和简单性,无论您选择哪种语言和框架。
结论
本文旨在在我的能力和专业知识范围内,普及函数式范式的知识。需要强调的是,我并非函数式编程专家,本文面向的是了解面向对象编程 (OOP) 并对函数式编程感兴趣的初学者。希望本文对您有所帮助,并愿意提供任何必要的帮助。愿原力与你同在🍒
文章来源:https://dev.to/cherryramatis/ending-the-war-or-continuing-it-lets-bring-function-programming-to-oop-codebases-3mhd