点击上方“程序人生”,选择“置顶公众号”
第一时间关注程序猿(媛)身边的故事
作者丨老曹
来源丨喔家ArchiSelf
我是——编程世界的函数,不是数学中的幂,指,对和三角函数等等,但是和f(x)又有着千丝万缕的关系。
我是代码中的最小执行组织,但不是最小执行单元。最小的执行单元是一条条语句,这些语句有机地组合起来完成一个或多个功能并且可以复用,这才是我——函数。
内存与堆栈和我之间是啥关系?
有无参数的我有何异同?
我的简洁性?复杂度如何评估?
我的高阶与递归有啥区别?
我的回调和匿名是一回事么?
对象中的方法是我么?
控制对象的行为方式有哪些呢?
为什么说类型错误只是异常处理的一种方式?
面对数据密集型应用和并发场景,我有何作用?
......
且听一个函数的自白,从函数的角度看编程的方式。
我眼中的大环境——内存
空山不见人 但闻人语响
代码最终都要加载到内存中执行,组成函数的代码同样如此。内存容量是受限的,需要考虑一个函数在内存中所需要处理和生成的数据量。内存中没有变量名或签名的内存地址,只有以数字表示的内存地址。考虑“什么将存入内存”,“什么存入硬盘”以及“何时将内存内容存入硬盘”,这些考量都与性能优化息息相关。
而现代编程语言强调透明的内存管理,关注处理中不断增长的数据规模,这很容易失去对内存消耗的控制,从而导致运行时性能的下降。对我所采用的不同内存使用策略,所带来的不同结果给予适当的关注,是件有意义的好事情。
我的运行环境——堆栈
明月松间照 清泉石上流
由于内存中的东西太多了,于是把我运行环境中的内存称为堆栈。对于栈,所有操作都针对栈中的数据,堆用于存储后续操作所需的数据。堆中数据需要入栈进行操作,最终出栈回到堆。栈溢出是内存耗尽的情况。
通常对程序员来说,栈是不可见的,几乎在每个现代的编程语言中,栈都是支持线程执行的内存区域。 堆是许多现代编程语言实现中的另一重要内存区域,用于动态内存的分配和释放,例如创建列表和对象。不要将这里的堆栈与数据结构中的概念混淆,数据结构中的堆是一个基于树的数据结构。
有一种执行环境叫栈机器,使用了栈而不是寄存器来支持程序表达式的计算,许多现代虚拟机都是这样的,例如JVM。
我源自面向过程的抽象——过程函数
大事化小 小事易了
程序员们通常会把复杂问题分解为若干个较小的容易问题,这是一种面向过程的抽象,一个过程是实现某一功能的我,我可能接收输入,但未必产生输出。极端一点的我既没有输入参数,也没有返回值,是纯粹的过程函数。每个过程函数独立做一件事,完成一项任务,过程函数一般不返回数据,只操作共享数据。人们把这种编程方式叫做结构化编程。
作为过程函数的我一般用全局变量来共享状态,我会改变或增加共享状态。过程函数可能不是幂等的,而缺乏幂等性被很多人认为是编程错误的一个来源。
幂等性: 若一个函数或过程是幂等的,对其进行多次调用将观察到同样的效果,
与一次调用的效果是相同的。
而且,采用全局变量也一直被认为是一个馊主意,然而在系统层面,架构中的组件共享其实和全局变量类似,这让我有时候感到无语。
我和伙伴们组合起来——复合嵌套
但远山长 云山乱 晓山青
当程序员把复杂问题分解成若干小问题的时候,一般都是把我接收的输入形成输出,这样就可以把所有任务都视为输入集合到输出集合的映射关系了。嗯,f(x)的味道来了。
这时候,函数间无状态共享,类似于数学中的复合函数。特殊的,把函数作为输入或者输出的函数被称为高阶函数。多个参数的函数可以通过柯里化技术转化为一系列的单参数高阶函数。
这种串行话处理的一个知名应用就是Unix Shell 中的管道,编译器及其他语言处理器也很适合这种方式,且倾向于利用图和树等数据结构组合过程。类似的,还有单元测试和开发部署。
构成我的代码简洁性
天街小雨润如酥 草色遥看近却无
程序员通常把简洁作为编程技艺的标志之一,但是当代码简洁成为唯一目标的时候,每行代码又往往会变得冗长且难以理解,这是因为需要利用编程语言的许多高级特性和库来实现。无论好坏,源代码行数(SLOC)还是可以用来估算成本和开发效率的,也可以评估可维护性和其他许多管理指标。例如,CoCoMO就是基于SLOC的成本评估模型,至今还被采用。
基于代码行数的简洁性往往取决于已经构建好的第三方库和方法,当然,运用得当,简洁性也会带来优雅且可读性强的代码。一般地,可以信任核心库,但对第三方库的使用需谨慎。
面向简洁性,融合高阶函数,以至于一切皆为函数, 甚至形成了新的编程模式——函数式编程。纯粹的函数式编程语言,以Haskell 为代表。
我的复杂性度量
吾尝观窍妙 渠敢讥杂驳
我的复杂程度是由程序员决定的,可以非常简单,也可以非常复杂。一般地,可以采用一种叫圈复杂度的方式来评估我的复杂程度。圈复杂度是一个用于衡量代码复杂度的方式,主要是通过描述控制流路径的数量来表示复杂度。圈复杂度把程序看成一个有向图,计算公式如下:
CC = E -N +2P E是边数 N 是节点数,P是节点出口数。
圈复杂度可以衡量程序的复杂性,同样适用于函数。
我调用我自己——递归函数
知人者智 知己者明
由于很多的问题都可以使用归纳法进行建模,就象学生时代的数学归纳法那样,即已知n=0的情况和n到n+1的推导规则,对问题进行求解。一般地,在编程世界中,归纳法用递归函数表示。递归函数就是自己调用自己,一直在栈中操作,如果递归层次过深的话,会导致栈溢出问题的出现。
在许多编程语言中,尾递归优化解决了递归调用中的栈溢出问题。 尾调用是指一个函数里的最后一个动作是一个函数调用,即在函数尾部发生的递归调用。尾递归即在函数尾部发生的递归调用,尾递归发生时,程序语言的处理器可以安全地删除先前的栈记录,因为该调用返回时栈中不需要继续其他操作,这就是尾递归优化,尾递归优化有效地将递归函数转为迭代,节省了时间和内存。
需要注意的是,python中并不对尾递归进行优化,一般要对调用深度进行限制。
下一个是我的自动调用——回调和匿名
忽如一夜春风来,千树万树梨花开。
一般地,函数间的调用是显式的,即一个函数执行完毕在执行下一个函数。 但有这样一种使用场景,一个函数有一个额外的参数,通常是最后一个,这一参数是另一个函数,在函数执行到末尾的时候,作为参数的函数也会被调用。
参数函数作为输入,也作为当前函数的输出,函数间的这种管道式传递可以解决处理规模较大的问题,即管道中下一个被调用的函数会当作当前参数的输出。 这种后续传递,通常使用匿名函数。
如果参数函数并不是在末尾被调用,而是在特定的事件或条件发生时由另外的一方调用,参数函数用于对该事件或条件进行响应,通常使用回调函数。在C/C++中,回调函数就是一个通过函数指针调用的函数,把函数的指针(地址)作为参数传递给另一个函数,用这个指针来调用其所指向的函数。回调函数一般使用通知机制。典型的场景如编译器优化,处理程序的正常流程和异常流程,解决单线程语言的IO阻塞问题等等。
需要注意的是,大量的回调函数可能会增加复杂性,使代码的可读性变差,例如JavaScript 中的回调地狱。
我们长在对象上就成了——方法
千人同心 则得千人之力
我们都是要处理数据的,可以把数据封装起来形成一个可以修改的数据抽象。 将若干函数逐个绑定在数据抽象上,建立函数的调用顺序,查看数据的最终结果,这是一种面向数据的过程封装抽象,特点在于绑定操作将数据抽象作为参数,调用指定函数,并将函数返回值赋回。
如果将问题分解成某些问题领域的相关对象,每个对象都是一个数据封装,处理过程暴露在外,数据只能通过这些过程访问,不可直接访问,每个对象可以重新定义在其他对象中已经定义好的过程。这种长在对象上的函数又叫——方法。
对象的本质是过程分享对象內的数据,与类和继承联系在一起。继承实际上定义了对象间的从属关系,从而有了抽象类,基类,实例,覆盖,子类,单例等等。
对象中我们的远程调用
万里云霄送君去 不妨风雨破吾庐
随着网络应用的发展,网络中某个节点的软件希望引用其他远程节点的对象实例,并且把远程对象的方法当作本地方法来使用。于是,诞生了分布式对象系统的平台和框架,例如CORBA 和RMI。这些分布式对象系统有一个前提假设,就是需要为所有的分布式组件采取通用的编程语言或基础架构,但通用基础架构的假设是难以成立的。CORBA的统一化方式退出了舞台,它和web技术的大规模应用相冲突,因为后者基于的是不同技术的大规模系统设计方法。
但是对象方法的远程调用还是有使用场景的,如果每个对象是仅公开一个过程的数据封装,即能够接收和发送消息。消息分发机制能将消息发送至另一个封装。 对象向外界公开一个函数————接收消息的函数而并非一系列函数,其他的数据和函数被隐藏在内部, 接口函数处理能够被对象解释的消息;一些无法被对象解释的消息,则被忽略或生成某种形式的错误;另一些消息可能并不由该对象直接处理,而是由其他与接收对象相关的对象处理。
对于消息分发机制,带来的是某个对象使用其他对象的方法执行过程的能力。消息分发是接收消息、解释消息并确定执行步骤的过程。该过程可能是方法执行、错误返回或者向其他对象转发消息。
对象的域——我们与对象中数据的关系
悠然一曲泉明调 浅立闲愁轻闭门
对象的域一般是指键与简单值的映射,对象中的一些方法成为了键与值之间的函数映射,构造函数是最先被调用的方法。闭域是指每个对象是一个键值映射,其中某些值是我们这些函数。对象的方法引用对象自身的键,使得映射是封闭域的。
闭域解释了对象编程中的一个特色——原型。原型常见于无类面向对象语言中的对象。原型带有自己的数据和函数,可以自由地改变而不影响其他对象,新原型可以通过复制已存在的原型获得。
闭域风格的缺点在于没有访问控制,只能由程序员来约束,通过键来检索字典等同于向字典发消息。
对象的抽象——抽象对象
草枯鹰眼疾 雪尽马蹄轻
抽象对象是将大问题分解为问题域相关的对象抽象。抽象对象定义了对象的抽象行为,具体对象以某种方式与抽象对象绑定,绑定机制可以不定,应用程序的其他部分并不依赖对象的内容,而依赖对象的行为。
在设计模式中,适配器模式的目的与抽象对象是一致的,隔离了应用程序与具体的功能实现。抽象对象在大型系统设计中举足轻重,抽象对象的实现根据涉及的具体编程语言而定。Java中的抽象对象是接口,可以在类型上参数化;Haskell是一种强类型的纯函数语言,抽象对象表现为类型类;C++拥有抽象类,连同模版一起完备地提供了参数化抽象对象的概念。
控制对象中我们的另一方式——控制反转
春潮带雨晚来急 野渡无人舟自横
还有另一种对象行为的控制方式,利用对象和模块等不同形式的抽象,将大问题分解成若干个实体,这些实体不能被直接调用,而是为其他实体提供接口,使其他实体能够注册回调函数,这些实体中的函数调用是通过其他实体注册过的回调函数来完成的。
这种行为控制方式不会在程序中显式地调用函数,而是通过反转关系,使调用者可以同时触发多个行为,是一种能够在框架中触发任意应用代码的机制,这就是控制反转。
控制反转是分布式系统设计的一个重要概念,源于异步硬件中断,回调函数可以同步执行也可以异步执行。在事件发生时,不同网络节点间的回调函数不用长轮询,从而,事件驱动框架应运而生。
类似的,可以使用一个用于发布和订阅事件的基础结构,对象实体中的我们负责订阅和发布事件,基础结构负责事件的管理和分发。 这样的基础结构常与异步组件共同使用,也可能包含更复杂的事件结构,支持更精确的事件过滤,相当于控制反转的轻量级形式。
审视自身,进而改变我自己
见贤思齐焉 见不贤而内自省也
在程序设计的过程中可以将程序自身也一起考虑。有一种抽象的方式是可以获取自身以及其他的抽象信息,但不能改变这些信息。程序获取自身信息的能力叫做自省,支持自省的语言有java,python,javascript,以及PHP等,而C/C++不支持自省。其中,python 有强大的自省能力,如callable,inspect等。 然而,使用自省使得程序变得不直观,甚至难以理解,有利有弊吧。
反射使用了自省,程序在运行时可以通过增加抽象、变量等方式进行自我修改。反射的目的是要求程序能自我修改,ruby支持完备反射,python 和javascript在限制条件下支持反射,而java只支持小部分的反射。在设计过程中,当无法预期代码被修改方式的时候,会使用反射。
如果将问题的切面增加到主程序中,但不改变这种抽象方式的源码和使用该抽象的代码段,再通过一个外部绑定机制将这种抽象形式和切面绑定在一起,这就是AOP。AOP是一种受限制的反射,目的是在已有程序的指定代码前后插入任意代码。 切面主要关注可能被分散到应用程序中的代码,这些代码通常会影响许多组件,典型的切面如 profiling 和 tracing。
把我们有组织的固定下来充分复用——插件
但要前尘减 无妨外相同
如果把我们有组织的固定下来,所有或部分被预编译后通常会自成一体,主程序和每个包单独编译,主程序在开始时动态地加载这些包,使用动态加载包中的函数和对象,无需知道具体事项,这就是插件。
插件又叫plugin或者addon,不需重新编译,就可以将一系列功能加入到正在执行的应用程序中。通过一个外部定义来说明哪些包需要被加载,通常是配置文件,路径约定,用户输入或其他运行时加载外部代码的机制。现代操作系统的动态链接库dll/so 就是插件风格,需要注意的是存在配置深渊。
对于分布式体系结构和支持第三方扩展的独立应用程序,带有反射能力的编程语言使得在运行时链接组件变得可行并且非常简单。Java Spring框架就支持由反射机制带来的插件化开发,称为“依赖注入”和“插件”,插件一般使用描述性配置语言如INI和XML。
据说,插件是软件进化和定制的核心。
我错了?!——异常处理
此情可待成追忆 只是当时已惘然
异常是在程序运行中超出正常预期的情况。我和每一个伙伴都会检查自身参数的合理性,当参数不合理时,返回合理的结果或者给参数指定合理的值。所有的代码块都会检测可能存在的错误,当错误发生时,跳过代码块,设置合理的状态并继续执行函数的其他部分。
通常防御式编程能为用户带来较好的体验,每个过程和函数都检测自身参数的合理性,若参数不合理,程序停止运行。另外,当错误发生时,最好将上下文相关的信息写入日志,同时将错误传递回函数调用链。如果对异常采取消极态度,至少也应该通知各方正确的使用方式,以及停止运行的原因。
全局捕获是我们另一种处理异常的方法,在调用其他函数时,程序仅检测能够提供有意义反馈的错误。这种异常处理在函数调用链中位于较上层,仅在程序最外层进行异常处理,无视异常时间发生的位置。
无论在哪里捕获异常,调用栈都是异常信息的一部分,除非局部存在有意义的处理方式,更好的做法是将异常返回到函数调用链的上游。
我眼中的类型错误
堪嗟岁月蹉跎久 却悔尘寰错误多
对于输入参数而言,一般地,我会声明所期待的参数类型。如果调用方没有传送预期类型的参数,则会产生类型错误,这时将不再执行。类型不匹配是指我得到的值类型与所期待的值类型不符;或者一个伙伴返回了一个特定类型的值,但该值稍后被调用者当作其他类型的值使用。不同类型的值通常被分配不同大小的内存空间,这意味着当发生类型不匹配时,内存可能被重写而变得不一致,这就是这类异常的问题所在。
所有现代高级编程语言都有一个类型系统,在开发和执行过程中的不同节点检测数据类型。静态类型的语言如Java 和 Haskell,动态类型如JS,python等等。参数类型分为显示类型和隐式类型,相关的逻辑操作包括强制类型装换,类型推理和类型安全。
我可以专注于数据计算
无边落木萧萧下 不尽长江滚滚来
如果我专注于数据计算的话,会有一些特殊的约束。首先是隔离,核心函数不要有任何副作用,所有IO行为都最好和纯粹的函数明确区分开来,所有包含IO的函数最好从主程序中调用。这样做的主要目的是避免或最小化IO操作,尽量隔离IO操作,因为IO操作在大型系统中是个大问题。
典型的数据密集型应用是数据库,数据独立于程序执行,能够被多个程序使用,存储易于快速检索,通过对数据查询来解决问题。对某些数据的规范处理可能包含数据列及公式,某些数据可能由其他数据通过公式决定。当数据改变时,相关联数据将自动改变,例如数据库中的视图,触发器和存储过程等。
如果数据的可用形式是流,我就是数据流的过滤器/变换器,根据下游的需求,对上游的数据进行处理。流式处理适用于支持生成器的语言,对于Java 这些不支持生成器的语言,可以通过迭代器实现。
我也可以适应并发处理场景
水深草茂群蛙怒 日出风和宿麦秋
面向高并发的场景,每个对象都存在一个队列,用于放置向其他对象发送的消息。每个对象都是一个数据封装,仅公开其接收消息的接口,用于前述队列,每个对象运行于独立的线程中。每个对象主动轮询各自的消息队列,一次处理一条消息,当队列为空时,阻塞当前线程。这大概就是Actor模型了。
当一个或多个并发单元,同时配备了一个或多个数据空间,数据空间用于并发单元的数据存储和检索,并发单元之间只能通过数据空间进行数据交换。 尤其是当任务需要横向开展的时时候,这种基于数据空间交换的方式,也是适合于数据密集型并行处理的。
对于流式数据而言,输入的数据流被分成若干块,map函数对每个数据块运用worker,通常过程是并行的。而 reduce 函数得到各个worker函数的结果,并重组成相关输出。 这种基于MapReduce的函数处理方式,极其适用于可以单独拆分和处理数据的数据密集型操作,其局部结果在最后被重组。如果将worker 函数的结果重组,第二次map以重组后的数据作为reduce函数的参数的话,就是双重MapReduce方式了。
一般的,map可以并行,而reduce不行,hadoop将map的结果列表重组并打乱,使后续reduce函数的调用可以对数据重组进行修改,从而reduce也成了可以并行化的。
至于restful,不过是将我们函数的思想用于子系统,更关注扩展性,去中心化和独立组件开发而已。
结尾的告白
谁人在处望风烟 芸芸众生吾自潜
函数是程序中最小的有序时空,运转于内存堆栈。
关于参数,没有输入参数和返回值就是纯过程函数, 而同时有参数和返回值才可能实现幂等性。多参数的我可以柯里化为单参数高阶函数,而参数中是函数的话可以形成处理管道,或者回调函数。我自己调用自己,就是递归函数。
关于对象,我长在对象上变成了方法,进一步可以提升为抽象对象。对象间的远程调用一般用消息机制,对象间的行为操控可以说是控制反转,而通过对本身的自省可以形成反射,AOP 可以看作有条件的反射。对于插件,几乎是函数组装之集大成者。
关于错误,一般采用防御式编程,也可以采用消极的方式,无论是否采用全局捕获,调用栈都是异常信息的重要部分。
关于特定场景,不论是密集数据计算还是高并发情况,都最终落实到函数的层面。
点击图片get往期内容