The Interface Principle in C++
背景
C++的接口原则对于写出既有表现力,又保证了封装性的代码是非常重要的,当前即有语言特性与之相关,且未来还可能有更多特性来增强这一原则,值得我们注意。
本文用到的名词:
- 方法:类的成员函数。
- 函数:非成员函数。
非成员(非友元)函数
Effective C++的Item23中,Scott Meyers鼓励我们将只需要类的公开方法就能实现的函数移到类外面。下面是一个例子:
1 | class Circle |
注意下面两个方法,都只用到了Circle
的其它公开方法:
1 | double getPerimeter() const {return 2 * Pi * getRadius();} |
那么把它们移到Circle
外面作为非成员函数,就遵守了Meyers原则,增强了Circle
类的封装性:
1 | class Circle |
另一方面,这样也减少了Circle
本身的代码量,重构时涉及的代码更少,更稳定。
以下是应用这一原则的步骤:
- 确认指定方法是否只依赖类的其它公开方法(或改起来也比较容易)。
- 创建一个同名的非成员函数。
- 将该类型作为函数的第一个参数:
- 如果原方法不是const方法,参数类型就是非const引用。
- 如果原方法是const方法,参数类型就是const引用。
- 将实现代码复制过来,并在每个调用类公开方法的地方加上参数的名字。
注意,要保证新函数与旧方法同名。有时候我们会不喜欢给一个非成员函数起名为getPerimeter
,更愿意起名为getCirclePerimeter
,这样会显得更具体一些。但这是错的:“Circle”已经出现在第一个参数的类型中了,不管是对人还是对编译器,都不需要在函数名上再加一个“Circle”了。getPerimeter(circle)
看起来也要比getCirclePerimeter(circle)
更自然。
接口原则
新的Circle
类有点令人不安:它有在类外面的功能。这是我们在上一节有意做的,但通常来说类的功能不就是它的接口吗?
上面说对了一半,类的功能就应该是它的接口。但接口也不仅仅包括类的公开方法。这就是“接口原则”要说的。Herb Sutter在Exceptional C++的Item31-34中详细解释了这一原则(见相关文档1和2)。
满足以下条件的非成员函数也是类接口的一部分:
- 它的某个参数是该类的对象。
- 它与该类在相同的命名空间。
- 它与该类一同发布,即它们声明在相同的头文件。
上节中的getPerimeter
和getArea
就满足这些条件,因此它们也是Circle
接口的一部分。换句话说,下面两种调用方式,差别只在于语法:
1 | getPerimeter(circle); |
VS
1 | circle.getPerimeter(); |
根据接口原则,这两种表达方式都是在调用Circle
类的getPerimeter
功能。
ADL(参数依赖查找):接口原则与命名空间配合良好
当引入命名空间之后,接口原则可能会有问题:调用函数时要加命名空间,而方法则不用。也就是函数与方法开始有不一致了:
1 | namespace geometry |
现在函数的调用方式:
1 | geometry::getArea(circle); |
而如果是方法的话,调用方式:
1 | circle.getArea(); |
这两者的不一致对接口原则而言是一种挑战,因为接口原则需要函数与方法间只有语法上的区别,不应该有其它信息上的区别。
幸好C++有参数依赖查找(ADL),又称Koenig查找:将参数类型所在的命名空间的所有函数声明带到当前作用域,来解决名字查找的问题。上例中,在查找getArea
时,circle
触发了ADL,从而geometry
的所有声明被带到当前作用域,其中也包括了getArea
,因此不加命名空间编译器仍然能找到这个函数:
1 | getArea(circle); |
泛型代码
泛型代码中,非成员函数能发挥更大的作用。
前文中我们说过不建议在函数名字中嵌入参数类型的名字。事实上名字起的通用一些还有助于将其用于泛型代码。假设你还有一个Rectangle
类也能计算周长,因此你会为其实现一个getPerimeter
:
1 | double getPerimeter(Rectangle const& rectangle); |
之后我们就可以把getPerimeter
用到泛型代码中了:
1 | template <typename Shape> |
而且,有些类型不是类(比如内置类型)或你没办法给它加方法(比如三方库代码),这时候想为其增加一个泛型代码中可以用的功能,唯一可行的方法就是通过非成员函数。例如C++11增加的std::begin
和std::end
,设计为非成员函数的一大因素就是为了处理内置数组类型。
(实际上这就是另一种实现OO和多态的思路:Traits,很多人觉得它是比继承更好的OO方案,比如Rust就只有Traits没有继承。C++中用Traits还能减少类型间的耦合。)
C++的统一函数调用语法?
C++已经有一些语言特性在支持接口原则了,ADL就是其中最显眼的一个。未来还可能会有更多语言特性与接口原则相关。
std::invoke
(C++17)允许你用一套语法同时处理方法和函数:
1 | std::invoke(f, x, x1, ..., xn); |
- 如果
f
是方法,则调用x.f(x1, ..., xn)
。 - 如果
f
是函数,则调用f(x, x1, ..., xn)
。
已经有提案(见相关文档3)建议语言中直接支持以下两种语法的等价性:
1 | f(x, x1, ..., xn); |
如果f
是x
的一个方法,则等价于x.f(x1, ..., xn)
。而:
1 | x.f(x1, ..., xn); |
如果f
是函数,则等价于f(x, x1, ..., xn)
。
相关文档
- What’s In a Class? - The Interface Principle
- Namespaces and the Interface Principle
- Unified Call Syntax: x.f(y) and f(x,y)
What’s In a Class
本节主要介绍下这篇文章没有被前面内容覆盖到的东西。
C风格的OO
接口原则实际上起源于C风格的OO。例子:
1 | struct _iobuf { /*...data goes here...*/ }; |
这里FILE
就是一个类型,而fopen
、fclose
、fseek
、ftell
是它的公开方法,也就是它的接口。它的C++形式为:
1 | class FILE { |
从接口的角度,这两种形式没有什么区别。
类依赖什么
假设我们为类X
实现operator<<
,有两种方式:
1 | class X { |
以及
1 | class X { |
传统上我们会认为第一种方式更好,因为X
没有依赖ostream
。但实际上对吗?
- 根据接口原则,
operator<<
参数中有X
,且和X
一同被引入,它就是X
的一部分。 operator<<
参数中有ostream
,因此operator<<
依赖于ostream
。- 因此
X
也依赖于ostream
。
所以第一种方式根本没有减少X
的依赖。
一些有意思的结果
如果A
和B
是类,而f(A, B)
是一个非成员函数:
- 如果
A
与f
在一起,那么f
就是A
的一部分,那么A
就依赖B
。 - 如果
B
与f
在一起,那么B
就依赖A
。 - 如果三个都在一起,那么
A
与B
就相互依赖。
下面更有意思。假设有类A
和B
,且A
有方法A::g(B)
,那么有:
- 显然
A
依赖B
。 - 假设
A
和B
在一起,A::g(B)
的参数中有B
,且和B
在一起,那么A::g(B)
也是B
的一部分。显然A::g(B)
的参数中也有A
,即B
的一部分依赖A
,那么B
也依赖A
。因此A
和B
是相互依赖的关系。
“PartOf”的关系到底有多强
接口原则一直在强调非成员函数也可以是类接口的“一部分”,那么这个关系到底有多强?
答案是比成员函数低一些。