语义,而非语法;使用函数式优先编程增强开发人员的能力
“这与语法无关,而与语义有关。” ——理查德·费尔德曼
这篇文章只是我关于自己喜欢的语言以及为什么喜欢它们的一些想法的总结。总的来说,我认为软件开发者就像艺术家一样;我们对不同技术的喜爱或抗拒很大程度上受到新旧程度、情感联系和个人联想的影响。我们喜欢自己喜欢的东西,而不一定是那些“正确”的东西,即使它们真的有所谓的“正确”之处。
然而,最近我看到一些语言给我自己和其他开发人员带来了快乐,我花了一些时间思考为什么会这样;是什么让这些(看似不同且不相关的)语言似乎都激发了用户同样的热情和兴趣。
这种看似的差异其实至关重要。Rust、Elixir、f# 和 Go 绝不会混淆,但它们的拥护者们的情感反应却让人感觉似曾相识。在函数定义、平台、对象定义等各种怪癖之间,似乎存在着一些更奇妙的设计理念吸引着人们。
因此,我想提出一些我注意到的问题,并解释一下为什么我认为它们对我们很重要。
注意:我将在这里使用我用F#编写的贪吃蛇游戏的一个小型实现来举例,因为 F# 语言几乎涵盖了我今天要讲的所有内容。而且,我也很喜欢它。
默认不变
如果要我提出这些语言语义中最强大的一个来影响和改进我们编写的程序类型,那一定是让变量在初始化后默认不可更改。显式修改的概念似乎如此根深蒂固地融入了编程的意义,以至于没有它,编程似乎难以想象。那么,用它编程意味着什么呢?
对计算机来说,没什么区别。本质上,利用不可变性的语言和我们熟悉的内存变量和空间是一样的。但对开发者来说,能够保证数据永远是你第一次定义的样子,这很重要。如果你想要一些新的东西,你可以用第一个东西作为新东西的模板,但它们是不一样的。
让我们看一个例子。
[<StructAttribute>]
type Game = { Food: Food; Snake: Snake; Size: int; Status: Status }
let advance game =
match game with
| AlreadyOver -> game
...
| EatsFood ->
let newSnake = updateSnake game.Snake true
let newFood = createFood game.Size (newSnake.Head :: newSnake.Tail)
{ game with Snake = newSnake; Food = newFood }
这里我们实现了贪吃蛇的核心数据类型,即 类型Game
,它是一个记录/对象,包含 类型的字段: Food Food
, Snake Snake
, Size int
, Status 。稍后我们将在文章中学习这些类型,但现在我想重点介绍的是下面显示的函数Status
片段。advance()
advance()
是一个接受游戏信息并返回游戏信息的函数。我删掉了大部分实现,但保留了 Advance 判断蛇是否吃到了食物的部分。
我们来看一下操作顺序:
let newSnake = updateSnake game.Snake true
用于根据旧蛇的状态创建新蛇。let newFood = createFood game.Size (newSnake.Head:: newSnake.Tail)
通过传递网格的大小和新的蛇来创建新的食物。- 最后我们返回
{ game with Snake = newSnake; Food = newFood }
。现在,这看起来很像一个状态更新。它将游戏字段更改为这些新值。但它实际上是在创建一个新记录,使用旧游戏中的值,但添加了这些新的更改。
旧游戏未经修改。游戏返回的值完全不同。但语言的语义使其能够廉价、高效且合理地生成新值。因此,我们不必担心后续操作会意外改变先前的值。
思考最后一点至关重要。并非我们不能在其他语言中这样编程,只是它们的语义使其不那么值得。追踪是否进行修改会更加困难。这些语言中惯用的方法和函数都会发生修改。过于频繁地创建新值可能会带来性能开销。这些都是使用不可变值的语义障碍,它们剥夺了开发人员这种编程风格的权力,导致代码更加不稳定。
在 F# 和Rust等语言中,mutable
关键字是有意向的指示,表明你打算修改某个值。而在Elm等语言中,你根本无法进行修改。但无论哪种方式,它都让程序员在代码中更改状态时更加深思熟虑。而在软件开发领域,深思熟虑至关重要*。
无默认空值
我不会在这个问题上花太多时间,因为很多人已经详细阐述了空值的危险。
可以说,在不能或不能保证函数将返回您期望的类型的语言中,很难信任类型、类型和函数本身,也许更重要的是,不强制您编写返回您所说的类型的操作。
函数返回Some value
或 是可以的Nothing
。这是一个语义上正确的逻辑运算。但有时事情会失败。如果因为忘记在函数的所有操作路径中返回值而导致语言插入空值,那就不好了。编写和使用不可信的代码会很困难。如果每个函数都无法返回其应有的值,那么阅读和遵循文档也会很困难。
let changeDirection game proposedChange =
match proposedChange with
| Perpendicular game.Snake.Direction ->
{ game with Snake = { game.Snake with Direction = proposedChange } }
| _ -> game
此函数changeDirection
负责改变蛇的移动方式。它包含一些保护逻辑,以确保蛇的方向只能垂直改变。向上移动的蛇可以向左或向右移动,但不能反向移动,例如,不能反转回原位。
| _ -> game
是我们的匹配 (switch) 语句的默认情况,其中我们返回未更改的游戏。如果出现以下情况,F# 会报错:
- 我们忘记了默认值(或未能考虑输入的所有可能形状)
- 这个函数在任何时候都只能返回游戏以外的任何内容。除非我们告诉它这个函数可以返回游戏或其他内容,否则它不会编译。但是,每次调用这个函数时,我们都必须处理它可能返回游戏也可能不返回游戏的情况。我们所有的输入都必须与输出匹配。
这意味着如果我说一个函数返回一个int
,语言本身将确保我没有撒谎,而我宁愿不撒谎。
据我所知,过去十年中创建的几乎所有语言都没有为其函数和对象提供默认的可空性。原本是为了提供便利,结果却成了弊端,开发人员更喜欢没有它。
简洁
关于简洁,我有很多想法。太多想法了。我无法一一写出来,因为不简洁就意味着简洁本身就是错的。
简而言之,
编程语言和范式的流行度时起时落,但没有什么会真正消亡。在 90 年代和 21 世纪初,我们见证了 C#、C++ 和 Java 的鼎盛时期,它们曾是软件开发的热门语言。很多时候,人们认为Python、Ruby 和 JavaScript 等动态语言的兴起,是对开发人员感受到企业级语言开销带来的摩擦的直接回应。
有些人认为这是对静态类型僵化的一种抵制。开发人员想要更多的自由,减少在“类型争论”上的时间,选择执行操作而不是定义结构。
我认为这只是部分原因,而不是全部。具体来说,我不认为类型一定是问题所在,而更像是极其冗长的语法造成的附带损害。
花括号、可访问性修饰符、分号、分号、分号以及无处不在的类型定义,对于新开发人员来说,是一种令人生畏的语法,而且似乎给新兴开发人员增加了负担;你对语言的理解越深入,你似乎就必须编写越多的代码来表达自己。
let private opposite = // Direction -> Direction
function
| Up -> Down
| Down -> Up
| Left -> Right
| Right -> Left
这是 F# 中的一个私有函数,它返回的是相反的方向。F# 在其函数中没有显式的返回语句;所有内容都是表达式,因此函数体是有效的返回值。缩进处理函数体的边界。换行符定义 switch 中的后续 case。箭头 (->) 将 case 与结果分隔开。Mise en place。
与老一代企业级语言相比,像 Python、F# 和 Rust 这样的语言竭尽全力消除每个结构中多余的语法、冗长的符号和过于繁琐的解释。它们将空格作为语法;这种想法对于编译来说可能没什么意义,但对人类可读性却有着巨大的提升。人们可以阅读和解析的代码在词汇上简洁明了。
总的来说,语言变得越来越简洁,越来越具有表现力,越来越依赖直观的空白来确定范围。
至于详细类型定义的问题:
类型推断
作为上面讨论的简洁模式的直接延续,最近我们看到了类型推断的出现(语言编译器或运行时根据使用情况确定和强制类型的能力)。
到目前为止,我向您展示的所有 F# 代码都是完全/强类型的。每个函数参数和函数返回值都经过类型检查器的推断和强制执行。
一些工具(例如 VSCode Ionide扩展)利用了这一点并会为您显示类型。
您在此处看到的所有类型注释都覆盖在代码上。它们实际上并未写入文件中。
当我知道我可以获得强类型和编译时保证的所有好处而不必明确地写出所有类型信息时,我很难回到动态语言。
安全与简洁兼具。诚然,类型推断并非完美,如果您在非优化的编辑器环境中阅读代码,可能会丢失上下文。但在这种情况下,它仍然不会比动态代码更糟糕,而且您仍然知道所有逻辑都是类型安全的,并且已通过检查。
我个人从未感受到动态编程爱好者提到的使用静态类型所带来的速度减慢——我认为使用强类型可以更快地编写工作代码——但如果你关心速度和表现力,类型推断似乎是缓解它的一种很好的方法。
抽象数据类型
我们已经达到了我想在这里讨论的最后一个模式,我觉得我把最好的留到了最后;至少对我来说,这是我们在这里讨论的所有内容中我个人最喜欢的,也是对我作为开发人员的进步影响最大的。
代数数据类型又称自定义类型(又称联合和乘积类型)是一个相对简单的概念,具有深刻的应用。
归根结底,编程就是向机器发出指令,让它执行有意义的工作。现代编程涉及进行抽象,从而生成可维护的代码,并执行我们期望的行为。值、函数、类、模块以及所有这些命名空间使我们能够定义结构和思想,将我们努力的实际领域映射到数据结构和逻辑的程序空间。
代数数据结构 (ADT) 提供了一种简单的语法,以尽可能少的开销来表达问题的形状。
让我们看看如何。
type Game = { Food: Food; Snake: Snake; Size: int; Status: Status }
and Snake = { Head: Head; Tail: Tail; Direction: Direction }
and Head = Position
and Tail = Position list
and Food = Position
and Position = int * int
and Status =
| Active
| Won
| Lost
and Direction =
| Up
| Down
| Left
| Right
这些类型代表了我的贪吃蛇游戏的领域。这些概念,可以说,对贪吃蛇的概念来说,是有意义的。什么是贪吃蛇游戏?
玩蛇游戏至少需要多少信息量?
这里发生了一些有趣的事情:
- 游戏的数据结构由较小的结构组成
- 我们可以轻松地为类型添加别名(赋予它们一个语义更明确、与应用程序上下文相关的名称)。例如,how
Food
和蛇Head
都只是位置信息,但为了更清晰起见,我们可以在整个代码中使用它们的别名。 - Status 和 Direction 都是Union 类型。它们类似于枚举,但本质上并非整数或字符串。它们是完全限定的值,我们可以在代码中使用它们,例如创建我们自己的应用程序独有的原语。
你可能不会觉得这特别令人兴奋,说这些只是花哨的枚举和记录,但 ADT 完全不受形状的束缚:
type Message =
| Restart
| Dir of Direction
| Tick
这里我们创建了一个Message
具有两个更简单值的类型,Restart
它Tick
不依赖于任何其他数据,并且有一个参数化值,Dir
它需要一个Direction
。
let update game msg =
match msg with
| Restart -> Game.init 10
| Dir direction -> Game.changeDirection game direction
| Tick -> Game.advance game
当我们使用此数据类型时,我们可以做出判断并访问与每个值相关的数据,但不能混淆它们。除了该情况之外,我们不能在任何情况下使用方向Dir direction
,因为方向仅存在于该值上。
这使我们能够精确地建模领域,而不会造成浪费。用 Java 这样的语言来表达类似这样的消息类型并非易事,而且需要编写大量的代码。因此,人们很少这样做,而是选择使用更多的可变性和可空值来处理数据缺失或不应存在的状态。
这会导致更多的错误。
我们不应该把现实世界的领域塞进编程语言的原始类型中;我们的编程语言应该提供工具来精确地、无浪费地表示我们的领域。表示得越好,处理数据就越容易。
现在,无论我想用什么语言,ADT 对我来说都是首选功能。一种语言越是难以描述某个事物的本质,我就越不想用它。
最后的想法
如果您已经到达这里,感谢您花时间阅读我写的关于我喜欢的模式的小情书,以及我认为其他开发人员也喜欢它们的原因。
我特意没有提到函数式编程,但一路走来,我完全没有提到。虽然几乎所有这些模式都起源于函数式编程领域,并在其基础上经历了显著的迭代,但最近我发现自己不再试图将世界划分为函数式编程和非函数式编程;我更愿意讨论我喜欢的模式,以及实现这些模式的工具。
F# 本身最近也做了同样的事情,其更新的标语是:
F# 使每个人都能编写简洁、强大且高效的代码
我们的目标不是函数式或面向对象,也不是成为最流行的语言或最快的语言。
它是为了帮助人们编写优秀的代码。
它是为了帮助开发人员表达他们的愿望。
它是为了避免错误和漏洞。
能够很好地执行这些理念的语言似乎很受欢迎。而且不仅仅是新语言。所有老牌工具和框架都在考虑赋能开发者。
这其实从来都与分号无关。我们刚才讨论的内容并非风格指南中的建议或推荐。它们并非埋藏在诸如“你需要了解的关于 X 的一切”之类的大部头中。它们本身就融入了语言生态系统之中。
请在评论中告诉我您的想法,以及哪些语言模式给您带来快乐。
文章来源:https://dev.to/kirkcodes/semantics-not-syntax-developer-empowerment-using-functions-first-programming-45oo