ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Alan Kay眼中的面向对象:消息传递才是核心,不是类与继承

Alan Kay眼中的面向对象:消息传递才是核心,不是类与继承

1. 为什么今天还要回头聊面向对象

1.1 一个被说烂了却很少被真正理解的概念

干了十来年开发,我越来越觉得“面向对象”这四个字被用坏了。面试的时候问候选人什么是面向对象,十个人里有八个会背“封装、继承、多态”,再追问一句“为什么要有继承”,基本就开始支支吾吾。我们每天写class、new对象、调方法,但很少有人停下来想过:这套东西到底是为了解决什么问题?它最初的设计者脑子里装的是什么?

Alan Kay是公认的“面向对象之父”,这个头衔来源于他在上世纪六十年代末到七十年代在施乐帕克研究中心的工作,尤其是Smalltalk语言的诞生。但有意思的是,如果你去翻他的访谈和邮件列表发言,会发现他本人对后来主流语言里那套“面向对象”的实现方式并不满意,甚至多次公开说“C++和Java做的那些东西,跟我当初想的不是一回事”。这句话我第一次看到的时候挺震惊的,因为我自己就是从Java入门的,一直以为面向对象就等于类、继承、接口这些东西。

后来我花了不少时间去看他的演讲记录、论文和邮件,慢慢拼出了一个更完整的图景。这篇文章就是把我这些年理解到的东西整理出来,结合我自己写代码、做架构的经历,聊聊Alan Kay当初到底想干什么,后来业界把它变成了什么样子,以及我们今天写代码的时候能从中学到什么。不管你是刚学编程的新手,还是写了十几年代码的老手,我觉得都能从中找到一些有用的东西。

1.2 这篇文章适合谁看

如果你正在学面向对象,被那些抽象概念搞得头大,这篇文章能帮你从“为什么”的角度重新理解它。如果你已经工作几年,每天写业务代码,觉得面向对象就是写一堆类然后互相调用,这篇文章可能会让你重新审视自己的设计习惯。如果你是做架构的,经常要在模块拆分和接口设计上做决策,Alan Kay关于“消息传递”的观点可能会给你一个完全不同的视角。

我不会讲太多学院派的理论,更多是从实际写代码的角度出发,把Alan Kay的原话和我的理解、我的踩坑经验混在一起说。有些地方我会直接给出代码示例,有些地方我会用类比来解释,尽量让不同基础的读者都能跟上。

2. Alan Kay当初到底想表达什么

2.1 他说的“面向对象”跟你学的不太一样

Alan Kay在多次访谈中澄清过,他提出“Object-Oriented”这个词的时候,核心思想其实只有一条:消息传递。不是类,不是继承,不是封装,而是对象之间通过发送消息来通信。他原话大意是,他后悔用了“对象”这个词,因为大家的注意力全跑到“对象”上去了,而忽略了真正重要的“消息”。

这个观点我第一次看到的时候完全没反应过来。因为在我学的Java里,方法调用就是方法调用,哪有什么“消息”?但如果你去看Smalltalk的语法,会发现它跟主流语言完全不同。在Smalltalk里,你写object method: argument,这看起来像调用方法,但Alan Kay坚持认为这是“给对象发了一条消息”。区别在哪?区别在于,发消息的人不需要知道接收者内部怎么处理这条消息,甚至不需要知道接收者到底存不存在这个方法——如果不存在,接收者可以自己决定怎么响应,比如转发给别的对象,或者返回一个默认值。

这听起来好像跟Java的动态代理有点像,但理念上差很远。Java的方法调用是编译期就绑定的(除非你用反射),你调一个方法,编译器必须确认这个方法存在。而Smalltalk的消息传递是运行时的,发送者只管把消息扔出去,接收者自己决定怎么处理。这种设计带来的灵活性是巨大的,但代价是编译期检查变弱了,性能也有损耗。

2.2 从生物细胞到软件对象:一个被忽略的类比

Alan Kay本科学的是数学和分子生物学,他经常用生物细胞来类比对象。这个类比我觉得特别值得展开说,因为它能解释很多设计上的取舍。

想象一个细胞。细胞有细胞膜,把内部和外部隔开,这就是封装。细胞通过表面的受体接收外部信号分子,这就是消息接收。细胞内部有一整套生化反应网络,根据接收到的信号做出响应,这就是方法实现。关键点在于:发送信号的细胞完全不知道接收信号的细胞内部在干什么。它只是释放一个信号分子,至于谁会接收到、接收到之后会怎样,它不关心也管不着。

这个类比跟我们现在写的代码有什么不同?最大的不同在于,我们现在写代码的时候,经常是“我知道你内部怎么实现的,所以我直接调你的方法”。比如我写一个订单服务,我知道库存服务有一个deductStock方法,我就直接调它。但在Alan Kay的设想里,订单服务应该只是发出一个“库存需要减少”的消息,至于库存服务怎么处理这个消息——是直接减、是先检查再减、还是转发给另一个服务——订单服务不应该知道也不应该关心。

这个区别听起来很哲学,但它对架构设计的影响是实实在在的。我后来做微服务拆分的时候,就深刻体会到了这一点。早期我们服务之间直接互相调接口,A服务知道B服务的接口定义,B服务改个参数A就得跟着改。后来我们改成发事件消息,A只管把事件扔到消息队列里,B自己订阅自己处理,A完全不知道B的存在。这不就是Alan Kay说的消息传递吗?

2.3 为什么他后来对主流面向对象语言不满意

Alan Kay对C++和Java的不满主要集中在几点。第一,它们把类当成了核心,而不是消息。在C++和Java里,你首先定义类,然后创建对象,对象之间通过方法调用通信。但在Alan Kay看来,类只是创建对象的模板,真正重要的是对象之间的消息流动。第二,它们过度依赖继承。继承是一种强耦合关系,父类改一点东西,所有子类都可能受影响。Alan Kay更倾向于用组合和消息转发来复用代码。

第三,它们把类型系统搞得太重。Java的静态类型系统在编译期就能发现很多错误,这当然是好事,但代价是灵活性降低。你想在运行时动态改变一个对象的行为,在Java里就得用反射或者动态代理,写起来很别扭。而在Smalltalk里,这本来就是最自然的事情。

我自己的体会是,Java写业务代码确实很舒服,IDE的自动补全、编译期检查、重构工具都很强大。但当我需要做一些高度动态的事情时,比如写一个规则引擎、一个插件系统,Java就显得很笨重。这时候我会怀念Smalltalk那种“什么都是消息”的简洁模型。

3. 消息传递到底比方法调用强在哪

3.1 一个订单系统的例子

光说理论没意思,我拿一个实际场景来对比。假设我们在做一个电商系统,下单之后需要做几件事:扣库存、生成支付单、发通知。用传统的Java方法调用,代码大概长这样:

public class OrderService { private StockService stockService; private PaymentService paymentService; private NotificationService notificationService; public void createOrder(Order order) { stockService.deduct(order.getItems()); paymentService.createPayment(order); notificationService.send(order.getUserId(), "下单成功"); } }

这段代码很直观,但问题也很明显:OrderService直接依赖了三个服务,它知道每个服务的方法名和参数。如果哪天扣库存的逻辑变了,比如要先预占再实扣,StockService的接口就得改,OrderService也得跟着改。这就是耦合。

如果用消息传递的思路来写,OrderService只负责发一条“订单已创建”的消息:

public class OrderService { private MessageBus messageBus; public void createOrder(Order order) { messageBus.publish(new OrderCreatedEvent(order)); } }

库存服务、支付服务、通知服务各自订阅这条消息,自己决定怎么处理。OrderService完全不知道有谁在监听,也不知道它们会做什么。新增一个“积分服务”要监听下单事件?直接加一个订阅者就行,OrderService一行代码都不用改。

这个例子可能有人会说“不就是事件驱动吗,有什么稀奇的”。确实,事件驱动架构现在很流行,但很多人用事件驱动只是把它当成解耦的工具,没有意识到这背后就是Alan Kay最初说的消息传递。而且事件驱动只是消息传递的一种形式,Smalltalk里的消息传递比这更彻底——连方法调用本身都是消息。

3.2 消息传递带来的三个实际好处

第一个好处是极致的解耦。发送者不需要知道接收者的任何信息,不需要知道它的类、它的接口、它的方法签名。这意味着你可以在系统运行的时候动态添加或移除接收者,发送者完全无感。我在做一个插件化系统的时候深刻体会到了这一点。主程序只负责发消息,插件自己注册监听,想加新功能就写个新插件,主程序完全不用动。

第二个好处是更好的容错性。在方法调用模型里,如果被调用的方法抛异常,调用方必须处理。但在消息传递模型里,消息发出去之后,发送者可以继续干自己的事,接收者处理失败是接收者的事。当然这不是说不需要错误处理,而是说错误的边界更清晰了。我在做分布式系统的时候,服务之间通过消息队列通信,一个服务挂了不影响其他服务继续处理消息,等它恢复了再继续消费就行。

第三个好处是更自然的并发模型。对象之间通过消息通信,天然就是异步的。Alan Kay在设计Smalltalk的时候,虽然早期版本是单线程的,但消息传递的模型天然适合并发。Erlang就是把这个理念发挥到极致的语言,每个进程就是一个对象,进程之间只通过消息通信,没有共享内存,所以并发编程变得非常简单。我后来学Erlang的时候,发现它的核心思想跟Alan Kay几十年前说的完全一致。

3.3 消息传递的代价和适用边界

当然,消息传递不是银弹。它最大的问题是调试困难。方法调用的时候,你打开IDE就能看到调用栈,知道是谁调了谁。但消息发出去之后,谁在处理、处理到哪一步了,你很难追踪。我在用事件驱动架构的时候,最头疼的就是排查问题。一个订单状态不对,你得去翻消息队列的日志,看消息有没有发出去、有没有被消费、消费的时候有没有报错。

另一个问题是性能开销。方法调用是直接跳转,消息传递需要经过消息队列或者事件总线,中间多了序列化、网络传输、反序列化这些步骤。对于高性能场景,比如高频交易系统,消息传递的延迟可能是不可接受的。

还有一个问题是类型安全。Java的方法调用在编译期就能检查参数类型对不对,但消息传递通常是运行时的,消息格式对不对只有运行的时候才知道。虽然可以用Schema来约束,但灵活性换来的就是安全性降低。

所以我的经验是:模块内部用方法调用,模块之间用消息传递。一个类内部的方法互相调用,没必要搞成消息;但两个服务之间、两个子系统之间,消息传递带来的解耦收益远大于它的代价。

4. 继承、组合与原型:三种复用方式的取舍

4.1 继承为什么被Alan Kay嫌弃

Alan Kay对继承的态度很明确:能不用就不用。他在访谈里说过,继承的问题在于它把“是什么”和“怎么实现”绑在了一起。你继承一个类,不仅继承了它的接口,还继承了它的实现。父类的实现改了,子类可能就坏了。这就是所谓的“脆弱基类问题”。

我踩过这个坑。早期做项目的时候,我设计了一个BaseService,里面放了一些公共方法,然后所有的Service都继承它。一开始挺好,代码复用很爽。但后来BaseService加了一个方法,有个子类刚好有同名但参数不同的方法,编译就报错了。再后来BaseService改了一个protected方法的实现,好几个子类的行为都变了,排查了半天才发现是父类的问题。

Alan Kay更推崇的是组合。你要复用代码,就把那个对象组合进来,通过消息转发来调用它的功能。这样两个对象的耦合度低得多,一个变了另一个不一定受影响。在Smalltalk里,虽然没有像Java那样明确的“组合优于继承”的教条,但整个语言的设计就是偏向组合的。

4.2 原型继承:一个被低估的方案

Smalltalk的继承机制其实跟Java不太一样。Smalltalk里没有“类”和“实例”的严格区分,一个对象可以当作另一个对象的原型,新对象直接复制原型然后修改。这种机制后来在JavaScript里被发扬光大,就是原型继承。

原型继承的好处是更灵活。你不需要先定义一个类,再创建实例。你可以直接拿一个现成的对象,改吧改吧就当模板用了。这在快速原型开发的时候特别方便。我在写一些脚本或者做实验的时候,经常用JavaScript的原型继承,几行代码就能搞出一个对象,不用像Java那样先写一个类文件。

但原型继承也有问题,就是结构不清晰。一个对象继承自哪个原型,在运行时才能确定,静态分析工具很难帮你检查。大型项目里如果到处用原型继承,代码会变得很难维护。所以JavaScript后来也加了class语法,虽然底层还是原型,但至少写起来清晰多了。

4.3 我的实际选择:看场景

说了这么多,实际写代码的时候怎么选?我的经验是这样的:

  • 业务实体类:用组合。一个订单有用户信息、商品列表、支付信息,这些都是组合关系,不是继承关系。订单不是一种用户,也不是一种商品,所以不应该用继承。
  • 框架扩展点:用继承或者接口。比如你要写一个自定义的Servlet,继承HttpServlet是合理的,因为Servlet容器需要按照固定的生命周期来调用你的代码,继承提供了这个契约。
  • 工具类:用静态方法或者组合。工具类不需要继承,也不需要实例化,直接调静态方法最简单。
  • 需要动态改变行为的场景:用原型或者消息转发。比如你要实现一个可以运行时添加功能的日志对象,用装饰器模式(组合的一种)比继承灵活得多。

Alan Kay的观点给我的启发是:不要因为语言支持继承就到处用继承。每次你想写extends的时候,先问自己一句:这里真的是“是一种”的关系吗?还是只是“有一个”或者“用到了”的关系?大部分时候,答案是后者。

5. 从Smalltalk到现代语言:哪些思想被保留了

5.1 动态派发与反射

Smalltalk最核心的机制是动态派发:你给对象发一条消息,对象在运行时决定调用哪个方法。这个机制在现代语言里以各种形式被保留了。Java的反射、Python的getattr、Ruby的method_missing,本质上都是动态派发。

我在做框架开发的时候经常用反射。比如写一个ORM框架,需要根据数据库表的字段动态生成对象,这时候反射就很有用。但反射的代码写起来很啰嗦,性能也不如直接调用。所以我的原则是:框架内部可以用反射,业务代码尽量不用。框架需要灵活性,业务代码需要可读性和性能。

5.2 接口与协议

Java的interface、Go的interface、Python的Protocol,这些其实都是对“消息传递”的一种静态化表达。接口定义了一组消息,实现接口的类承诺能处理这些消息。但跟Smalltalk不同的是,这些语言通常要求类显式声明实现了某个接口,而Smalltalk不需要——只要对象能响应消息就行,不需要提前声明。

Go的接口设计我觉得最接近Alan Kay的理念。Go的接口是隐式实现的,一个类型只要有了接口要求的所有方法,就自动实现了该接口。你不需要写implements,编译器自动帮你检查。这种设计既保留了静态类型的安全性,又有一定的灵活性。

5.3 消息队列与事件驱动架构

现代分布式系统里的消息队列(比如Kafka、RabbitMQ)和事件驱动架构,可以说是Alan Kay消息传递思想在系统层面的体现。服务之间不直接调用,而是通过消息通信,这跟Smalltalk里对象之间发消息本质上是同一回事,只是粒度从对象变成了服务。

我在做微服务架构的时候,越来越倾向于用事件驱动的方式来做服务间通信。同步调用的问题在于,调用方必须等待被调用方响应,被调用方挂了调用方也跟着挂。异步消息就没这个问题,发送方把消息扔出去就完事了,接收方什么时候处理、处理多久,发送方不关心。这种架构的弹性明显更好。

6. 常见问题与实操避坑指南

6.1 学了Alan Kay的观点之后,我该怎么写代码

这是很多人会问的问题。我的回答是:不要走极端。Alan Kay的观点是很有启发性的,但不意味着你要把所有方法调用都改成消息传递,把所有继承都改成组合。那是不现实的,也是没必要的。

我的建议是从小处着手。下次你写代码的时候,遇到下面这些情况,可以试试用Alan Kay的思路来重新思考:

  • 当你发现两个模块之间互相依赖,改一个另一个就得改的时候,考虑用消息传递来解耦。
  • 当你写了一个很深的继承层次,子类越来越多、越来越难维护的时候,考虑用组合来替代。
  • 当你需要运行时动态改变对象行为的时候,考虑用消息转发或者装饰器模式。
  • 当你设计一个跨团队、跨服务的接口时,考虑用事件而不是直接调用来通信。

6.2 消息传递会不会让代码更难懂

会,也不会。对于简单的场景,方法调用确实更直观。你打开代码,看到a.foo(),就知道调了a的foo方法。但消息传递是间接的,你看到bus.publish(event),不知道谁在处理这个事件,得去搜订阅者。

但对于复杂的场景,消息传递反而让代码更清晰。因为每个模块只关心自己发什么消息、收什么消息,不需要关心整个调用链。我在维护一个大型系统的时候,最怕的就是那种几十层的方法调用链,改一个地方得顺着调用链一路看下去。消息传递把这种长链切断了,每个模块的边界更清晰。

所以我的经验是:小系统用方法调用,大系统用消息传递。系统小的时候,调用链短,方法调用更直观。系统大了之后,调用链复杂,消息传递的解耦优势就体现出来了。

6.3 常见问题速查表

问题原因解决思路
继承层次太深,改父类影响子类脆弱基类问题用组合替代继承,或者把父类改成接口
模块之间互相依赖,改一个动全身直接方法调用导致强耦合引入消息/事件机制,让模块通过消息通信
消息发出去不知道谁在处理消息传递的间接性加日志追踪,用消息中间件的管理界面查看消费情况
动态派发性能差运行时查找方法热点路径用静态调用,非热点路径用动态派发
接口定义太死,扩展困难静态类型系统的限制用泛型、默认方法、或者动态语言特性来增加灵活性

6.4 几个我踩过的坑

第一个坑是过度解耦。有一段时间我迷上了事件驱动,把系统里所有的调用都改成了发事件。结果代码变得极其难懂,一个简单的业务流程被拆成了十几个事件,调试的时候得在日志里翻半天才能拼出完整的流程。后来我学乖了,只在真正需要解耦的地方用事件,模块内部的调用还是老老实实用方法。

第二个坑是消息格式不统一。早期做事件驱动的时候,每个服务定义自己的事件格式,结果消息队列里什么格式都有,消费方得写一堆适配代码。后来我们定了一个统一的事件信封格式,所有事件都包一层,里面放事件类型、时间戳、来源、负载,消费方先解析信封再处理负载,清爽多了。

第三个坑是忘了处理消息丢失。消息队列虽然可靠,但也不是100%不丢消息。我们有一次因为磁盘满了,消息队列丢了一批消息,导致一些订单状态不对。后来加了消息持久化和补偿机制,才解决了这个问题。所以用消息传递的时候,一定要考虑消息丢了怎么办。

7. 我个人的一些体会

写了这么多,最后说几句我自己的感受。Alan Kay的观点对我最大的影响,不是让我改变了写代码的方式,而是让我改变了思考代码的方式。以前我看到一个系统,首先想的是它有哪些类、哪些接口、继承关系是什么样的。现在我会先想:这个系统里有哪些角色,它们之间需要传递什么消息,消息的流动路径是什么样的。

这个视角的转变让我在设计系统的时候更关注边界和契约,而不是实现和结构。类怎么组织、继承怎么设计,这些都是实现细节。真正重要的是:谁需要什么信息,信息怎么从产生的地方流到需要的地方。把这个想清楚了,代码结构自然就出来了。

另外,Alan Kay对教育的看法也很有意思。他说过,教编程不应该从语法开始,而应该从“你想让计算机做什么”开始。先想清楚要解决什么问题,再去学怎么用代码表达。这个观点我特别认同。我见过太多初学者,花大量时间学语法,但拿到一个实际问题就不知道从哪下手。其实应该反过来,先有一个想做的项目,在做的过程中遇到什么问题就学什么,这样学得又快又扎实。

面向对象这个概念被讨论了这么多年,各种定义和解释满天飞。但回到Alan Kay最初的想法,其实很简单:让对象通过消息来协作,每个对象管好自己的事,不要越界。这个原则放到今天,不管是写代码、做架构、还是带团队,都挺适用的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进