0%

The Interface Principle in C++

The Interface Principle in C++

原文地址

背景

C++的接口原则对于写出既有表现力,又保证了封装性的代码是非常重要的,当前即有语言特性与之相关,且未来还可能有更多特性来增强这一原则,值得我们注意。

本文用到的名词:

  • 方法:类的成员函数。
  • 函数:非成员函数。

非成员(非友元)函数

Effective C++的Item23中,Scott Meyers鼓励我们将只需要类的公开方法就能实现的函数移到类外面。下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
class Circle
{
public:
explicit Circle(double radius) : m_radius(radius) {}

double getRadius() const {return m_radius;}
double getPerimeter() const {return 2 * Pi * m_radius;}
double getArea() const {return Pi * m_radius * m_radius;}

private:
double m_radius;
};

注意下面两个方法,都只用到了Circle的其它公开方法:

1
2
double getPerimeter() const {return 2 * Pi * getRadius();}
double getArea() const {return Pi * getRadius() * getRadius();}

那么把它们移到Circle外面作为非成员函数,就遵守了Meyers原则,增强了Circle类的封装性:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Circle
{
public:
explicit Circle(double radius) : m_radius(radius) {}

double getRadius() const {return m_radius;}

private:
double m_radius;
};

double getPerimeter(Circle const& circle) {return 2 * Pi * circle.getRadius();}
double getArea(Circle const& circle) {return Pi * circle.getRadius() * circle.getRadius();

另一方面,这样也减少了Circle本身的代码量,重构时涉及的代码更少,更稳定。

以下是应用这一原则的步骤:

  • 确认指定方法是否只依赖类的其它公开方法(或改起来也比较容易)。
  • 创建一个同名的非成员函数。
  • 将该类型作为函数的第一个参数
    • 如果原方法不是const方法,参数类型就是非const引用。
    • 如果原方法是const方法,参数类型就是const引用。
  • 将实现代码复制过来,并在每个调用类公开方法的地方加上参数的名字。

注意,要保证新函数与旧方法同名。有时候我们会不喜欢给一个非成员函数起名为getPerimeter,更愿意起名为getCirclePerimeter,这样会显得更具体一些。但这是错的:“Circle”已经出现在第一个参数的类型中了,不管是对人还是对编译器,都不需要在函数名上再加一个“Circle”了。getPerimeter(circle)看起来也要比getCirclePerimeter(circle)更自然。

接口原则

新的Circle类有点令人不安:它有在类外面的功能。这是我们在上一节有意做的,但通常来说类的功能不就是它的接口吗?

上面说对了一半,类的功能就应该是它的接口。但接口也不仅仅包括类的公开方法。这就是“接口原则”要说的。Herb Sutter在Exceptional C++的Item31-34中详细解释了这一原则(见相关文档1和2)。

满足以下条件的非成员函数也是类接口的一部分:

  • 它的某个参数是该类的对象。
  • 它与该类在相同的命名空间
  • 它与该类一同发布,即它们声明在相同的头文件

上节中的getPerimetergetArea就满足这些条件,因此它们也是Circle接口的一部分。换句话说,下面两种调用方式,差别只在于语法:

1
getPerimeter(circle);

VS

1
circle.getPerimeter();

根据接口原则,这两种表达方式都是在调用Circle类的getPerimeter功能。

ADL(参数依赖查找):接口原则与命名空间配合良好

当引入命名空间之后,接口原则可能会有问题:调用函数时要加命名空间,而方法则不用。也就是函数与方法开始有不一致了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
namespace geometry
{

class Circle
{
public:
explicit Circle(double radius) : m_radius(radius) {}

double getRadius() const {return m_radius;}

private:
double m_radius;
};

double getPerimeter(Circle const& circle) {return 2 * Pi * circle.getRadius();}
double getArea(Circle const& circle) {return Pi * m_radius * circle.getRadius();}

} // end of 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
2
3
4
5
6
template <typename Shape>
void operateOnShape(Shape const& shape)
{
double perimeter = getPerimeter(shape);
....
}

而且,有些类型不是类(比如内置类型)或你没办法给它加方法(比如三方库代码),这时候想为其增加一个泛型代码中可以用的功能,唯一可行的方法就是通过非成员函数。例如C++11增加的std::beginstd::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);

如果fx的一个方法,则等价于x.f(x1, ..., xn)。而:

1
x.f(x1, ..., xn);

如果f是函数,则等价于f(x, x1, ..., xn)

相关文档

What’s In a Class

本节主要介绍下这篇文章没有被前面内容覆盖到的东西。

C风格的OO

接口原则实际上起源于C风格的OO。例子:

1
2
3
4
5
6
7
struct _iobuf { /*...data goes here...*/ };
typedef struct _iobuf FILE;

FILE* fopen(const char* filename, const char* mode);
int fclose(FILE* stream);
int fseek (FILE* stream, long offset, int origin);
long ftell (FILE* stream);

这里FILE就是一个类型,而fopenfclosefseekftell是它的公开方法,也就是它的接口。它的C++形式为:

1
2
3
4
5
6
7
8
class FILE {
public:
FILE(const char* filename, const char* mode);
~FILE();
int seek(long offset, int origin);
long tell();
...
};

从接口的角度,这两种形式没有什么区别。

类依赖什么

假设我们为类X实现operator<<,有两种方式:

1
2
3
4
5
6
7
class X {
...
};
ostream& operator<<(ostream& o, const X& x) {
...
return o;
}

以及

1
2
3
4
5
6
7
8
9
10
11
class X {
public:
virtual ostream& print(ostream& o) {
...
return o;
}
...
};
ostream& operator<<(ostream& o, const X& x) {
return x.print();
}

传统上我们会认为第一种方式更好,因为X没有依赖ostream。但实际上对吗?

  1. 根据接口原则,operator<<参数中有X,且和X一同被引入,它就是X的一部分。
  2. operator<<参数中有ostream,因此operator<<依赖于ostream
  3. 因此X也依赖于ostream

所以第一种方式根本没有减少X的依赖。

一些有意思的结果

如果AB是类,而f(A, B)是一个非成员函数:

  • 如果Af在一起,那么f就是A的一部分,那么A就依赖B
  • 如果Bf在一起,那么B就依赖A
  • 如果三个都在一起,那么AB就相互依赖。

下面更有意思。假设有类AB,且A有方法A::g(B),那么有:

  • 显然A依赖B
  • 假设AB在一起,A::g(B)的参数中有B,且和B在一起,那么A::g(B)也是B的一部分。显然A::g(B)的参数中也有A,即B的一部分依赖A,那么B也依赖A。因此AB是相互依赖的关系。

“PartOf”的关系到底有多强

接口原则一直在强调非成员函数也可以是类接口的“一部分”,那么这个关系到底有多强?

答案是比成员函数低一些。