面向对象与函数式编程的关系

众所周知, 这些年函数式编程蔚为风气. 我们知道, 函数式编程是有 lambda 演算为数学基础的, 而面向对象, 普遍的说法是没有数学基础.

kotlin dart 等语言都将函数设为一等公民, 不搞函数式已经要被时代抛弃了.

早些年我追随 JavaScript 和 Erlang 的脚步, 对 Java 这种 name kingdom 产生了极大的嫌弃, 很多所谓的模式无非就是回调, 一层搞不定的包一层, 闭包总能解决问题.

名词王国里的死刑(翻译) - Hi~Roy!

在世界的另一边,有一片贫瘠的陆地,动词才是一等公民。这就是函数式王国,包括Haskellia, Ocamlica, Schemeria和一些其他国家。因为这些国家地处偏远,所以很少和Java王国有接触。也正因如此,这些函数式王国没事的时候也互相攻击来解解闷。

AbstractSingletonProxyFactoryBean,
InternalFrameInternalFrameTitlePaneInternalFrameTitlePaneMaximizeButtonWindowNotFocusedState

Why OO Sucks - 为什么面向对象糟透了?

但经过几年高强度的面向对象实践, 我已经从这种思潮回退, 与此同时回退的还有动态语言————可见我中的毒主要来自 JavaScript 😃. 当然认知是螺旋式进步的, 回退的结果也不可能是传统意义的面向对象了.

让我们看看 Compose 框架:

fun <T> compositionLocalOf(
    policy: SnapshotMutationPolicy<T> =
        structuralEqualityPolicy(),
    defaultFactory: () -> T
): ProvidableCompositionLocal<T> = DynamicProvidableCompositionLocal(policy, defaultFactory)

其它 vert.x, gradle 等等以及 reactive 的就不举例了, 太多了.

当这些框架粉墨登场, FP 的缺点也就暴露无遗了, 结合自己的经历, 我认为 FP 事实上在开倒车, 而之前 OO 的问题很可能是因为 OOP 实践不足所致.

JavaScript 里也大量存在着高阶函数, 但由于 JavaScript 是动态语言, 参数不提供类型, 掩盖了高阶函数极其烧脑问题.一旦要为高阶函数的函数参数指定类型, 事情很快就会失控.

// 很好理解, 需要传一个 String f(s as String)
String f(Function<String, String> callback)
// ???
String f(Function<Function<String, String>, String, String> callback)
// ?????????
String f(Function<Function<String, Function<String, String>>, String, String> callback)

这种代码的可读性几乎为零. 一个函数你知道它是干什么用的怎么调用, 是因为它有友好的函数名, 以及友好的参数名, 这都写在编码规范里, 是每个入行的新人都要掌握的知识.

但这种高阶函数的函数参数, 完全不给任何机会注明所需的函数是一个什么样的东西, 对于第一级闭包, 尚有函数参数的参数名可以参考, 到第二级以上, 连参数信息都没有了, 只剩一团漆黑, 如果到第三第四重, 代码可读性不堪设想.

名字虽然讨厌, 但是它是照亮你的脑子的唯一的东西, 当上帝创造了亚当, 亚当最爱干的事就是给各种东西起名字. 正如维特根斯坦所说, 语言的界限就是一个人的世界的界限.

在传统的面向对象里, 这个问题本来是不存在的, 我们以往的做法是:

interface Supplier{String supply(String key){}}
String f(Supplier callback)
interface ComputableSupplier{String supply(Supplier transformer, String key){}}
String f(ComputableSupplier callback)

在这个场景里, 接口类型名称, 函数名称, 参数名称都透露了应该用户怎么干.

那么为什么面向对象反而可以解决的更漂亮呢? 面向对象不是没有数学基础吗?

在大约 08 年我发现, 实际上面向对象就是一种函数式编程:对象就是闭包. 以这个标准看, Java 是能做到这个的面向对象语言, 而 C# 不行.

下面这段代码是当时一个通讯客户端, 写在 JSP 里, 随时可以改, 通过 Ajax 调用. 会阻塞, 因为当时的 JSP 还不支持异步. 但通讯部分不阻塞.

当时我发明了一个 ContinuousHandler 的设计, 通过 registerSuccess registerFail 登记下一个出口应该使用的 Handler.

    final ContinuousHandlerBoat handler = new ContinuousHandlerBoat() {
		@Override
		public void sessionCreated(IoSession session) throws Exception {
			session.setAttribute("handler", this);
			super.sessionCreated(session);
			session.setIdleTime(IdleStatus.READER_IDLE, timeout);
		}
		... ...
	};

	handler.setBaseHandler((new LoginHandler(handler, username, password, 8)).registerSuccess(
			new BindDeviceTransmittingHandler(deviceNo) {

            @Override
            protected void deviceBound(IoSession arg0, long arg1) {
                result.setObject("B" + function + "与" + deviceNo + "绑定成功");

                ByteBuffer buffer = ByteBuffer.allocate(11).setAutoExpand(true).putLong(deviceNo).put("MO".getBytes()).put(type.getBytes()).flip();

                this.transferData(buffer);
            }

            @Override
            protected void deviceOffline(IoSession arg0, Object arg1) throws Exception {
                result.setObject("O" + timeFormat.format(new Date()) + function + deviceNo + "不在线");
            }

		    ... ...

        }.registerSuccess(new ContinuousHandler() {
                @Override
                public void continueHandle(ContinuousHandler prevHandler, IoSession session) {
                    session.close();
                    connector.setWorkerTimeout(0);
                }
			})).registerFail(new ContinuousHandler() {
                @Override
                public void continueHandle(ContinuousHandler prevHandler, IoSession session) {
                    ...
                }
	}));

相信你会和今天用的 .then(..).failed(..) 对应起来, 二者的确殊途同归.

展示这个代码主要是显示我有充分的资格讨论这个问题. 有些人会想, 这很简单, 我知道你要说什么, 匿名内部类就是闭包, 这没错, 匿名内部类可以访问声明函数域的变量, 的确具有闭包性. 但是这个思考还不够究竟.

下面我们从一个更小的例子来看面向对象和 FP 是怎么统一的.

abstract class Shape{
    abstract double getArea(){}
}
class Rectangle extends Shape{
    Rectangle(double width, double height){...}
    double getArea(){ return width * height; }
}
class Circle extends Shape{
    Circle(double radius){...}
    double getArea(){ return Math.PI * radius * radius; }
}
double getArea(Shape shape){ return shape.getArea(); }

这个例子很熟悉了, 我就不解释了. 如果我们用函数将它展开, 是这样的:

enum ShapeType{Rectangle, Cicle}
double getArea(ShapeType shapeType, double width, double height){
    switch(shapeType){
        case Rectangle: return getAreaOfRectangle(width, height);
        case Circle:  return getAreaOfCircle(width);
    }
}
double getAreaOfRectangle(double width, double height){...}
double getAreaOfRectangle(double radius){...}

这里 Circle 只需要一个参数 radius, 但是 getArea 作为入口函数, 它只能从多, 因此有两个参数, 并且参数名也不叫 radius.

仔细看这两种代码:

double getAreaOfRectangle(double width, double height){}
double getAreaOfRectangle(double radius){}
double getArea(ShapeType shapeType, double width, double height)
class Rectangle extends Shape{
    Rectangle(double width, double height){...}
    double getArea(){  }
}
class Circle extends Shape{
    Circle(double radius){...}
    double getArea(){  }
}
double getArea(Shape shape){ return shape.getArea(); }

发生了什么? getArea 的参数对齐了!

这很重要, 我们知道在 FP 里干参数对齐这个活的叫柯里化, 类就是一种柯里化技术!

什么是柯里化, 柯里化在 FP 里非常重要, 它可以将多参数函数化解为单参数函数:

function add(a, b){return a + b};
add(5,4)
// 柯里化
function add(a){
    return function adda(b){
        a + b;
    }
}
add(5)(4)

通过柯里化, 参数 a 被隐藏在闭包 add 中, 到 adda 时可解出来使用.

我们看看这个问题用 FP 的柯里化是怎么干的:

// usage Circle(2)()
function Circle(radius){
    return function getArea(){
        return Math.PI * radius * radius;
    }
}
// usage Rectangle(4, 3)()
function Rectangle(width, height){
    return function getArea(){
        return width * height;
    }
}
function getArea(shapeArea){
    return shapeArea()
}

面向对象的类通过构造函数将不规则不能对齐的参数列表收纳进作为类的 field, 而能对齐的这部分则暴露为公共函数, 公共函数这部分可以抽象成一个带有名字的规格, 也就是抽象类或接口.

Rectangle 类折叠了 width, height 两个参数, Circle 类折叠了 radius 参数, 可以想象 Triangle Polygon 等等还可以折叠其它的参数列表, 最终实现了 getArea 函数对齐.

既然参数折叠在 Shape 类, 运算放在 Shape 就是再正常不过的事情, 柯里化里 getArea 不就放在 Circle 或 Rectangle 里吗? 属于这个闭包的函数为什么不放在这个闭包?

从上面可以看到, 面向对象和 FP 本质上确实是一回事, 面向对象实际上是一种更好的 FP. 命名保障了更好的可读性.

并且, 面向对象提供了一种混合, 上面的柯里化只有一个出口函数, 如果要提供多个出口函数就需要复合了, 例如, 要加上周长的计算, 柯里化的方案是这样的:

// usage: 
//  area: Circle(2)[0](), 
//  length: Circle(2)[1]()
function Circle(radius){
    return [function getArea(){
            return Math.PI * radius * radius;
        }, function getLength(){
            return Math.PI * radius * 2;
        }
    ]
}

显然,这是一个和面向对象同构的方案, 但不如面向对象清晰。

那么,为什么大家说面向对象没有数学基础呢? 实际上真正的面向对象语言和 FP 是一回事, 但是面向对象分析就不同了, 当我们要完成需求分析提供面向对象设计方案时, 给出什么样的设计方案就取决于设计者的经验和见解了, 一个类该不该出现, 到底是不是要提取一个公共类来继承? 这些都没有清晰的规范, 用园林的话叫宜亭斯亭宜榭斯榭.

现在我们再回头看 Joe Armstrong 的文章, 就会发现他的思路存在很大的问题, 他设计的 Erlang 并不是一个真正的 FP 语言. 的确, Erlang 的变量不能改变值等等, 都清晰的体现了 FP 的特征, 但是 Erlang 搞的数据和函数分离, 本质上是违背 FP 的. 真正的 FP 必然导致大量折叠了数据的闭包, 而操纵这些数据的函数只能处于这个闭包的范围. 数据和函数分离实际上是过程式编程的老套路, erlang 精彩的点主要是进程、消息和函数的 tail-recursive, 这解决了函数式编程最难处理的状态改变问题,并且给出了一个漂亮的结构化无锁并发方案,直到今天还启发着各种语言。

Erlang 认为数据应该只放在栈区, 也就是放在参数区, 这样可以保证一目了然, 固定输入固定输出, 刚才我们看到, 即使在参数区通过柯里化折叠后, 也不能直接看到闭包里的数据, 柯里化具有天然的隐蔽性. 上面我们给内部函数都命名了, 如 adda, getArea, 实际上这不是必须的, 假如不给名称, 用户拿到这个函数后有多茫然可想而知. 首先出来的函数没有名称, 其次, 它引用的闭包数据不详. 面向对象里的类事实上修正了这个问题, 类通过暴露 field, 让用户可以自省闭包内的数据, 通过类名方法名, 让用户不再为收到的闭包无名无姓而茫然不知所措. 试想如下函数:

function add(a){return function(b){return a + b}}
var add2 = add(2);
var sum = add2(3)

这里用户调用的 add2 仅仅是一个变量名称, 该名称出了这个区域就无效了. 这种 FP 有什么值得留恋的呢? 的确, FP 有趣, 接近本质, 但是并不适合工程化.

另一方面, 隐蔽性的数据是柯里化的必然结果, 而柯里化是一种普遍的折叠数据的模式, 数据隐蔽不但能折叠数据, 还能将数据和行为划分成一个一个合理的区域, 这本来就是软件工程的不二法门.

文章末尾我们通过一个案例再对比一下 Java 和 C#, 以及 JavaScript. 2006 年我做一个地图组件, 上面有一个工具条, 当选择移动工具时, 按下鼠标可以移动地图, 当选择画矩形工具时, 按下鼠标确定起点, 移动决定选区, 松手绘制完成, 选择绘制多边形等等, 都有不同的鼠标处理逻辑.

我设计了一个通用的处理类:

class Tool{
    void handleMouseDown(e);
    void handleMouseUp(e);
    void handleMouseMove(e);
}
class Map:Control{
    Tool Tool{get;set;};
    override void onMouseDown(e){
        if(Tool != null) Tool.handleMouseDown(e);
    }
    override void onMouseUp(e){
        if(Tool != null) Tool.handleMouseUp(e);
    }
    override void onMouseMove(e){
        if(Tool != null) Tool.handleMouseMove(e);
    }
}

这样, 不同的工具切换不同的 Tool 即可, 同时, 例如绘制矩形工具, 它具有一个自己的属性:

class RectangleDrawer: Tool {
    Rectangle Rectangle{get;set;}

}

大部分工具还有自己的状态机, 如矩形可以拖动, 可以按住锚点和边框调节大小, 这些可以通过从属于 Tool 这个闭包的 flag 和状态机实现.

熟悉 Java 的人会发现, 我做的这个类正好就是 Java 的 MouseHandler. 我以前不是搞 Java 的, 偶尔看看 Java 对 SWING 等等也非常鄙视, 做出来的东西太丑, 集卡慢丑笨代码冗长于一身, addMouseListener 之类代码又臭又长.

但当我沿着抽象路径前进时, 正好 Java 就在那儿, 而 C# 给出的所谓委托的事件处理回调, 本质上是愚蠢的, 这里可以看到, 我需要一个一个的将委托转发到我的 Tool 才能做一点复杂的东西.

C# 非常愚蠢的赶 FP 的场子, 误以为自己的委托就是 FP 的精髓, 实际上, 委托是单函数, 类是一簇函数, 类能给出 1-N 个出口, 1-N 个出口收纳在同一个闭包, 并分享它们的一部分数据, 并且这部分数据还不溢出, 这才是 OO 的精髓.

的确 C# 里函数是可以独立出现的, 但是它没有和 OO 真正融合. 而在 Java 里, 函数总是出现在类里, 这让函数多了一层, 但是这一层往往是必须的, Java 这种设计始终保持了面向对象思想, 一以贯之, 即使今天增加了 Lambda 表达式, 也不过是单函数接口的一个语法糖, 没有造成设计思想的混乱.

更可笑的是 C# 没有理解到类的闭包性, 把匿名内部类砍掉了, 把同样的语法用来做对象初始化了, 这彻底堵死了 C# 转型的空间.

所以, C# 和 VB 一样, 充满了各种小机灵, 但缺乏大智慧.

回到 JavaScript, 我也用 JavaScript 实现了地图工具条. 在使用 JavaScript 完成同样的事情时, 我甚至不需要定义类:

// 事件绑定到当前 Tool 是一致的
map.setTool(
    {
        flag: ...,
        state: ...,
        mousedown: function(e){ this.state...},
        mouseup: function(e){...},
        mousemove: function(e){...},
    }
)

这一来, 工具条的每个按钮都可以现场编写处理程序, 按钮和处理程序结合在一起, 代码再也不分散了, 当程序很长无可避免就单独起一个类, 在开发时可以先完成功能, 然后再分离出去.

要知道这比 C# 可是强太多了!

因为这些原因我对 JavaScript 无比喜爱. 但随着大规模工程的开展, 我发现 JavaScript 这种动态语言根本不适合大规模工程, 时间久了不记得这些 key, 和人合作没有规格, 只能不停的翻源码看实例. 在 2000 年时,我能背下 VB6 所有的函数, 这给我带来了极大的便利. 当年 JavaScript的库规模也不大, 而我用的库很多都是自己写的, 我能记得很清楚.但是现在的库多如牛毛, 再靠人脑记忆简直痴人说梦.

那么, 动态语言都该死吗? 我并不完全否定动态语言, 我认为动态最好限定在一个极小的上下文, 比如一个函数内的局部变量, 当你确定它不会溢出到其它函数, 使用动态语言就没有那么可怕了. d2js 就是这样, 查询结果限制在函数内, 字段就是同一个上下文的 SQL 查询里, 不会失控. 不大的类各个方法之间也可以使用动态特征. 总之, 鸟笼政策, 在鸟笼里随意飞, 不要在广阔天地里搞动态就好, 同样我也是这样使用 groovy.

文字是两周前写的, 今天碰巧云风在纠结鼠标键盘的处理问题, 我分享了我的做法, 想想这篇文章还是挺有价值, 分享出来.

posted @ 2024-07-29 14:26  Inshua  阅读(3)  评论(0编辑  收藏  举报