博客 阅读时间 8 分钟

OpenCV:运用 KISS 与 YAGNI 原则的艺术

分享本文
OpenCV: The Art of Using the KISS and YAGNI Principles

作为程序员,我们常常忍不住想使用设计模式、语言惯用法、高级语言特性和知名库,这当然是值得提倡的。然而,在动手之前,我们有必要透过 KISS 和 YAGNI 原则的视角来审视这些技术。

"KISS"代表"保持简单,别犯傻"(Keep It Simple, Stupid)。这是一条设计原则,主张简单性应是关键目标,应避免不必要的复杂性。其理念是:简单的方案更容易理解、维护和排查问题。KISS 原则被广泛应用于各个领域,包括工程、软件开发、用户界面设计和项目管理。

YAGNI 代表"你不会需要它"(You Ain't Gonna Need It)。这是软件开发和敏捷方法论中的一条原则,它建议开发者:在真正需要某个特性来解决具体问题或满足具体需求之前,不要将其添加到代码库中。

YAGNI 原则基于这样一个理念:过早添加不必要的特性或功能可能导致几个潜在问题。

OpenCV(开源计算机视觉库)是一个主要面向实时计算机视觉的编程函数库,由英特尔位于下诺夫哥罗德的俄罗斯研究中心开发。该库是跨平台的,主要聚焦于实时图像处理。

OpenCV 是一个庞大的项目,包含众多复杂的特性。尽管如此,OpenCV 的开发者们恪守了一些基本原则,使其代码库明显更易于理解和维护。

让我们探究一下 OpenCV 的一些设计选择:

模块化

1 - 基于库的架构

基于库的架构使所提供的功能更容易复用并集成到其他项目中。此外,基于库的架构鼓励干净的 API 和关注点分离,使系统更易于理解,因为开发者可以专注于大图景中的小部分。

OpenCV 采用了这种方式,定义了许多库;每个库都有特定的职责,而且所有库都使用 opencv_core 库。

opencv1

2 - 通过命名空间实现模块化

OpenCV 大量使用命名空间来有效组织其代码库。以下是 opencv_core 项目中使用的一些命名空间示例:

opencv2

OpenCV 使用"按特性划分命名空间"的方式。这种方式用命名空间来反映特性集,将与单一特性相关的所有项(且仅与该特性相关的项)放入同一个命名空间。这样就产生了高内聚、高模块化、命名空间之间耦合最小的命名空间。紧密协作的项被放在一起。

在 OpenCV 中,使用命名空间主要有三个原因:

  • 对库进行模块化。
  • 隐藏细节,如"cv::detail"命名空间。如果我们想告知库的使用者:他不需要直接使用这个命名空间中的类型,因为它仅供内部使用,这种方式就非常有用。在 C# 中,"internal"关键字可以做到这一点,但在 C++ 中没有办法向库使用者隐藏公共类型。
  • 匿名命名空间:没有名字的命名空间。它避免了创建全局静态变量。您创建的"匿名"命名空间只能在创建它的文件内访问。

将数据模型定义为 POD 类型

每个项目都有自己的数据模型,我们可以使用纯旧数据(POD)类型来定义这个模型。POD 类型是一种仅表示为字段值(实例变量)的被动集合、不使用面向对象特性的数据结构。在编程中使用 POD 类型的好处很多,包括:

  1. 效率:POD 类型通常具有简单的内存布局,这往往带来更高效的内存使用和更快的性能。它们避免了与复杂数据结构和成员函数相关的开销。
  2. 兼容性:POD 类型与底层编程结构和数据交换格式兼容,适合与外部系统和语言对接。
  3. 互操作性:POD 类型可以轻松在系统的不同模块或组件之间传递,也可以在不同系统或编程语言之间传递,从而促进互操作性。
  4. 易用性:POD 类型易于使用和理解,因为它们通常表示基本数据类型或这些类型的聚合。
  5. 性能:与更复杂的数据类型相比,POD 类型在执行速度和内存使用方面通常带来更好的性能。
  6. 可预测性:由于 POD 类型具有简单且定义良好的结构,其行为通常更可预测,这使调试和优化更容易。

总体而言,使用 POD 类型有助于实现更简单、更高效、更易维护的代码,尤其是在性能关键或资源受限的环境中。

让我们在 OpenCV 代码库中搜索没有方法、只包含字段的结构体。

opencv3

该查询的结果涉及 OpenCV 项目中定义的 25% 的类型。OpenCV 几乎将其所有数据模型都定义在只有字段的结构体中。

避免多重继承

多重继承会使设计复杂化并使调试更加困难,这就是为什么许多 C++ 专家建议避免使用它。

让我们找出 OpenCV 代码库中继承自多个具体基类的类。

opencv4

只有测试项目中的少数几个类使用了多重继承;整个 OpenCV 代码库都避免了这个概念。

避免定义复杂的函数

有许多指标可以检测复杂函数;代码行数(NBLinesOfCode)、参数数量和局部变量数量是最基本的几个。

还有其他一些检测复杂函数的有趣指标:

  • 圈复杂度是一种流行的过程式软件度量,等于一个过程中可以做出的决策数量。
  • 嵌套深度是定义在方法上的度量,相对于方法体中最深层嵌套作用域的最大深度。
  • 最大嵌套循环等于一个函数中循环嵌套的最大层级。

这些指标的最大可接受值取决于团队的选择;没有通用的阈值。

让我们在 OpenCV 代码库中搜索可能被认为是复杂的方法。

opencv5

只有 1% 是为降低复杂性而重构的候选。

耦合

低耦合是理想的,因为应用程序某一区域的变更将只需要在整个应用程序中做更少的改动。从长远来看,当修改应用程序或添加新特性时,这可以节省大量时间、精力和成本。

低耦合可以通过使用抽象类来实现。以下是使用抽象类带来的三个关键好处:

  • 抽象类提供了一种定义契约的方式,从而促进复用。如果一个对象实现了一个抽象类,那么该对象就必须符合一个标准。使用另一个对象的对象被称为消费者。抽象类是对象与其消费者之间的契约。
  • 抽象类还提供了一层抽象,使程序更易于理解。抽象类让开发者可以讨论代码行为的一般方式,而不必陷入大量细节。
  • 抽象类强制组件之间的低耦合,这使得保护抽象类的消费者免受实现该抽象类的类中任何实现变更的影响变得容易。

让我们搜索 OpenCV 定义的所有抽象类:

opencv6

如果我们的主要目标是实现低耦合,那么使用抽象类时有一个常见错误可能会使其效用荡然无存:使用具体类而不是抽象类。为了更好地解释这个问题,我们来看下面的例子:

类 A 实现了包含 calculate() 方法的抽象类 IA。消费者类 C 是这样实现的:

public class C
{
   ….
   public:
      void calculate()
      {
        …..
        m_a->calculate();
        ….
       }
       A* m_a;
 };

类 C 引用的不是抽象类 IA,而是类 A。在这种情况下,我们失去了低耦合的好处。这种实现有两个主要缺点:

  • 如果我们决定使用 IA 的另一个实现,就必须修改类 C 的代码。
  • 如果 A 中添加了一些 IA 中不存在的方法,而 C 使用了它们,我们也失去了使用接口的契约优势。

C# 在语言中引入了显式接口实现能力,以确保 IA 的方法永远不会从具体类的引用被调用,只能从接口的引用调用。这项技术非常有用,可以保护开发者不失去使用接口的好处。

内聚性

单一职责原则指出,一个类不应该有多于一个的变更原因。这样的类被称为内聚的。高 LCOM 值通常表明类内聚性差。LCOM 度量有几种变体。LCOM 的取值范围是 [0-1]。LCOM HS(HS 代表 Henderson-Sellers)的取值范围是 [0-2]。高于 1 的 LCOM HS 值应被视为警示。以下是 LCOM 度量的计算方法:

LCOM = 1 – (sum(MF)/M*F)
LCOM HS = (M – sum(MF)/F)(M-1)

其中:

  • M 是类中方法的数量(静态方法和实例方法都计入,还包括构造函数、属性的 getter/setter、事件的 add/remove 方法)。
  • F 是类中实例字段的数量。
  • MF 是类中访问某个特定实例字段的方法数量。
  • Sum(MF) 是类中所有实例字段的 MF 之和。

这些公式背后的基本思想可以表述为:如果一个类的所有方法都使用它的所有实例字段,那么这个类就是完全内聚的,这意味着 sum(MF)=M*F,从而 LCOM = 0 且 LCOMHS = 0。

高于 1 的 LCOM HS 值应被视为警示。

opencv8

只有少数类型内聚性不佳。

结论

如果您看一眼 OpenCV 的源代码,您会为其实现的简洁性感到惊讶:没有不必要的高级设计概念,也没有过度工程——只有始终如一地应用的良好基本原则。

分享本文