面向 (XX) 编程
编程是为了解决现实中的问题,利用计算机来模拟现实世界,然而现实世界是复杂的,所以编程所要面对的问题也是复杂的。不过好在人类善于总结、归纳,我们总能想到面对复杂问题的解决之道,比如数学物理中的公式,设计模式,各种规律等等。
总之,做的一切都是让复杂的事情,变得简单一些!
内容框架
面向XX编程,这里的”XX”可以被很多概念替代,比如“过程”,“对象”,“切面”…….这些概念相信程序员都不陌生,然后我们的思路却可以更开放点,你可以面向“人民币”编程,面向“框架”编程,面向“CRUD”编程……都可以。
面向,是英文Oriented翻译过来,确切来讲是“以…为导向”。如果是面向对象编程,可以理解为以“对象”这种方向目标来进行编码,解决现实问题。因此,面向的是什么,即脚下的“路”通向哪里,决定了最终的结果。
本文以各种编程范式为路径,来总结揭示出编程中的思想。
编程范式
范式,可以理解为模式。它是前人总结的思想,每种思想都能很好地适应和解决一些特定问题,但是没有一种模式(范式)能解决所有的问题,这也是我们常听到的软件行业中的一句话—“没有银弹”。
命令式(过程式)
目前的计算机几乎都是冯•诺伊曼机,有输入输出设备,控制单元,逻辑运算单元和存储单元。命令式编程就是这种结构的映射,中央

处理器从内存中取出指令和数据,一条一条依次执行。
大家可以类比上汇编语言这种低级语言,它直接操作计算机的寄存器,内存和指令。但这里并不是说,符合命令式的语言就是低级语言,大部分的语言,包括高级语言都是符合命令式的思想。不管是什么高级语言,最终它都要被翻译成机器能理解的指令集来执行。
下面就是汇编的一个例子,将两个数字相加,然后写回到寄存器ecx:
mov eax, 5 mov ebx, 10 add eax, ebx mov ecx, eax
上面的说明,我们就可以很容易地理解命令式范式,通过一系列的指令,将数据赋值给变量,中间可能还会加上”循环“,”条件判断“等分支来执行程序。
因此,我们回头看看自己曾经写过的代码,是不是都很符合这种”思想”,一步一步的求解,直至问题解决。大多数的语言都符合这种特性,因为它是最直观,也是最显而易见的,也可以这样理解,命令式面向的是指令(命令)。
函数式
相对于显而易见的命令式,函数式就不是那么直观,甚至有些不太容易被接受。下面给出一个求阶乘的例子,首先是命令式的方式:
int factorial(int n) { int result = 1; while (n > 1) { result *= n; n--; } return result; }
上面利用循环迭代,来相乘计算阶乘结果。紧接着,再用函数式的方式:
int factorial(int n) { return (n <= 1) ? 1 : n * factorial(n - 1); }
明显函数式更加简洁,相信大家也一眼看出了”递归“用法。没错,递归就是函数式的一个”典型“使用。它直接使用了阶乘函数的定义来求解问题 f(x) = f(x-1) * x。我记得在刚开始接触算法,甚至刷算法题的时候,很多时候都是很难想到递归的实现,因为它并不是那么直观。
函数式的核心是函数,即面向函数编程。函数是作为被处理的对象,而不是命令式中的指令。特别是在数学计算,人工智能领域,函数式编程的应用都非常广泛。毕竟,函数本身就是数学中的概念,验证和处理数学领域(比如数学建模,科学计算)问题,并不需要编程语言使用那么多代码步骤,相反越简单越好。
下面是纯函数语言Haskell的求阶乘实现:
factorial :: Integer -> Integer factorial 0 = 1 factorial n = n * factorial (n - 1) main :: IO () main = do let result = factorial 5 putStrLn $ "Factorial of 5 is: " ++ show result
上面例子不难看出,haskell纯纯的就是在做数学翻译。我们再看个更直观的集合的例子:
数学表达式: {x| x ∈ rest, x < pivot} Haskell表达式 : [x| x <- rest, x < pivot]
函数式有不少好处,将程序运行状态的变换限制在函数内,那么给定一个输入,必定有一个固定输出。因此这种对外部而言的无状态化,具有很好的可确定性。对于程序员来说,调试、重构和单元测试都变得很容易。甚至《代码整洁之道》中强调,函数要短小只做一件事。
语言层面来看,Java8以后,开始引入Function概念,FunctionalInterface注解被用来标记一个接口最为函数式接口,函数作为参数被传入进行处理,结合Streams写法,让代码变的简洁易理解。
numbers.stream().map(function).collect(Collectors.toList());
还有像Javascritpt中的柯里化,闭包都是函数式的体现。Java中类似闭包比如定义内部类时,传入变量加final修饰。
泛型编程
有一个程序公式:程序 = 算法 + 数据结构,相信我们都不陌生。数据结构像菜谱,算法像厨师的手艺,相同的食材,在不同的厨师手中,味道可能截然不同。如果一个顶级的厨师,我们想要利用他做出更多好吃的菜,那我们就要提供不同的食材给他处理,而不是请很多一般的厨师。
在编程世界也一样,我们希望算法和数据结构解绑,一个算法可以处理多种数据结构,而不是为多种数据结构编写相同的算法,就仅仅是因为算法处理的数据类型不同,而且对于很多静态语言也不支持不同数据类型的重载。如果简单地把函数就当作算法来讲,一个函数如果能服务多种数据类型,简直不要太爽,这样就省了很多代码,毕竟程序员的时间很宝贵,因此我们引入泛型。
泛型的核心是算法,面向算法编程。它表达的是一种将”实现“和”结构“分离的思想。
Java5开始,引入了泛型。Java中方法的泛型会在编译时擦除,并生成泛型参数的上界,如果是接口或者类的话,便会生成桥接方法。
public static <T> void iterateArray(T[] arr) { for (T element : arr) { System.out.println(element); } }
上面是Java泛型的例子,Javascript就简单一些,不过也跟它是弱类型语言有关系:
// arr可以是任何类型的数组 function iterateArray(arr) { for (let i = 0; i < arr.length; i++) { console.log(arr[i]); } }
不管语言实现怎么写,其实泛型都是数据类型在内存中的不同进行了屏蔽。
对象式
面向对象太有名了,程序员肯定都接触过。三大特征:封装,继承,多态。
它将数据和行为都封装到一个叫”对象“的结构中,对象是程序的基本单元,也是此范式的核心。
有了这个封装了数据和行为的对象,代码就可以具有一定的灵活性,重用性,因此继承就有了很大的威力,同时也让多态有了发挥空间。
这也是为什么大型应用普遍采用”面向对象”,程序的灵活性和扩展性往往在这类项目中非常重要。
同时,面向对象非常符合我们对世界的认知,一一映射,这也让程序员能够很好的上手理解。
由于面向对象太过普遍和流行,在这基础上,”4人帮“总结了23种设计模式,来应对面向对象开发过程中的一些问题处理模式。
同时又有SOLID设计原则,供我们进行参考:
- 单一职责(SRP)。其中的核心是界定职责。
- 开闭原则(OCP)。对扩展开放,对修改关闭。
- 里氏替换原则(LSP)。简单点说,子类可以替换父类,子类继承了父类所有的行为方法。这样很好屏蔽子类实现。
- 接口隔离原则(ISP)。由于子模块之间必要情况下需要轻度耦合,因此我们需要用接口来隔离这种耦合。
- 依赖反转原则(DIP)。高层模块不能依赖低层模块,而应该依赖抽象层。
不管是上述的设计模式还是设计原则,其中的基本思想就是要开发出高内聚,低耦合的系统。程序员在写代码时,要尽量面向接口来编码,而不是具体的实现。
然而,高内聚低耦合是一个天平的两端。一个系统内的各个子系统必须协调工作才行,所以必须是有一定的耦合,我们要做的就是平衡两者之间的关系。高内聚低耦合是我们设计系统的目标,而不是完全消灭耦合,一味提高内聚性。
遵守设计模式,设计原则,都是为了让我们开发出扩展性高的系统。只有这样,才能将复杂的问题瓦解到一个可控的范围。因此,面向对象是一种处理复杂问题或系统的解决之道!
市面上讲解面向对象的书籍实在是多如牛毛,我觉得这也是程序员必须掌握的一种模式。
切面式
切面编程,即AOP(Aspect-Oriented Program)。看待事物需要从多种角度和方面,同理编程而言也要从多个角度来看,AOP就是以”横切面“的角度。
拿OOP面向对象来说,A继承B,B继承C,C继承D……这些都是竖向角度,继承让子类,子类的子类,子类的子类的子类……都有了父类的行为,这就达到的代码的复用能力。但是,对于横向角度而言,好像就没办法实现复用了,因此就有了切面式。
下面我们举个例子:
一个系统中,我们需要监控一些关键方法的性能(比如IO,网络等等),但这些方法分布在各个地方。接下来,我们如果用继承,也许能解决问题,比如:
class A { public void IO(){ long x = System.currentTimestamp(); ...... // 业务逻辑 ...... long cost = System.currentTimestamp() - x; // 计算出方法的耗时。 } }
这样我们能够让需要IO的地方都继承A,达到关键方法监控的问题。
这样有一些问题,首先非常强制性的规定了所有的类必须继承A;其次,如果我想要跨子系统和模块间可能就没办法实现。如果是小型系统,这样做未尝不可。
如果是切面方式,我们的做法就方便很多,它允许我们定义切面(Aspect)和连接点(Join Point),动态的在系统中找到每个符合条件的注入点,注入我们自定义的Advice(流程),也就是我们自定义的代码。通过这种方式,我们对原来代码也没有入侵性。下面是代码例子:
@Aspect public class IOMethodExecutionTimeAspect { private final ThreadLocal<Long> startTime = new ThreadLocal<>(); @Before("execution(* *(..)) && execution(* *IO())") public void beforeIOMethodExecution() { startTime.set(System.currentTimeMillis()); } @After("execution(* *(..)) && execution(* *IO())") public void afterIOMethodExecution() { long endTime = System.currentTimeMillis(); long executionTime = endTime - startTime.get(); System.out.println("IO Method executed in " + executionTime + " milliseconds"); } }
首先,我们定义了切面,即@Aspect注解的类IOMethodExecutionTimeAspect。我们定义的切入点就是以IO结尾的所有方法,注入在方法的Before和After注入我们自定义的两种流程,这样便可以实现对所有IO方法的耗时监控。
Java中的AOP框架AspectJ本质是基于动态代理的。首先匹配到合适的切入点对象,通过代理创建对象,并根据Advice类型织入到现有的代码逻辑中。
通过AOP编程,我们分离了非业务逻辑和业务逻辑,很好做到了解偶。
并发式
并发式编程,也是程序员耳熟能详的了。随着机器硬件性能提升,用户体验的需求提升,利用好并发编程能让我们满足用户的体验。毕竟没人愿意只能先等着下载完电影,才能开始浏览网页,我们需要的能够”同时进行“,虽然这种”同时“可能是被模拟出来的。
说到并发,大多数程序员都能说出锁,线程,异步等等。但其实从并发范式出发的,其实并不止与此,在系统设计中,大到系统架构、任务或者流程,小到算法,流程控制,都可能是并发。
并发模式是以任务(进程)为导向的,核心是让更高效提高程序性能。
大多数高级语言都支持并发编程,Java21版本更是引入了虚拟线程,从此我们可以开开心心创建成千上万个线程,也不必担心线程管理的问题。其实它就是在虚拟机层面实现了线程的任务,而不是像以前一样和操作系统的线程一一映射的关系。
final class VirtualThread extends BaseVirtualThread { private static final Unsafe U = Unsafe.getUnsafe(); private static final ContinuationScope VTHREAD_SCOPE = new ContinuationScope("VirtualThreads"); private static final ForkJoinPool DEFAULT_SCHEDULER = createDefaultScheduler(); private static final ScheduledExecutorService UNPARKER = createDelayedTaskScheduler(); private static final int TRACE_PINNING_MODE = tracePinningMode(); private static final long STATE = U.objectFieldOffset(VirtualThread.class, "state"); private static final long PARK_PERMIT = U.objectFieldOffset(VirtualThread.class, "parkPermit"); private static final long CARRIER_THREAD = U.objectFieldOffset(VirtualThread.class, "carrierThread"); private static final long TERMINATION = U.objectFieldOffset(VirtualThread.class, "termination"); ...... }
另外还有一款语言Erlang,它是专门为并发设计的编程语言。
事件式
我们编程理念中,往往是调用者和被调用者的关系。有些场景下,这个关系可能会被反转,被调用者去通知调用者。也就是说,只有调用者关心的事件发生之后,才会触发通知。
比如用户在界面上点击了某个按钮,或者输入了某些字符,此时才触发事件,通知下游处理事情。否则,按照传统的编程思维,下游需要不断的轮询是否有相关的事件发生。
上述的例子都是基于事件驱动的,这种模式在计算机中应用的非常广泛,比如Linux的I/O多路复用,基于Epoll来监听多个文件描述符,只有关心的事件才会被触发到应用程序。
事件驱动范式的核心是事件,利用事件来对系统进行解偶,调用者和被调用者并不强关联。
页面交互回调,Javascript中会设置callback,甚至有臭名昭著的回调地狱;还有Webhook的注册回调机制,都是典型的事件驱动模型。
我相信大家应该都在代码中或多或少看到过xxxListener之类的代码,这些很多都包含了事件驱动的模式内涵。
总结
每种范式都有自己的优点和应用场景,各家语言都或多或少满足上面的多种范式。对于编程语言而言,它们也是在不断的进化之中,吸取各家之长来丰富自己。同时他们也有有各自的缺点:
- 命令式的代码很难维护,因为数据和逻辑是揉杂在一起,谁也不想看到一个1000行的方法。
- 函数式的语言表现力会有欠缺。
- 泛型会大大影响代码的可读性,如果过度使用可能代码会膨胀比较厉害。
- 对象编程虽好,但是运行效率肯定不如命令式,代码简洁度不如函数式。
- 切面式会给代码的调试增加难度,同时性能上会有些损失。
- 并发式增加代码复杂度,并且可能会有不可预知的问题,相信有这块经验的同学会深有感触。
- 事件驱动方式的代码就更加复杂,异步的事件驱动跟踪调试也是个问题。
我们清楚看到,每种都有自己的优势和劣势。
这些范式思想就像是武功的内功心法,各家思想都能在不同的层面上得到借鉴,比如如果将一些模式应用到架构中,就是架构模式,应用到编码就是编程范式,应用到模块间,可能就是设计模式。
了解编程范式,是为了了解其中的设计思想,而不是单纯的来和编程语言一一映射,更不是讨论编程语言的优劣之争。将其中的思想内化到自己未来的技术实现上才是主要目的。