Effective C++ 条款23:宁以 non-member、non-friend 替换 member 函数
宁可拿 non-member non-friend 函数替换 member 函数。这样做可以增加封装性、包裹弹性(packaging flexibility)和机能扩充性。
一、引言:封装性的量化思考
Scott Meyers 在本条款中提出了一个精妙的观点:封装性的高低,可以用"能够访问私有成员的函数数量"来衡量。
- 能访问私有成员的函数越少 → 封装性越高
- 能访问私有成员的函数越多 → 封装性越低
这个视角让我们重新思考:一个函数应该作为 member,还是作为 non-member?
二、问题场景:WebBrowser 类的设计困境
假设我们要设计一个网页浏览器类:
cpp
// ❌ 成员函数过多的设计
class WebBrowser {
public:
// 核心功能------必须访问私有成员
void clearCache() { cache_.clear(); }
void clearHistory() { history_.clear(); }
void clearCookies() { cookies_.clear(); }
// 便利功能------也可以通过公有接口实现
void clearEverything() {
clearCache();
clearHistory();
clearCookies();
}
// 更多便利功能...
void clearForPrivacy() {
clearCookies();
clearHistory();
// 额外清理...
}
private:
std::vector<std::string> cache_;
std::vector<std::string> history_;
std::vector<std::string> cookies_;
};
2.1 问题分析
clearEverything() 和 clearForPrivacy() 真的需要成为 member 函数吗?
它们完全可以只通过公有接口实现,却获得了访问所有私有成员的权限。这意味着:
- 封装性降低:更多函数能访问私有数据
- 接口膨胀:类的公有接口变得臃肿
- 编译依赖增加:便利函数的变更也需要重新编译依赖 WebBrowser 的代码
三、non-member non-friend 方案
3.1 核心思想
如果一个函数可以通过类的公有接口完成其功能,那么它不应该成为 member 函数或 friend 函数。应该将它放在同一个命名空间中:
cpp
// ✅ 精简的核心类
class WebBrowser {
public:
// 只保留必须访问私有成员的核心功能
void clearCache() { cache_.clear(); }
void clearHistory() { history_.clear(); }
void clearCookies() { cookies_.clear(); }
private:
std::vector<std::string> cache_;
std::vector<std::string> history_;
std::vector<std::string> cookies_;
};
// ✅ 便利功能放在命名空间中
namespace WebBrowserUtils {
void clearEverything(WebBrowser& browser) {
browser.clearCache();
browser.clearHistory();
browser.clearCookies();
}
void clearForPrivacy(WebBrowser& browser) {
browser.clearCookies();
browser.clearHistory();
}
// 轻松添加新功能,无需修改 WebBrowser 类!
void backupAndClear(WebBrowser& browser) {
// backup(browser); // 假设有备份功能
clearEverything(browser);
}
}
3.2 封装性对比
| 方案 | 能访问私有成员的函数数 | 封装性 |
|---|---|---|
| member 方案 | clearCache, clearHistory, clearCookies, clearEverything, clearForPrivacy |
低 |
| non-member 方案 | clearCache, clearHistory, clearCookies |
高 |
💡 关键洞察 :
WebBrowserUtils::clearEverything无法 访问WebBrowser的私有成员。它只能通过公有接口操作,这正是封装性最大化的体现。
四、包裹弹性:命名空间的优势
4.1 功能分组与模块化
non-member 函数可以按功能分组到不同的头文件中,减少编译依赖:
cpp
// web_browser.h ------ 核心类,最小接口
class WebBrowser {
public:
void clearCache();
void clearHistory();
void clearCookies();
void navigate(const std::string& url);
std::string getCurrentPage() const;
private:
// 实现细节...
};
// web_browser_cleanup.h ------ 清理功能
namespace WebBrowserUtils {
void clearEverything(WebBrowser& browser);
void clearForPrivacy(WebBrowser& browser);
}
// web_browser_export.h ------ 导出功能
namespace WebBrowserUtils {
void exportHistory(const WebBrowser& browser, const std::string& filename);
void exportBookmarks(const WebBrowser& browser, const std::string& filename);
}
// web_browser_security.h ------ 安全功能
namespace WebBrowserUtils {
void enablePrivateMode(WebBrowser& browser);
void checkForMalware(WebBrowser& browser);
}
好处:
- 客户端只需包含需要的头文件
- 新增功能无需修改核心类
- 不同团队可以独立开发和维护各自的模块
4.2 与 STL 设计哲学一致
STL 是这一设计哲学的典范:
cpp
#include <vector>
#include <algorithm>
std::vector<int> vec = {3, 1, 4, 1, 5, 9};
// std::sort 不是 std::vector 的成员函数!
std::sort(vec.begin(), vec.end());
// std::find 也不是!
auto it = std::find(vec.begin(), vec.end(), 5);
// std::reverse 同样不是!
std::reverse(vec.begin(), vec.end());
STL 将容器 和算法分离,这正是 non-member 思想的极致体现。算法不依赖于容器的内部实现,而是通过统一的迭代器接口操作容器。
五、实际应用场景
5.1 输入输出操作符
cpp
class Complex {
public:
Complex(double r, double i) : real_(r), imag_(i) {}
double getReal() const { return real_; }
double getImag() const { return imag_; }
private:
double real_, imag_;
};
// ✅ non-member 运算符------两侧参数对称处理
const Complex operator*(const Complex& lhs, const Complex& rhs) {
return Complex(
lhs.getReal() * rhs.getReal() - lhs.getImag() * rhs.getImag(),
lhs.getReal() * rhs.getImag() + lhs.getImag() * rhs.getReal()
);
}
// ✅ non-member 输出运算符
std::ostream& operator<<(std::ostream& os, const Complex& c) {
return os << c.getReal() << " + " << c.getImag() << "i";
}
// ✅ non-member 输入运算符
std::istream& operator>>(std::istream& is, Complex& c) {
double r, i;
if (is >> r >> i) {
c = Complex(r, i);
}
return is;
}
5.2 工具函数命名空间
cpp
class Document {
public:
void save(const std::string& filename);
void load(const std::string& filename);
std::string getContent() const;
void setContent(const std::string& content);
size_t wordCount() const;
private:
std::string content_;
};
// ✅ 相关功能在命名空间中组织
namespace DocumentUtils {
// 便利操作
double calculateReadability(const Document& doc);
std::string generateSummary(const Document& doc, size_t maxLength);
// 批量操作
void batchProcess(std::vector<Document>& docs);
std::vector<std::string> extractKeywords(const Document& doc);
// 格式转换
std::string convertToHTML(const Document& doc);
std::string convertToMarkdown(const Document& doc);
}
// 使用示例
void processDocument(Document& doc) {
doc.save("original.txt");
auto readability = DocumentUtils::calculateReadability(doc);
auto summary = DocumentUtils::generateSummary(doc, 200);
auto html = DocumentUtils::convertToHTML(doc);
}
5.3 跨团队协作的模块化
cpp
// 核心团队维护------稳定接口
class DatabaseConnection {
public:
bool connect(const std::string& connectionString);
void disconnect();
QueryResult executeQuery(const std::string& query);
private:
// 实现细节...
};
// 工具团队开发------独立演进
namespace DatabaseUtilities {
class ConnectionPool {
public:
DatabaseConnection& getConnection();
void returnConnection(DatabaseConnection& conn);
};
class QueryBuilder {
public:
QueryBuilder& select(const std::string& columns);
QueryBuilder& from(const std::string& table);
QueryBuilder& where(const std::string& condition);
std::string build();
};
class PerformanceMonitor {
public:
void startQuery(const std::string& query);
void endQuery();
QueryStatistics getStatistics() const;
};
}
六、ADL:让 non-member 函数更易用
Argument-Dependent Lookup(ADL,又称 Koenig Lookup)让 non-member 函数的使用更加自然:
cpp
namespace MyLibrary {
class String {
public:
String(const char* str);
const char* c_str() const;
};
// ADL 会自动找到这个函数
std::ostream& operator<<(std::ostream& os, const String& str) {
return os << str.c_str();
}
}
// 使用 ADL------不需要限定命名空间
void useString() {
MyLibrary::String str = "Hello";
std::cout << str; // 自动找到 MyLibrary::operator<<
}
七、常见误区与澄清
| 误区 | 澄清 |
|---|---|
| "non-member 函数性能更差" | 编译器可以内联命名空间中的函数,性能与 member 函数相同 |
| "所有函数都应该变成 non-member" | 核心功能(必须访问私有成员)仍应是 member |
| "non-member 函数破坏面向对象" | 恰恰相反,它强化了封装------面向对象的核心 |
| "friend 函数也可以实现封装" | friend 能访问私有成员,封装性比 member 还差 |
什么时候用 member?
cpp
class MyClass {
public:
// ✅ 必须是 member:需要访问私有成员,且不是运算符
void internalOperation() {
// 直接操作 private 成员
}
// ✅ 必须是 member:赋值运算符
MyClass& operator=(const MyClass& other);
// ✅ 必须是 member:下标运算符
int& operator[](size_t index);
// ✅ 必须是 member:调用运算符
void operator()(int arg);
private:
int data_;
};
📌 C++ 语法规定 :
=,[],(),->运算符必须是 member 函数。
八、总结
核心原则
- 封装性最大化:优先选择无法访问私有成员的 non-member non-friend 函数
- 接口最小化:类的公有接口只包含核心功能
- 命名空间组织:使用命名空间将相关功能逻辑分组
- 机能扩充性:新增功能无需修改原有类
决策流程
设计一个新函数:
↓
能否只通过公有接口实现?
↓ 是
使用 non-member 函数,放入相关命名空间
↓ 否
使用 member 函数(或极少数情况下的 friend)
最终设计框架
cpp
// 核心类保持精简和稳定
class CoreComponent {
public:
void essentialOperation1();
void essentialOperation2();
State getCurrentState() const;
bool isValid() const;
private:
// 实现细节...
};
// 相关功能在命名空间中组织
namespace ComponentFeatures {
void complexOperation(CoreComponent& comp);
Result calculateDerivedValue(const CoreComponent& comp);
bool validateConfiguration(const CoreComponent& comp);
std::string generateReport(const CoreComponent& comp);
// 工厂函数
CoreComponent createFromFile(const std::string& filename);
}
// 扩展功能在子命名空间中
namespace ComponentFeatures::Advanced {
void advancedAnalysis(CoreComponent& comp);
}
📌 记住:优秀的软件设计不是将所有功能塞进类中,而是通过精心的职责分离创建清晰、可维护的架构。non-member non-friend 函数是最大化封装性的有力工具。
参考与延伸阅读
- 《Effective C++》第三版,Scott Meyers,条款23
- 《C++ Primer》第五版,关于命名空间和 ADL 的章节
- Sutter's Mill: GotW #84: Monoliths "Unstrung"
如果这篇文章对你有帮助,欢迎点赞 👍、收藏 ⭐、留言 💬!你的支持是我持续输出的动力!