OOP 并不糟糕
面向对象编程(OOP)虽然不是作者偏好的范式,但主流静态类型OOP在多人长期协作编程方面做对了一些非常重要的事情。本文重点探讨了其中最核心的一点,并通过与Haskell的对比,说明OOP并非一无是处。
什么是OOP?
本文中的OOP指使用静态类型语言编程,具备以下特性:
- 类:组合状态和可修改状态的方法。
- 继承:允许类复用其他类的状态和方法。
- 子类型:如果类型B实现了类型A的公共接口,则B的值可作为A传递。
- 虚拟调用:方法调用的接收者类型由运行时而非静态类型决定。
符合此定义的语言包括C++、Java、C#、Dart。
OOP的优势:可组合库与向后兼容演进
通过一个日志库和数据库库的示例,展示了OOP特性如何实现库的平滑扩展和向后兼容:
- 初始设计:简单的
Logger类和DatabaseHandle类。
- 抽象化:将
Logger改为抽象类,添加具体子类(如_SimpleLogger)。此更改向后兼容,用户代码无需修改。
- 添加新功能:
- 添加忽略日志的
_IgnoringLogger子类。
- 添加文件日志等实现。
- 为
DatabaseHandle添加使用日志的新实现(如_LoggingDatabaseHandle)。
- 添加基于严重级别的日志过滤(
_LogAboveSeverity)。
- 添加文件日志并支持刷新操作(
FileLogger)。
关键优势:
- 所有更改均保持类型安全。
- 库演进完全向后兼容。
- 应用程序代码无需修改类型或方法。
- 仅需更新对象构造方式即可使用新功能。
在Haskell中实现类似功能
方法1:代数数据类型(ADT)与回调
data Logger = MkLogger { _log :: Message -> Severity -> IO () }
- 新状态通过闭包捕获,而非添加到类型中。
- 新类型(如
FileLogger)需要单独定义,现有代码无法直接使用。
- 扩展性有限,需要重构用户代码。
方法2:类型类
class Logger a where
log :: a -> String -> Severity -> IO ()
- 需要隐藏具体类型以保持向后兼容。
- 使用类型参数会导致“雪球效应”,更改传播到所有用户。
- 存在类型(existentials)语法复杂,使用受限。
方法3:效果单子
class MonadLogger m where
log :: String -> Severity -> m ()
- 通过单子转换器实现不同日志后端。
- 避免了类型参数传播问题。
- 但需要整个生态系统遵循相同模式。
可组合效果系统
- 不同效果库(如mtl和eff)通常不兼容。
- 使用不同处理器需要复杂的类型操作。
- 对生态系统一致性要求高。
OOP与Haskell方案的对比
| 方面 |
OOP |
Haskell(效果单子) |
| 代码更改 |
仅需修改对象构造 |
需要整个生态采用相同模式 |
| 类型安全 |
全程保持 |
全程保持 |
| 向后兼容 |
天然支持 |
需要精心设计 |
| 新开发者门槛 |
低,代码直观 |
高,需要理解效果系统 |
| 库兼容性 |
无需特殊处理 |
可能需要适配器 |
结论
主流静态类型OOP在类型安全的前提下,实现了直白的向后兼容演进和易于组合的特性。这对于大型长期项目至关重要。
Haskell虽有设计模式(如效果单子)可解决类似问题,但需要整个生态系统遵循相同模式。功能编程社区应更认真对待OOP的这些工业成功经验,而非简单归因于历史偶然。