1. 成员变量声明为Private
建议将成员变量声明为Private,然后再public中提供调用该数据的接口
设置成Private的原因分析
- 类内成员变量被声明为Private,那么就可以外部代码直接访问或者修改内部数据
- 通过公共接口获取内部数据,这样可以减少对外部代码的影响
- 直接将成员变量设置为public,可能会导致数据不一致或者逻辑错误,将成员变量设置为Private就可以避免外界随意修改数据,从而确保数据的完整性
将成员变量设置为public错误事例分析
外部代码可以随意修改变量的数值,与此同时还可以将其设置负数
#include <iostream>
#include <string>class Person {
public:std::string name; int age;
};int main() {Person p;p.name = "Alice";p.age = -5; std::cout << "名字:" << p.name << ",年龄:" << p.age << std::endl;return 0;
}
解决方法:成员变量声明为Private,同时提供公共接口
- 获取成员变量只可以通过getter方法访问
- 设置成员变量则是通过setAge,该方法内部会检查数据的有效性
#include <iostream>
#include <string>class Person {
public:Person(const std::string& name, int age) : name_(name) {setAge(age); // 使用 setter 进行初始化}// Getterstd::string getName() const { return name_; }int getAge() const { return age_; }// Settervoid setAge(int age) {if (age >= 0) { // 检查年龄有效性age_ = age;}else {std::cerr << "错误:年龄不能为负数" << std::endl;}}private:std::string name_;int age_;
};int main() {Person p("Alice", 25);std::cout << "名字:" << p.getName() << ",年龄:" << p.getAge() << std::endl;p.setAge(-5); // 试图设置无效年龄std::cout << "名字:" << p.getName() << ",年龄:" << p.getAge() << std::endl;return 0;
}
成员变量声明为Private的好处
还可以支持数据验证和日志记录功能,也就是通过接口访问成员变量的时候,可以添加验证逻辑或者日志逻辑,从而更好的实现数据管理;
成员变量设置为Private后,还可以延迟初始化,避免不必要的消耗
2. 优先使用非成员非友元函数
主要是为了减少类的复杂性,如果一个函数在实现功能的时候不需要访问类的私有成员,那么就可以将这个类设计成为非成员非友元函数
问题分析
例如在一个表示分数的类中,实现了*运算符重载,此时如果将*运算符重载成为成员函数,那么就会增加这个类的复杂性
#include <iostream>class Rational {
public:Rational(int numerator = 0, int denominator = 1): numerator_(numerator), denominator_(denominator) {}Rational operator*(const Rational& rhs) const {return Rational(numerator_ * rhs.numerator_, denominator_ * rhs.denominator_);}void print() const {std::cout << numerator_ << "/" << denominator_ << std::endl;}private:int numerator_;int denominator_;
};int main() {Rational a(1, 2);Rational b(3, 4);Rational result = a * b;result.print(); return 0;
}
解决方法:如果一个类并不需要访问私有成员(因为a,b的数值都是构造时就赋值了),此时就可以将其设计为非成员非友元函数
- operator*定义成非成员函数,然后声明为Rational的友元,这样就可以访问其私有成员
- 通过这样的方法简化结构的同时,还可以访问其成员
class Rational {
public:Rational(int numerator = 0, int denominator = 1): numerator_(numerator), denominator_(denominator) {}void print() const {std::cout << numerator_ << "/" << denominator_ << std::endl;}private:int numerator_;int denominator_;// 提供访问私有成员的友元声明friend Rational operator*(const Rational& lhs, const Rational& rhs);
};// 非成员运算符重载
Rational operator*(const Rational& lhs, const Rational& rhs) {return Rational(lhs.numerator_ * rhs.numerator_, lhs.denominator_ * rhs.denominator_);
}int main() {Rational a(1, 2);Rational b(3, 4);Rational result = a * b;result.print(); return 0;
}
适合使用该方法场景分析
- 不依赖对象内部状态的操作,例如只是数学计算、全局功能等
- 运算符重载
- 类中的一些辅助函数
总结反思
- 优先使用非成员非友元函数:如果一个函数不需要访问类的私有成员,将它设计为非成员非友元函数更好
- 减少类的复杂性:使用非成员函数可以减少类的职责和耦合度,使类的设计更清晰
- 运算符重载优先使用非成员实现:运算符重载时,优先考虑非成员实现,除非必须访问类的私有成员
- 提供友元函数访问私有成员:在非成员函数需要访问私有数据时,可以将其声明为友元函数
3. 如果所有参数都需要类型转换,使用非成员函数
C++中运算符重载时,可能会需要对操作数进行隐式类型转换,当所有操作数都需要进行类型转换的时候,最好选择非成员函数实现运算符重载,因为成员函数的运算符重载只会对右侧参数进行隐式类型转换,而非成员函数则允许对所有参数进行隐式类型转换。
成员函数运算符重载限制分析
成员函数实现运算符重载的时候,隐式类型转换只会应用右侧的参数,也就是说,左侧的参数必须和对象类型匹配,否则编译器不会进行隐式类型转换
- 下述代码编译的时候,编译器会尝试将2作为*左侧的操作数(this对象,也就是Rational类型)
- 但是2不是Rational类型,所以最终肯定会导致编译失败
#include <iostream>class Rational {
public:Rational(int numerator = 0, int denominator = 1): numerator_(numerator), denominator_(denominator) {}// 成员函数重载运算符*Rational operator*(const Rational& rhs) const {return Rational(numerator_ * rhs.numerator_, denominator_ * rhs.denominator_);}void print() const {std::cout << numerator_ << "/" << denominator_ << std::endl;}private:int numerator_;int denominator_;
};int main() {Rational r1(1, 2);Rational result = 2 * r1;result.print();return 0;
}
解决方法:使用非成员函数运算符重载
因为非成员函数可以允许对所有参数进行隐式类型转换的,这样就可以让编译器将2隐式转换为Rational类型,从而避免了成员函数的类型限制
#include <iostream>class Rational {
public:Rational(int numerator = 0, int denominator = 1): numerator_(numerator), denominator_(denominator) {}void print() const {std::cout << numerator_ << "/" << denominator_ << std::endl;}private:int numerator_;int denominator_;// 友元声明,允许非成员函数访问私有成员friend Rational operator*(const Rational& lhs, const Rational& rhs);
};// 非成员函数重载运算符*
Rational operator*(const Rational& lhs, const Rational& rhs) {return Rational(lhs.numerator_ * rhs.numerator_, lhs.denominator_ * rhs.denominator_);
}int main() {Rational r1(1, 2);Rational result = 2 * r1; // 允许隐式类型转换result.print(); return 0;
}
使用场景分析
- 非成员函数运算符重载
- 所有参数可能都需要进行隐式类型转换:例如
int * Rational
,这时非成员函数更灵活 - 对称的二元运算符:像
+
、-
、*
、/
等对称运算符,通常适合实现为非成员函数 - 允许访问私有成员:通过
friend
声明,可以让非成员函数访问类的私有数据成员,确保函数实现的灵活性
- 所有参数可能都需要进行隐式类型转换:例如
- 成员函数运算符重载
- 赋值相关运算符:如
=
、+=
、-=
、*=
、/=
,因为它们会改变左侧操作数的值,通常应实现为成员函数 - 单目运算符:如前置和后置
++
、--
、取地址&
、解引用*
等,通常适合实现为成员函数,因为它们通常只对一个操作数进行操作
- 赋值相关运算符:如
4. 实现一个不抛异常的swap函数
自我实现swap函数的原因
首先std库中提供的swap函数默认是通过拷贝构造函数和赋值运算符来实现,但是这些操作有可能会出现异常,其次主要目的就是减少性能开销,提高代码安全性
错误分析,使用默认swap交换一个有动态资源的类
- swap使用的是拷贝构造和赋值与赋值运算符,那么就很有可能触发内存分配和释放操作
- 代码中如果构造和赋值运算符抛出异常,那么swap就可能引发异常,最终导致w1和w2状态不一致
- 中断原因分析:widget类中name_是一个指针类型,当使用swap交换两个widget对象中的name_指针时,一个对象被销毁后,其会释放name_指针指向的内存,而另一个对象也会释放这块已经释放的内存,所以导致了双重释放,最终导致了异常中断
- 双重释放的根源在于,在没有定义深拷贝的时候,swap使用的是浅拷贝,所以只是简单的拷贝了其数值,所以就造成了多个对象持有相同的指针,所以最终在析构的时候就会重复释放同一块内存,最终引发异常
#include <iostream>
#include <string>
#include <algorithm> // for std::swapclass Widget {
public:Widget(const std::string& name) : name_(new std::string(name)) {}~Widget() { delete name_; }// 打印 name_void print() const {std::cout << "Widget 名称: " << *name_ << std::endl;}private:std::string* name_;
};int main() {Widget w1("Alice");Widget w2("Bob");std::swap(w1, w2); // 使用 std::swap,默认调用拷贝构造和赋值w1.print();w2.print();return 0;
}
解决方法:实现一个成员函数swap来直接交换数据成员,这样就可以有效避免不必要的拷贝和赋值
- 通过交换指针,实现两个对象动态资源的交换,这样就避免了浅拷贝的问题
- 通过非成员函数调用类中的swap函数,从而确保std::swap兼容
#include <iostream>
#include <string>
#include <utility> // for std::swapclass Widget {
public:Widget(const std::string& name) : name_(new std::string(name)) {}~Widget() { delete name_; }// 提供一个不抛异常的 swap 成员函数void swap(Widget& other) noexcept {std::swap(name_, other.name_); // 直接交换指针,不抛异常}// 打印 name_void print() const {std::cout << "Widget 名称: " << *name_ << std::endl;}private:std::string* name_;
};// 提供一个非成员的 swap 函数,以便与 std::swap 兼容
void swap(Widget& lhs, Widget& rhs) noexcept {lhs.swap(rhs); // 使用 Widget 的成员 swap
}int main() {Widget w1("Alice");Widget w2("Bob");swap(w1, w2); // 使用自定义 swap 函数w1.print();w2.print();return 0;
}
总结反思
- 优先自己定义一个没有异常的swap函数,同时通过noexcept声明来确保swap函数不会抛出异常
- 实现一个非成员swap函数,也就是通过调用成员swap函数,从而确保与std库中的swap函数兼容,使得可以无缝衔接到自己类函数中
5. 尽量延后变量定义的时间
减少性能开销,当定义个类对象的时候,构造函数会立即调用,有可能会涉及到资源分配,所以适当的将变量定义延后,可以避免不必要的初始化
错误分析
例如代码中,即使最终a不是大于b的,变量Result和temp还是被创建了,这就浪费了内存空间
#include <iostream>
#include <string>int main() {int a = 5;int b = 10;int result = 0; // 提前定义变量std::string temp = "未使用的字符串"; // 提前定义变量if (a > b) {result = a + b;}std::cout << "Result: " << result << std::endl;return 0;
}
解决方法:延后变量的定
也就是说,只有在变量Result和temp需要的时候才被定义,这样就可以避免不必要的内存分配
int main() {int a = 5;int b = 10;if (a > b) {int result = a + b; // 延后定义变量std::cout << "Result: " << result << std::endl;}if (a == 5) {std::string temp = "延迟定义的字符串"; // 延后定义变量std::cout << "Temp: " << temp << std::endl;}return 0;
}
总结反思
- 尽量延后变量的定义,减少性能损耗
- 变量定义在首次使用的地方,可以增强代码可读性
6. 尽量少进行转换
转换增加的劣势
- 频繁的类型转换会增加代码的复杂程度,使得代码难以理解和维护
- 运行时的类型转换和多层次的隐式类型转换会损耗性能,最终会拖慢程序的运行速度
隐式类型转换问题分析
- 代码中如果将double转换成int类型,那么double后面的小数点数据就会丢失
#include <iostream>void printDouble(double value) {std::cout << "Double 值: " << value << std::endl;
}int main() {int intValue = 10;printDouble(intValue); // 隐式转换 int 到 doubledouble doubleValue = 5.99;int truncatedValue = doubleValue; // 隐式转换 double 到 intstd::cout << "截断后的 int 值: " << truncatedValue << std::endl;return 0;
}
方法:使用显式类型转换,明确自己想要转换成何种类型
- 例如可以使用static_cast表明转换意图
#include <iostream>int main() {double doubleValue = 5.99;int truncatedValue = static_cast<int>(doubleValue); std::cout << "截断后的 int 值: " << truncatedValue << std::endl;return 0;
}
技巧:要避免将基础类型转换为自定义类型的行为
- 使用explicit关键字避免构造函数进行隐式类型转换,也就是只有在显式声明的时候才可以将double转换为complex对象
- 隐式类型转换分析
- Complex c2 = 3.0:double到complex的隐式类型转换
- 首先explicit关键字的作用就是在一个类中接受单个参数的构造函数,编译器允许使用该函数进行隐式类型转换,所以在这个类中c1对象可以通过传入单个参数完成构造
- c2报错的原因在于编译器需要从double隐式转换到complex,但是此时构造函数被标记为explicit,所以造成了编译器报错,阻止了这种隐式转换的发生
#include <iostream>class Complex {
public:Complex(double real, double imaginary) : real_(real), imaginary_(imaginary) {}// 避免隐式转换的构造函数explicit Complex(double real) : real_(real), imaginary_(0) {}void print() const {std::cout << "Complex 数: " << real_ << " + " << imaginary_ << "i" << std::endl;}private:double real_;double imaginary_;
};int main() {Complex c1(3.0); // 合法// Complex c2 = 3.0; // 编译错误,防止隐式转换c1.print();return 0;
}
总结反思
- 代码中减少隐式类型转换的使用,从而避免隐式类型转换而导致的错误
- 多使用显示类型转换,例如可以使用static_cast或者dynamic_cast等,以确保代码意图明确
- 使用explicit关键字,防止构造函数的隐式类型转换
7. 避免返回对象内部的指针或者引用
直接返回对象的内部指针或者引用会使得对象的实现细节暴露,这样就会导致数据不安全和未定义的行为,特别是当返回指针或者引用是一个局部变量的时候,如果无意中修改了类中的数据,这样就可能会导致问题。
错误分析:返回对象的内部指针或者引用
- getName函数返回了name_指针,暴露了Person类中的细节
- 外部可以通过namePtr直接访问或者间接修改Person类的私有数据,最终破坏其封装性
#include <iostream>
#include <string>class Person {
public:Person(const std::string& name) : name_(name) {}// 返回内部指针(不安全)const std::string* getName() const {return &name_;}private:std::string name_;
};int main() {Person p("Alice");const std::string* namePtr = p.getName();std::cout << *namePtr << std::endl; // 输出:Alice// 非法操作:尽管是 const,外部代码可以修改底层数据或产生悬空指针风险return 0;
}
解决方法:返回name数据的副本,保证不对原始对象的影响
#include <iostream>
#include <string>class Person {
public:Person(const std::string& name) : name_(name) {}// 返回副本std::string getName() const {return name_;}private:std::string name_;
};int main() {Person p("Alice");std::string name = p.getName();std::cout << name << std::endl; // 修改 name 的副本不会影响 Person 对象的 name_name = "Bob";std::cout << p.getName() << std::endl; return 0;
}
返回指针或者引用保证安全方法:使用const限制修改
通过返回数据引用,从而使得const从而确保调用者无法修改返回的内容
#include <iostream>
#include <string>class Person {
public:Person(const std::string& name) : name_(name) {}// 返回 const 引用,防止修改const std::string& getName() const {return name_;}private:std::string name_;
};int main() {Person p("Alice");const std::string& nameRef = p.getName();std::cout << nameRef << std::endl; nameRef = "Bob"; return 0;
}
使用智能指针安全的返回指针和引用
针对于动态资源分配,使用智能指针可以更好的管理资源分配
- 通过智能指针管理动态分配的资源,同时通过const限制修改权限
#include <iostream>
#include <memory>
#include <string>class Person {
public:Person(const std::string& name) : name_(std::make_shared<std::string>(name)) {}// 返回智能指针std::shared_ptr<const std::string> getName() const {return name_;}private:std::shared_ptr<const std::string> name_;
};int main() {Person p("Alice");std::shared_ptr<const std::string> namePtr = p.getName();std::cout << *namePtr << std::endl; // 输出:Alice// *namePtr = "Bob"; // 编译错误,无法修改return 0;
}
总结反思
- 要避免返回内部数据的指针或引用,尽量选择返回数据的副本,从而保证类的封装性和安全性
- 如果必须返回引用或者指针,那么使用const限制访问权限,防止其调用修改内部数据
- 如果是动态资源,需要分配内存空间,那么优先使用智能指针对资源进行管理,从而避免内存泄漏或者悬空指针的情况
8. 为“异常安全”努力
核心意思就是实现在抛出异常的时候保证程序运行安全,也就是在抛出异常的时候要确保状态的完整性和一致性
异常安全的三种保护标准
- 基本保证:也就是即使发生异常,程序不会发生资源泄漏或者数据不会发生资源泄漏或者数据损坏,即使程序在异常发生后不能恢复正常操作
- 强烈保证:要求程序在异常后可以退回到异常发生前的状态,也就是操作要么成功要么完全失败,类似于数据库中的原子性
- 不抛出异常安全保证:承诺某个函数绝对不会抛出异常,也是最强的安全保证,可以通过noexcept关键字声明不抛出异常的函数
方法1:使用RAII
通过RAII机制,保证即使发生异常的时候,也可以保证资源正常释放
#include <iostream>
#include <memory>
#include <string>class Person {
public:Person(const std::string& name) : name_(std::make_unique<std::string>(name)){}void printName() const {std::cout << "姓名: " << *name_ << std::endl;}private:std::unique_ptr<std::string> name_;
};int main() {try {Person p("Alice");p.printName();}catch (...) {std::cout << "异常发生" << std::endl;}return 0;
}
方法2:使用noexcept保证不抛出异常
利用该关键字声明一个函数不会抛出异常,通过noexcept提高代码的稳定性和性能
- swap函数使用noexcept声明不抛出异常,从而保证swap操作异常的安全性
#include <iostream>
#include <utility>
#include <vector>class Widget {
public:Widget(int value) : value_(value) {}void swap(Widget& other) noexcept {std::swap(value_, other.value_);}private:int value_;
};int main() {Widget w1(1);Widget w2(2);w1.swap(w2); // 不抛出异常的 swap 操作return 0;
}
方法3:使用事务式编程
也就是进行操作之前,先创建一个副本或者备份,目的就是确保操作成功后替换原始对象,这样即使操作过程中发生异常的时候,程序依然可以恢复原状
class Transaction {
public:Transaction(const std::string& data) : data_(data) {}void setData(const std::string& newData) {// 先创建备份,保证操作失败时能够恢复std::string backup = data_;// 假设此操作可能抛出异常processData(newData);// 更新成功才替换原数据data_ = newData;}void printData() const {std::cout << "数据: " << data_ << std::endl;}private:void processData(const std::string& newData) {if (newData.empty()) throw std::runtime_error("无效的数据");}std::string data_;
};int main() {Transaction transaction("初始数据");try {transaction.setData("");}catch (const std::exception& e) {std::cout << "异常: " << e.what() << std::endl;}transaction.printData(); // 输出:新数据或初始数据return 0;
}
总结反思
- 优先使用智能指针管理动态内存资源,从而减少资源泄漏的风险
- 优先使用noexcept,可以避免其抛出异常
- 要谨慎操作状态修改,使用备份或者事务机制,从而确保即使发生异常也可以回滚
9. 理解Inline函数的利弊
内联函数就是将函数代码直接嵌入到调用点,从而避免函数调用的开销,编译器会在调用点替换函数调用为函数体代码,这样就减少了调用堆栈的实践。
内联函数的优点分析
- 减少函数调用时的开销
- 因为调用函数的时候,程序会进行一系列的操作,例如压栈、跳转到函数地址、返回值处理等,这些都会增加函数的开销
- 提高性能
- 对于一些小型、频繁调用的函数使用内联函数可以显著的提高性能
class Rectangle {
public:Rectangle(int width, int height) : width_(width), height_(height) {}// 内联 getter 函数inline int getWidth() const { return width_; }inline int getHeight() const { return height_; }private:int width_;int height_;
};int main() {Rectangle rect(10, 20);int area = rect.getWidth() * rect.getHeight(); // 内联后无调用开销return 0;
}
内联函数的缺点
- 增加代码体积
- 内联函数会显著增加代码的体积,也就会导致指令缓存压力增加,最终降低性能
- 编译时间加长
- 内联增加了编译器的负担,特别是当函数定义在头文件中时,每次编译都会将函数体嵌入每个调用点,导致编译时间变长,且链接时间也可能增加
- 内联函数多不方便进行调试
内联函数适用的场景
小型而且简单函数,因为这样的函数逻辑简单,编译器更好对其进行优化;对于一些执行频繁而且对性能要求高的小函数,内联函数也可以显著的提高其性能。
不适合内联函数分析
- 复杂大型的函数
- 递归函数
- 因为递归的调用次数在编译期间是未知的,递归的内联会导致代码膨胀
- 虚函数
- 调用的时候需要通过虚函数表进行解析,编译器通常无法再编译期间确定调用的具体函数
总结反思
- 小函数适合使用内联,相反大且复杂的函数不适合内联
- 递归和虚函数不适合内联,因为递归次数是不确定,且虚函数通常需要通过表去查找,内联通常会被编译器忽略
- 编译器自动优化,编译器会自动识别哪些函数适合内联
10. 将文件间的编译依赖降到最低
文件之间编译过度依赖代价
- 增加编译时间
- 因为每次修改头文件,或者所有直接或间接依赖该头文件的文件都需要重新编译,这可能导致整个项目的编译时间显著增加。随着项目规模增长,频繁的头文件依赖会拖慢编译速度
- 耦合度增加
- 修改一个文件,会影响其他所有与之相关的文件
- 代码难以维护
方法1:使用前置声明
头文件中尽量使用前置声明,而不是选择包含完整的头文件,使用前置声明一个类的存在,而不需要包含其完整定义,从而减少头文件之间的依赖关系
- 前置声明适用于指针或者引用成员变量的声明
// Employee.h
#include <string>class Department; // 前向声明 Department 类class Employee {
public:Employee(const std::string& name);void setDepartment(Department* dept); // 使用前向声明的指针
private:std::string name_;Department* department_;
};
// Department.h
#include <string>class Department {
public:Department(const std::string& name);
private:std::string name_;
};
方法2:使用#include在源文件中实现依赖
也就是说将头文件的依赖放在源文件中,而不是头文件中,这样就可以减少头文件的包含。只有在类的成员函数需要某个类的定义时,才在.cpp文件中使用#include所需头文件即可
// Employee.h (头文件中只放前置声明)
#include <string>class Department; // 使用前向声明class Employee {
public:Employee(const std::string& name);void setDepartment(Department* dept);
private:std::string name_;Department* department_;
};
// Employee.cpp
#include "Employee.h"
#include "Department.h" // 只在 .cpp 文件中包含Employee::Employee(const std::string& name) : name_(name), department_(nullptr) {}void Employee::setDepartment(Department* dept) {department_ = dept;
}
方法3:pimpl方法
在类中使用指针指向该类的方法,将类的实现细节隐藏在源文件中,从而降低头文件的依赖关系;简单可以理解成调用的时候,直接通知封装好的指针
// Widget.h
#include <memory>
class WidgetImpl; // 前向声明 WidgetImpl 类class Widget {
public:Widget();~Widget();void doSomething();private:std::unique_ptr<WidgetImpl> pImpl; // Pimpl 指针
};
// Widget.cpp
#include "Widget.h"
#include "WidgetImpl.h" // 只有在实现文件中包含实现类Widget::Widget() : pImpl(std::make_unique<WidgetImpl>()) {}Widget::~Widget() = default;void Widget::doSomething() {pImpl->performTask();
}
总结反思
- 头文件使用前向声明,减少对编译的依赖
- 尽量在.cpp文件中使用#include,而不是头文件
- pimpl收发,通过指针调用,隐藏细节