如何写出更优雅的代码——编程范式简述

《如何写出更优雅的代码——编程范式简述》源站链接,阅读体检更佳!

什么是程序?

1976年,瑞士计算机科学家,Algol W,Modula,Oberon 和 Pascal 语言的设计师 Niklaus Emil Wirth写了一本非常经典的书《Algorithms + Data Structures = Programs》,即算法 + 数据结构 = 程序。这本书主要写了算法和数据结构的关系,对计算机科学产生了深远的影响。同时这本书也阐明了编程语言的两个方面——数据和对数据的操作。说起来程序的构成无非就是组织数据,然后操作数据产生结果,好像非常简单,但是落实到具体的编码的时候,事情往往就变得比较复杂了。比如:

  • 对于程序中的数据我们应该怎么组织和存储呢?是把所有的数据都存储在一个统一的区域,还是分区域进行存储呢?还是说一部分存储在一个公共的区域中,而另一部分则分模块存储?
  • 程序的控制流应该是怎样的呢?程序是不是可以在任意的代码行之间进行跳转呢(goto)?

随着软件规模的不断提升,业务的不断复杂,我们所面临的编码问题和编码场景会越来越多,为了保证代码质量,各种代码组织方式和约束就应运而生,我们称之为编程范式。

代码的编写是讲究方法的,而’编程范式’就是软件设计中的方法学,不同的范式提倡不同的代码组织方式,编程范式本质是从某方面对程序员编程能力的限制和规范,很多编程范式已经被熟知禁止使用哪些技术,同时允许使用哪些。例如,结构化编程不允许使用goto;纯粹的函数式编程不允许有副作用。

同时编程范型决定了程序员对程序执行的看法。例如,在面向对象编程中,程序员认为程序是一系列相互作用的对象,而在函数式编程中一个程序会被看作是一个无状态的函数计算的序列。

软件工程发展至今,编程范式也已经非常丰富了,下图是我从维基百科上找到的一张编程范式的全家福[^2]:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oZaavwPp-1671557293814)(null)]

最主要的几种编程范式

上面图片里的编程范式,越靠左边的越偏向声明式,越靠右的越偏向于命令式。看到上面的图片你可能会有一种脑袋发蒙的感觉,竟然有这么多种编程范式吗?有的范式别说是见了,听都没听过。不要惊慌,其实在现存的所有的编程范式中,最为通用也是最为流行的其实就三种,它们分别为:面向过程编程、面向对象编程、函数式编程,此外,还有一个最为基础的范式——结构化编程

其他的编程范式,要么是在上面的几种范式的基础之上构建出来的抽象层次更高的编程方法,或者是针对特定场景而出现的,其实已经不算是严格意义上的编程范式了,可以称之为模式了。

范式和模式的区别:范式是边界,它代表了程序员对程序的认知。而模式是套招,一般是基于范式之上,针对特定问题的。比如面向对象中的GoF23,也就是我们最常提的设计模式,它们就是在面向对象编程范式之上,针对某些问题的常用编码方式。

其实,编程范式之间并不是完全孤立的,不同的范式之间可能会有相互依赖的关系,也可能会有重合的部分。下图是我从维基百科上找到的几种最主要的编程范式之间的对比。

image-20221129010210870

从上面的表中可以看到,我们上面所提到的结构化编程、面向过程编程、面向对象编程和函数式编程均在此列,接下来我们就来分别介绍一下这四种范式。

结构化编程

很多人和文献都把结构化编程和面向过程编程划分成了同一个编程范式,这是因为面向过程编程是基于结构化编程的,这个其实从上面的主要编程范式对比表中所提到的面向过程编程的主要特征:sequence、selection和iteration也可以看出,因为这三者就是我们将要介绍的结构化编程的主要特征,把结构化编程等同于面向过程编程好像也无可厚非。

那这里我为什么要把结构化编程单独拿出来说呢?**这是因为结构化编程太基本和底层了,它基本到并不会影响程序员对什么是程序的认知,它核心的思想是指导程序控制流的编写方式,而除了纯粹的声明式语言(比如SQL、HTML等)之外,无论是哪种编程范式,都或多或少涉及到程序控制流的编写,所以我个人认为,结构化编程是最为基础的编程范式、没有之一。**况且维基百科中给出的主要编程范式对比图中也把结构化编程独立出来了不是吗。

相对于结构化编程,什么是非结构化编程呢?非结构化编程的特点是在程序的控制流中提供了无条件跳转的能力。我们这里暂且不提机器语言和汇编语言,相信同学们都或多或少的接触过C语言,那相信大家对goto也是有所耳闻的。C语言中的goto允许后接一个标号,代码执行到goto的时候,会无条件跳转到标号指示的代码行继续执行。C语言中goto的主要使用场景有在多层嵌套循环中提前跳出外层循环等,如下代码:

int main(int argc, char *argv[]) {
  for (int i = 0; i < 2; ++i) {
    for (int j = 0; j < 5; ++j) {
      printf("i=%d, j=%d\n", i, j);
      if (j == 3) {
        goto end; // 提前跳出外层循环
      }
    }
  }
  end: return 0;
}

这样看来,goto的存在是有必要的。但是,问题出现在C语言中对goto的使用是不加限制的,比如我们可以用goto来模拟一个循环:

int main(int argc, char *argv[]) {
  int i = 0;
  loop:
  {
    printf("i=%d\n", i);
    i++;
    if (i < 5) {
      goto loop;
    }
  }
}

当代码比较简单和短小的时候,goto的使用可能还在我们的控制之内,甚至我们还会觉得使用它让代码变得非常优雅灵活。但是当代码变得复杂的时候,很容易造成goto的滥用,造成代码的控制流让人难以捉摸,写出所谓的面条式代码,导致程序出现莫名其妙的bug。

类似于C语言中goto这种无条件跳转指令的滥用其实就是非结构化编程的显著特征,非结构化程序设计被批评最严重的方面就是会产生很难读懂的面条式代码,在创建大型工程方面有时会被认为是很差的,不过,因为赋予程序设计者很大的自由,被人称赞为如同莫扎特在谱曲。

但是,其实为程序开发者提供goto这种无条件跳转的能力是没有太大的必要性的。科拉多·伯姆和朱塞佩·贾可皮尼于1966年5月在《Communications of the ACM》期刊发表论文,说明任何一个有goto指令的程序,可以改为完全不使用goto指令的程序,后来艾兹赫尔·戴克斯特拉(Dijkstra,你一定听说过Dijkstra算法)在1968年也提出著名的论文[《GOTO有害论》(Go To Statement Considered Harmful)](Dijkstra68.pdf (cwi.nl))。

1996年,计算机科学家Bohrm和Jacopini证明了:任何简单或复杂的算法,都可以由顺序结构、选择结构和循环结构这三种基本控制结构组合而成。所以这三种结构就被称为程序设计的三种基本控制结构

到这里,我们可以说一下什么是结构化编程了:所谓的结构化是指,程序的控制流只能由顺序结构、分支结构、循环结构以及子过程调用组成,除了这四种(这里我们暂时不考虑某些语言中提供的错误处理)控制结构之外,没有其他的任何手段可以转移程序的控制流。

**限制使用或者不使用goto是结构化编程最显著的特征。**比如Java就摒弃了goto关键字(但是Java中仍然把goto作为了一个保留字),而对于上文中我们介绍的C语言中嵌套循环中跳出外层循环的goto使用场景,Java则采用了增强break语句的方式来进行实现,如下代码:

public static void main(String[] args) {
  outLoop:
  for (int i = 0; i < 2; ++i) {
    for (int j = 0; j < 5; ++j) {
      System.out.println("i=" + i + ", j=" + j);
      if (j == 3) {
        break outLoop; // 提前跳出外层循环
      }
    }
  }
}

也就是说,Java中的‘goto’的使用只限制在了必要的场景下。

介绍完结构化编程之后我们需要达成一个共识:我们编写任何算法,不论是面向过程编程中的过程、面向对象编程中对象的方法亦或者是函数式编程中函数的编写,其控制流的编写都是基于结构化编程的约束的。

面向过程编程

面向过程是一种以过程为中心的编程思想,面向过程编程主张按功能来分析系统需求,其主要原则可以概括为自顶向下、逐步求精、模块化等,因此,这种方法也被称为面向功能的程序设计方法。

简单地说,在面向过程的编程范式中,程序员需要把问题分解成一个一个的步骤,每个步骤用过程实现。面向过程编程是用计算机的思维方式去处理问题,将数据结构和算法分离,它是“程序=数据结构+算法”这个公式最直接的体现。数据结构描述待处理数据的组织形式,而算法描述具体的操作过程。我们用过程函数把这些算法一步一步的实现,使用的时候一个一个的依次调用就可以了。

面向过程编程中最小的程序单元是函数,每个函数都负责完成一个功能。整个软件系统由一个个函数组成,其中作为程序入口的函数被称为主函数,主函数依次调用其他的普通函数,普通函数之间依次调用,从而完成整个软件系统的功能。下图显示了结构化程序的逻辑示意图:

image-20200906185423414

每个函数都是具有输入、输出的子系统,函数的输入数据包括函数形参、全局变量和常量等;函数的输出数据包括函数的返回值、传出参数以及有可能在全局变量上发生的副作用等等。

可以看出,面向过程编程更加注重的是处理问题的步骤,这种范式思考问题的方式更加接近计算机。

面向对象编程

面向对象编程(OOP)是一种基于“对象”概念的编程范式,对象可以包含数据和行为。数据采用字段的形式(通常称为属性),行为采用过程的形式(通常称为方法)。

其实面向对象的实现方式不止一种,最为成功和流传最广的流派应该是使用“类”的方式来描述对象,这方面的代表有C++、Java等流行的编程语言;还有一种比较冷门的流派,就是基于“原型”的方式来描述对象,这其中的代表就是JavaScript语言了。

但是不管以什么样的方式实现的面向对象,对象都具有以下三个最基本的特征:

  • 每个对象都是唯一的,这个唯一性大多体现在程序的内存地址上。
  • 对象具有状态
  • 对象具有行为

只有正确理解对象的特征,我们才能用面向对象的方式思考,用面向对象的方式设计。

面向对象编程的核心思想是,对象具有自己的属性和状态(对象的状态是在某个时刻对象所有属性的值的快照),同时对象具有自己的行为,对象的行为受到对象状态的影响。而对象的某些行为又会引发自身属性的变化从而引发自身状态的变化。

面向对象编程是一种更加符合人类思维的程序设计方式,面向对象程序设计可以看作一种在程序中包含各种独立而又互相调用的对象的思想,这与结构化编程的思想刚好相反:结构化编程的程序设计主张将程序看作一系列函数的集合,或者直接就是一系列对电脑下达的指令。面向对象程序设计中的每一个对象都应该能够接受数据、处理数据并将数据传达给其它对象,因此它们都可以被看作一个小型的“机器”,即对象。

我们上文在介绍面向对象编程的时候提到,每个函数都是具有输入、输出的子系统,函数的输入数据包括函数形参、全局变量和常量等;函数的输出数据包括函数的返回值、传出参数以及有可能在全局变量上发生的副作用等等。

而在面向对象编程中,我们应该尽量把对象的行为要操作的数据限制在入参以及对象的属性范围内,让程序的内聚性更高。而程序模块之间的数据耦合也从结构化编程中通过全局变量进行耦合转化为了对象之间的关系,这让我们的思考在一定程度上也变得‘模块化’了。下图展示了结构化编程和面向对象编程程序结构的不同:

image-20200304215926158

同时,基于类的面向对象中还有广为人知的抽象、封装、继承、多态这些特性,这里我们先不展开讲了,在后面会有专门的文章进行更加深入的讨论。

目前已经被证实的是,面向对象程序设计推广了程序的灵活性和可维护性,并且在大型项目设计中广为应用。此外,支持者声称面向对象程序设计要比以往的做法更加便于学习,因为它能够让人们更简单地设计并维护程序,使得程序更加便于分析、设计、理解。

而且在面向对象设计领域已经有了很多组织对象间关系的前人的经验——GoF23设计模式。而且有非常多的设计原则,这些都能指导我们如何进行良好的面向对象设计。我后面会有一个专辑专门介绍面向对象编程。

函数式编程

在计算机科学中,函数式编程是通过应用和组合函数来构造程序的编程范式。函数式编程其实是一个非常古老的概念,其起源于学术界,但是刚开始并不流行,随着响应式编程和并发编程的兴起,函数式发挥出越来越重要的作用。

在刚开始接触函数式编程的时候会被非常多的概念弄得眼花缭乱,什么纯函数、无状态、柯里化、偏应用等等一系列概念接踵而至;函数式编程在某些时候被认为是纯函数式编程,但是其实纯函数式编程只是函数编程的一个子集。这篇文章中我并不打算非常全面得介绍函数式编程,而只是说明一下本人对函数式编程的基本看法,后面会有专门的文章来对函数式编程进行更加深入的讨论。

一下几点是我认为函数式编程中最核心的几点:

  • 函数是一等公民,这意味着它们可以绑定到名称(包括局部标识符),作为参数传递,并从其他函数返回,就像任何其他数据类型一样;
  • lambda表达式,表达式最基本的特性就是能够产生值,而lambda表达式的值时一个函数,而函数又是一等公民,有了lambda表达式就意味着我们可以在语言的运行时“计算出”函数;
  • 高阶函数,所谓的高阶函数就是可以接收函数作为参数或者返回一个函数的函数,高阶函数就是函数式编程中函数之间组合使用的体现。

其实不难看出,函数式编程最基本的思想是把函数也当成一种数据来对待,我们可以在方法间传递函数。函数式编程使得行为可以在程序的运行时进行动态的组合,而不再仅仅限于在代码编写阶段的相互调用。

函数式编程和声明式编程

在计算机科学中,函数式编程是通过应用和组合函数来构造程序的编程范式。它是一种声明式编程范式,其中函数定义是将值映射到其他值得表达式树,而不是更新程序运行状态的命令式语句序列。这是维基百科中对函数式编程的完整定义,我在上文中只采用了上半句。

其实我个人认为说函数式编程是声明式编程并不完全正确,它不是类似于SQL的那种完全的声明式的编程语言,而是一种半声明式的编程范式(当然也有可能是因为我见识短浅,没有接触过纯粹的函数式编程语言)。

1979 年,英国逻辑学家和计算机科学家 Robert Kowalski 发表论文《Algorithm = Logic + Control》,这篇文章中提到:

任何算法都会有两个部分, 一个是 Logic 部分,这是用来解决实际问题的。另一个是 Control 部分,这是用来决定用什么策略来解决问题。Logic 部分是真正意义上的解决问题的算法,而 Control 部分只是影响解决这个问题的效率。程序运行的效率问题和程序的逻辑其实是没有关系的。我们认为,如果将 Logic 和 Control 部分有效地分开,那么代码就会变得更容易改进和维护。

注意,最后一句话是重点——如果将 Logic 和 Control 部分有效地分开,那么代码就会变得更容易改进和维护

到此为止,我们已经得到了两个等式:

  • Programs = Algorithms + Data Structures
  • Algorithm = Logic + Control

第一个等式阐述了程序的两个方面,而第二个等式则想表达的是数据结构不复杂,复杂的是算法,也就是我们的业务逻辑是复杂的。我们的算法由两个逻辑组成,一个是真正的业务逻辑,另外一种是控制逻辑。控制部分用来描述如何使用逻辑。最粗略的看法可以认为“控制”是解决问题的策略,而不会改变算法的意义,因为算法的意义是由逻辑决定的。

也就是说,我们的算法中包含两个部分,一个是逻辑部分,其代表的是我们要’做什么’,另一个是控制部分,代表的是‘怎么做’。而对我们的业务逻辑来说,更关注的往往是‘做什么’。如果我们在编程的时候,可以只考虑‘做什么’而忽略’怎么做’,那就再完美不过了。这就是声明式的编程。

利用函数式思维就可以构建出声明式编程的环境,比如Java8中新增的Stream类库,通过Stream类库,Java将集合的遍历操作从外部迭代转化成了内部迭代,而我们在使用的时候只需要关注迭代过程中要做什么即可,至于如何迭代,则不需要我们去关心。如下代码:

public static void main(String[] args) {
  List<ReportCard> reportCards = ...;
  int englishAverage = reportCards.stream().mapToInt(ReportCard::getEnglish).average();
}

即使是我什么上下文也不交代,对于第三行代码,大家也可以比较轻松地猜出是在求英语科目的平均成绩,上面的例程其实就非常接近完全的声明式编程了。

但是‘做什么’还是需要我们以命令式的方式去编写出来的,比如第三行中提供给高阶函数mapToInt的lambda表达式。但是这相较于面向过程编程和面向对象编程,显然也是更加声明式的。

总结

这篇文章中,我们介绍了四种最基本的编程范式——结构化编程、面向过程编程、面向对象编程和函数式编程,这几种范式并不只是范式,更是思维方式和设计哲学,这是基本的程序上下文的组织方式。只有理解了这些基本范式,我们才能理解更高级的范式、才能驾驭更加复杂的程序上下文的组织方式。

同时,编程范式也是不同语言社区中开发者的通用语言,可以方便程序员之间的交流。在理解了编程范式之后,我们就可以愉快的编写‘人能读懂’的代码了。

但是,我们的程序最终是要在机器上运行的,那么机器是如何‘读懂’我们的代码的呢?下一篇文章,我将简述一下机器’读懂’我们的代码的过程——高级语言编译过程。

感谢你耐心读完。本人深知技术水平和表达能力有限,如果文中有什么地方不合理或者你有其他不同的思考和看法,欢迎随时和我进行讨论(laomst@163.com)。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

劳码识途

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值