现在你已经理解了在c++中写类的所有语法,它有助于复习我们以前介绍过的设计原则。类是c++中抽象的主要部分。应该尽可能地应用类的抽象原则将接口与实现分开。需要特别指出的是,应该将所有的数据成员设置为私有,并且有选择性的提供getter与setter成员函数。SpreadsheetCell类就是这提出实现的:m_value是私有的,而公共的set()成员函数设置其值,公共的getValue()与getString()访问其值。
1、使用接口与实现类
即使是有预先的措施和最佳设计原则,c++程序语言从根本上来说对于抽象原则也是不友好的。语法要求你把公共接口与私有(或受保护)的数据成员与成员函数在类定义中放在一起,因此暴露了一些内部的类的实现细节给到客户。这里面不好的一面是你不得不添加新的非公共成员函数或数据成员给到类中,类的所有客户不得不重新编译。在大型项目中这会变成一个负担。
好消息是你可以使得接口干净许多,隐藏所有的实现细节,使接口更稳固。不好的消息是代码量有点儿大。基本原则是为每一个类要写的类定义两个类:接口类与实现类。如果不用这种方法的话,实现类与你想要写的类一样。接口类提供了公共成员函数与实现类中的相同,但是它只有一个数据成员:一个指向实现类对象的指针。这叫做pimpl idiom,private implementation,或bridge pattern。接口类成员函数实现只是简单调用了实现类对象上相同的成员函数。这样做的结果就是不管实现如何变化,对公共接口类没有影响。降低了重新编译的需求。如果实现(并且只有实现)改变的话,使用接口的客户都不需要进行重新编译。注意习语只在单个数据成员是一个指向实现类的指针时才管用。如果它是一个值数据成员,当实现类的定义改变时客户还是要重新编译的。
在Spreadsheet类中使用这个方法,定义如下的公共接口类,叫做Spreadsheet。
export module spreadsheet;export import spreadsheet_cell;import std;export class Spreadsheet
{
public:explicit Spreadsheet(std::size_t width = MaxWidth, std::size_t height = MaxHeight);Spreadsheet(const Spreadsheet& src);Spreadsheet(Spreadsheet&&) noexcept;~Spreadsheet();Spreadsheet& operator=(const Spreadsheet& rhs);Spreadsheet& operator=(Spreadsheet&&) noexcept;void setCellAt(std::size_t x, std::size_t y, const SpreadsheetCell& cell);SpreadsheetCell& getCellAt(std::size_t x, std::size_t y);const SpreadsheetCell& getCellAt(std::size_t x, std::size_t y) const;std::size_t getId() const;static constexpr std::size_t MaxHeight{ 100 };static constexpr std::size_t MaxWidth{ 100 };void swap(Spreadsheet& other) noexcept;private:class Impl;std::unique_ptr<Impl> m_impl;
};export void swap(Spreadsheet& first, Spreadsheet& second) noexcept;
实现类,Impl,是一个私有的嵌套类,因为在Spreadsheet类之外无需知道这个实现类。Spreadsheet类现在包含了一个数据成员:一个指向Impl实例的指针。公共的成员函数与原来的Spreadsheet类相同。
嵌套的Spreadsheet::Impl类定义在spreadsheet模块实现文件中。应该对客户隐藏,所以Impl类没有export。Spreadsheet.cpp模块实现文件开始如下:
module spreadsheet; import std;using namespace std;// Spreadsheet::Impl class definition.
class Spreadsheet::Impl
{
public:Impl(size_t width, size_t height);// Remainder omitted for brevity.
};
Impl类与原来的Spreadsheet类拥有几乎同样的接口。对于成员函数实现,需要记住Impl是一个嵌套类;因此,需要指定范围Spreadsheet::Impl。所以,对于构造函数,它变成了Spreadsheet::Impl::Impl(...):
// Spreadsheet::Impl member function definitions.
Spreadsheet::Impl::Impl(size_t width, size_t height): m_id{ ms_counter++ }, m_width{ min(width, Spreadsheet::MaxWidth) }, m_height{ min(height, Spreadsheet::MaxHeight) }
{m_cells = new SpreadsheetCell * [m_width];for (size_t i{ 0 }; i < m_width; ++i) {m_cells[i] = new SpreadsheetCell[m_height];}
}
// Other member function definitions omitted for brevity.
既然Spreadsheet类有一个指向Impl实例的unique_ptr,Spreadsheet类需要有一个客户声明的析构函数。由于我们不需要在析构函数中做任何事,可以在实现文件中缺省如下:
Spreadsheet:: ̃Spreadsheet() = default;
实际上,它必须在实现文件而不是直接在类定义中缺省。原因是Impl类只是在Spreadsheet类定义中向前传递声明;也就是说,编译器知道在某处有一个Spreadsheet::Impl类,但是现在它还不知道其定义而已。这样,不能在类定义中缺省析构函数,因为编译器会尝试使用还没有定义的Impl类的析构函数。在这种情况下,当要缺省其它成员函数时也一样,比如move构造函数与move赋值操作符。
Spreadsheet成员函数,比如setCellAt()与getCellAt()的实现,只是传递请求给到背后的Impl对象:
void Spreadsheet::Impl::setCellAt(size_t x, size_t y, const SpreadsheetCell& cell)
{verifyCoordinate(x, y);m_cells[x][y] = cell;
}const SpreadsheetCell& Spreadsheet::Impl::getCellAt(size_t x, size_t y) const
{verifyCoordinate(x, y);return m_cells[x][y];
}SpreadsheetCell& Spreadsheet::Impl::getCellAt(size_t x, size_t y)
{return const_cast<SpreadsheetCell&>(as_const(*this).getCellAt(x, y));
}
Spreadsheet的构造函数必须构造一个新的Impl来干这个活:
Spreadsheet::Spreadsheet(size_t width, size_t height): m_impl{ make_unique<Impl>(width, height) }
{
}Spreadsheet::Spreadsheet(const Spreadsheet& src): m_impl{ make_unique<Impl>(*src.m_impl) }
{
}
拷贝构造函数看下来有一点儿奇怪,因为它需要拷贝源Spreadsheet的背后的Impl。拷贝构造函数使用一个指向Impl的引用,而不是一个指针,所以你必须间接引用m_impl指针来获得对象本身。
Spreadsheet赋值操作符必须同样的传递赋值给到背后的Impl:
Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs)
{*m_impl = *rhs.m_impl;return *this;
}
赋值操作符的第一行看起来有点儿怪。Spreadsheet赋值操作符需要传递调用给到Impl赋值操作符,它只在直接拷贝对象时运行。通过间接引用m_impl指针,强制直接对象赋值,它使得Impl的赋值操作符被调用。
swap()成员函数简单地交换单独的数据成员:
void Spreadsheet::swap(Spreadsheet& other) noexcept
{std::swap(m_impl, other.m_impl);
}
真正将接口与实现分开的技术是强大的。虽然一开始有点儿麻烦,一旦你习惯了,就会发现使用它很自然。然而,在大部分工作环境中并不是通用实践,所以你可能会发现你的同事会有一些抵制。最具竞争力的不是将接口分开的美学,而是如果类的实现发生了变化的构建时间的加速。如果类不使用pimpl idiom,对其实现细节的改变可能会触发长时间的构建。例如,对类定义添加一个新的数据成员触发所有使用该类定义的其它源文件的重建。使用pimpl idiom,可以任意修改实现类定义,只要公共接口类保持不变,就不会触发长时间的构建。
注意:使用稳固的接口类,构建时间可以大大缩短。
将实现与接口分开的另一个方法是使用抽象类,也就是说,一个只有纯的虚函数的接口。然后用一个实现类来实现这个接口。这是下一章要讨论的主题。