Post

圆和椭圆的思考: 谁是一等公民?

圆和椭圆的问题

首先, 我们假设有一个圆和一个椭圆需要存储, 我们可以声明这样的一个结构体或类来存储

1
2
3
4
5
6
7
8
9
10
11
class Ellipse {
  private double semiMajorAxis;   // 椭圆的半长轴
  private double semiMinorAxis;   // 椭圆的半短轴
  private char direction;         // 椭圆的方向, 即长轴和x轴重合还是和y轴重合
  ...                             // 可以在这里定义更多的字段
}

class Circle extends Ellipse {
  private double radius;          // 圆的半径
  ...                             // 可以在这里定义更多的字段
}

这样的字段设计似乎是没什么问题的, 但是如果需要实现gettersetter访问器, 就不能这样设计了, 因为Circle无法放弃继承Ellipse独有的字段的访问器.

此外, 即使没有访问器问题, 对于圆和椭圆, 他们的方法也不是可以直接继承的, 它们都可以求半径, 可以求得方程, 可以求周长, 但是对于椭圆来说, 它还可以旋转得到一个新的椭圆 (数学上不是这样, 但是对于计算机来说, 一个方向不同的椭圆, 是一个新的椭圆). 也就是说, 椭圆比圆有更多操作. 如果我们选择按椭圆的方法存储圆, 那么, 圆中将永远多存储一个无用的数据, 此外, 在修改圆的时候, 还需要额外修改使半长轴和半短轴相等, 这也无形中增大了性能负担.

既然椭圆的操作比圆更多, 那么是否应该让椭圆继承自圆呢?

这看似是合理的, 在代码的层面, 这也是可行的. 一个寻常的椭圆不会是一个圆, 但是一个圆可以被视为是一个半长轴和半短轴相等的圆. 但是, 根据Liskov可替代性原则 (Liskov Substitution Principle, LSP), 所以这个方案违背了LSP, 不是一个好的设计.

这实际上是基于类继承的面向对象程序设计的一个难题: 即按照LSP设计的子类和父类相比, 有更少的字段和操作. 这也暴露了基于类继承的面向对象的一个巨大缺陷.

接口和类的综合使用

那么, 对于我们刚刚提到的需求, 可以发现, 它们可以进行如下的分类

需求类别
计算面积所有图形
计算曲线方程所有可以求方程的图形或曲线
计算周长所有边长收敛的图形
旋转没有对应的旋转轴 ($C_n$) 的图形或曲线
计算焦距所有圆锥曲线
计算离心率所有圆锥曲线
平移所有图形和曲线

可以看出, 一些需求是所有图形都共有的, 一些需求是部分图形所有的, 可以据此将这些需求按其分类抽象起来. 而这些需求只是被类别所定义的, 而具体的实现依赖于具体的图形.

也就是说, 可以将上述需求交给接口来定义

需求接口
计算面积Shape
计算曲线方程AnalyticShape, AnalyticSection
计算周长LimitedPerimeterShape
旋转CnAsymmetricShape, CnAsymmetricSection
计算焦距ConicSection
计算离心率ConicSection
平移Shape, Section

这样一来, 使用接口的继承关系, 就可以设计出更合理的设计. 这也是为什么一些现代语言 (例如Go和Rust, 它们分别发布于2012年和2015年) 已经放弃了类的继承, 而是使用类似于接口和结构体的配合, 来实现面向对象. 这是因为接口的继承耦合度更低, 代码可维护性也就更好. 同样地, 使用委托而不是继承, 也是在一般情况下更好的设计方式.

This post is licensed under CC BY 4.0 by the author.