《Effective C++》——设计与声明(item 18 ~ item 25)

91 阅读16分钟

Item 18:让接口容易被使用,不易被误用

  • ① “好的接口很容易被正确使用,不易被误用。你应该在你的所有接口中努力达成这些性质。”

  • ② “ ‘促进正确使用’ 的办法包括接口的一致性,以及与内置类型的行为兼容。”

  • “ ‘阻止误用’ 的办法包括建立新类型限制类型上的操作束缚对象值,以及消除客户的资源管理责任。”

    建立新类型:

    假设为一个用来表现日期的 class 设计构造函数:

    class Date{
    public:
        Date(int month, int day, int year);
    };
    

    客户很容易犯下如下错误:

    • 错误次序传参:Date d(30, 3, 1995);
    • 无效月份或天数:Date d(2, 30, 1995);

    解决办法:导入外覆类型来区别天数、月份和年份,然后在 Date 构造函数中使用这些类型。

    struct Day{
        explicit Day(int d) : val(d) {}
        int val;
    };
    
    struct Month{
        explicit Month(int m) : val(m) {}
        int val;
    };
    
    struct Year{
        explicit Year(int y) : val(y) {}
        int val;
    };
    
    class Date{
    public:
        Date(const Month& m, const Day& d, const Year& y);
        ...
    };
    
    Date d(30, 3, 1995);    // 错误!不正确的类型
    Date d(day(30), Month(3), Year(1995));    // 错误!不正确的类型
    Date d(Month(3), Day(30), Year(1995));    // OK,类型正确
    

    限制类型上的操作:

    考虑有理数的 operator* 声明式:

    class Rational {...}
    const Rational operator* (const Rational& lhs, const Rational& rhs);
    
    Rational a, b, c;
    
    if(a * b = c) ...    // 其实是 if(a * b == c)
    

    许多程序员第一次看到这个声明时会想:为什么要返回一个 const 对象?

    因为程序员可能会出现上述错误:将 == 写成 =。一个良好的用户自定义类型的特征是它们避免无端地与内置类型不兼容,所以允许对两值乘积做赋值动作也就没什么意思了。将 operator* 的返回值声明为 const 可以预防那个 “没意思的赋值动作”。

  • “shared_ptr 支持定制型删除器(custom deleter)。这可防范 DLL 问题,可被用来自动解除互斥锁。”

    DLL 问题:

    对象在动态连接程序库(DLL)中被 new 创建,却在另一个 DLL 内被 delete 销毁。

    这一类跨 DLL 之 new/delete 成对运用会导致运行期间错误。

Item 19:设计 class 犹如设计 type

  • “Class 的设计就是 type 的设计。在定义一个新 type 之前,确定你已经考虑过下面的主题:”

    1. 新类型的对象应该如何被创建和销毁?

      • 理解:对象的创建和销毁是类型生命周期的核心。你需要明确如何分配和释放资源,是否需要复杂的初始化逻辑(构造函数)、清理工作(析构函数),以及是否需要支持对象的动态创建(通过工厂方法或其他方式)。
      • 考虑:例如,是否应该提供默认构造函数?是否需要析构函数来管理资源(如内存、文件句柄等)?如果类型涉及到资源管理,是否应该使用 RAII 模式?
    2. 对象的初始化和对象的赋值该有什么样的差别?

      • 理解:初始化和赋值是两种不同的操作。初始化是创建新对象时对其进行设置,赋值是将一个已存在的对象的值改变为另一个对象的值。你需要考虑是否有深浅拷贝问题,赋值操作是否需要特别的处理。
      • 考虑:例如,是否需要显式的拷贝构造函数和赋值运算符?是否需要区分不同的赋值方式,如浅拷贝或深拷贝?
    3. 新类型的对象如果被 pass-by-value,意味着什么?

      • 理解:传递对象时,按值传递会复制对象的内容,因此你需要考虑复制的代价和影响。如果对象包含大量数据或外部资源,按值传递可能会非常昂贵。
      • 考虑:是否应该禁止按值传递,改用引用或指针传递?如果允许按值传递,是否提供了有效的拷贝构造函数?
    4. 什么是新类型的“合法值”?

      • 理解:类型的“合法值”是指对象处于有效状态。你需要确定哪些值或状态对于这个类型是有效的,以及如何确保对象在整个生命周期内都保持有效状态。
      • 考虑:是否需要在构造函数中确保初始状态的合法性?是否需要检查每个成员变量的有效性?如何避免不合法的状态?
    5. 你的新类型需要配合某个继承图系吗?

      • 理解:如果你的类型需要参与类的继承结构,应该明确它的位置。是作为基类还是派生类?它是否需要支持多态行为(虚函数)?
      • 考虑:是否需要设计虚析构函数以确保多态安全?是否应考虑接口的设计以适应继承结构?
    6. 你的新类型需要什么样的转换?

      • 理解:类型之间的转换是类型设计中不可忽视的部分。你需要明确新类型是否需要与其他类型进行转换,如果需要,应该是隐式的还是显式的。
      • 考虑:是否应该重载转换运算符?哪些转换是有意义的?是否需要使用 explicit 关键字防止隐式转换导致的误用?
    7. 什么样的操作符和函数对此新类型而言是合理的?

      • 理解:某些操作符或函数可能对某些类型特别有用。你需要考虑哪些操作符应该为新类型重载,哪些操作是合理的,以及这些操作的含义。
      • 考虑:例如,是否需要重载加法、减法、赋值等操作符?是否有其他合理的操作需要定义(如比较运算符)?
    8. 什么样的标准函数应该驳回?

      • 理解:某些标准函数或操作符在新类型中可能没有意义,你需要明确哪些应该被禁用(如通过删除 delete 关键字或私有化构造函数来禁止某些操作)。
      • 考虑:例如,是否应该禁止拷贝构造函数或赋值运算符?是否应禁止默认构造函数?
    9. 谁该取用新类型的成员?

      • 理解:类型的成员访问控制(如 public、protected、private)是面向对象设计的关键部分。你需要决定哪些成员可以被外部访问,哪些成员应该只对内部或子类可见。
      • 考虑:哪些成员应该是公开的,哪些应该是私有的?是否需要提供 getter/setter 方法来访问成员变量?
    10. 什么是新类型的“未声明接口”?

      • 理解:未声明的接口指的是用户可能期望该类型支持的隐含操作。即使你没有明确声明某些接口,用户可能会假定它们存在,例如拷贝构造函数、赋值运算符等。
      • 考虑:确保类型的默认行为符合用户预期,或者明确禁用某些默认行为。如果类需要禁用拷贝或赋值行为,要通过 =delete 明确声明。
    11. 你的新类型有多么一般化?

      • 理解:类型设计中,你需要权衡通用性和专用性。如果类型过于通用,可能会增加复杂度;如果过于专用,可能会限制其应用场景。
      • 考虑:是否需要模板类来实现泛型?是否应该将类型设计为更通用的抽象类?
    12. 你真的需要一个新类型吗?

      • 理解:在创建新类型之前,你需要反思是否真的需要一个新类型。过度设计会导致复杂性增加,而现有的类型是否能满足需求。
      • 考虑:是否可以通过组合已有类型来实现需求?是否有其他标准库类型能满足需求?

Item 20:宁以 pass-by-refeence-to-const 替换 pass-by-value

  • “尽量以 pass-by-refeence-to-const 替换 pass-by-value ,前者通常比较高效,并可避免切割问题。”
  • “以上规则并不适用于内置类型,以及 STL 的迭代器和函数对象。对它们而言, pass-by-value 往往比较适当。”

Item 21:必须返回对象时,别妄想返回其 erference

  • “绝不要返回 pointer 或 reference 指向一个 local stack 对象。”

    • 局部栈对象的生命周期仅限于函数或代码块的作用域。一旦函数结束,局部栈上的对象就会被销毁。如果返回指向局部栈对象的指针或引用,调用者会持有指向已被销毁的对象的指针或引用,导致 未定义行为。访问这些对象可能会导致程序崩溃或出现难以调试的错误。
  • “绝不要返回 reference 指向一个 heap-allocated 对象。”

    • 堆(heap)上的对象没有固定的生命周期,通常需要手动管理其内存分配和释放。如果返回指向堆对象的引用,可能会让调用者误以为该对象在栈上或者由函数管理,从而忽视释放资源的责任,导致 内存泄漏重复释放 的问题
    int& getHeapValue() {
        int* p = new int(10);
        return *p; // 错误,返回指向堆对象的引用
    }
    

    虽然这个示例在技术上是可以编译和运行的,但调用者不知道该引用指向的对象是在堆上分配的,因此不会释放内存,导致 内存泄漏

  • “绝不要返回 pointer 或 reference 指向一个 local static 对象而有可能同时需要多个这样的对象。”

    • static 关键字使得局部静态对象在第一次被创建后在整个程序生命周期内都存在。然而,由于它们是静态的,全局共享同一个对象,所以多个调用者同时访问该对象时会出现资源竞争问题,尤其是在多线程环境中,可能导致 数据竞争不一致性
    • 下面的情况不会得到想要的结果
    const Rational& operator*(const Rational& lhs, const Rational& rhs)
    {
        static Rational result;
        // 将 lhs 乘以 rhs,并将结果置于 result 内
        result = ...;
        return result;
    }
    
    bool operator==(const Rational& lhs, const Rational& rhs);
    
    Rational a, b, c, d;
    ...
    if((a * b) == (c * d))
    {
    ...
    }
    else
    {
    ...
    }
    

    (a * b) == (c * d) 总是被核算为 true

Item 22:将成员变量声明为 private

  • “切记将成员变量声明为 private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供 class 作者以充分的实现弹性。”

  • “protected 并不比 public 更具封装性。”

Item 23:宁以 non-member、non-friend 替换 member 函数

  • 宁可拿 non-member non-friend 函数替换 member 函数。这样做可以增加封装性、包裹弹性(packing flexibility)和机能扩充熊。
    • 成员函数(member function)与类有紧密的耦合,非成员函数(non-member function)则降低了这种耦合性。当一个函数不需要访问类的内部实现或私有数据,它最好被设计为一个非成员函数,而不是成员函数。
    • 友元函数(friend function)可以访问类的私有成员,但它的引入会增加类的耦合度。友元函数虽然不是类成员,但由于能访问类的私有成员,往往与类的内部实现绑定。因此,友元函数也应尽量避免,除非确实需要访问私有数据。
    • 成员函数与类之间有很强的责任关联,成员函数的职责是操作类的内部状态。如果一个函数不改变或不访问类的状态,那么它不应作为成员函数存在。这样符合单一责任原则,保持类与函数职责清晰分离。

示例:

成员函数实现方式:

class Point {
public:
    Point(int x, int y) : x_(x), y_(y) {}
    
    // 成员函数,用于计算两点之间的距离
    double distance(const Point& other) const {
        int dx = x_ - other.x_;
        int dy = y_ - other.y_;
        return std::sqrt(dx * dx + dy * dy);
    }

private:
    int x_, y_;
};

non-member 函数实现方式:

class Point {
public:
    Point(int x, int y) : x_(x), y_(y) {}
    
    // 公有的访问接口
    int getX() const { return x_; }
    int getY() const { return y_; }

private:
    int x_, y_;
};

// 非成员函数,用于计算两点之间的距离
double distance(const Point& p1, const Point& p2) {
    int dx = p1.getX() - p2.getX();
    int dy = p1.getY() - p2.getY();
    return std::sqrt(dx * dx + dy * dy);
}
  • 在第一个实现中,distancePoint 类的成员函数,它与类的耦合性很强。

  • 在第二个实现中,distance 是一个非成员函数,通过类的公有接口访问对象的坐标值。这种方式保持了类的封装性,同时函数可以更加灵活地使用。

Item 24:若所有参数皆需类型转换,请为此采用 non-member 函数

  • “如果你需要为某个函数的所有参数(包括被 this 指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个 non-member。”

    观察下面的例子:

    class Rational {
    public:
            Rational(int numerator = 0, int denominator = 1);
            int numerator() const;
            int denominator() const;
    
            const Rational operator*(const Rational& rhs) const;
    
    private:
            ...
    };
    
    
    Rational oneHalf(1, 2);
    Rational oneEight(1, 8);
    // 正确
    Rational result = oneHalf * oneEight;
    Rational result = oneHalf * 2;
    
    // 错误
    Rational result = 2 * oneHalf;
    
    • 为什么 Rational result = oneHalf * 2; 可以编译通过? 这条语句的调用有下列过程:

      1. oneHalf 是一个 Rational 对象

      假设 oneHalf 是用如下方式创建的:

      Rational oneHalf(1, 2);  // oneHalf 表示 1/2
      
      1. 2 是一个整数

      整数 2 的类型是 int,但 oneHalf 是一个 Rational 对象,因此无法直接执行乘法运算。C++ 中需要有某种机制让 intRational 类型能够一起进行操作。

      1. 调用 Rational 类的 operator*

      Rational 类中定义了乘法运算符:

      const Rational operator*(const Rational& rhs) const;
      

      这里的 operator* 函数期望的参数是一个 Rational 类型,因此在 oneHalf * 2 中,编译器必须首先将 2 转换为 Rational 类型。

      隐式转换

      为了使 2 能够参与运算,编译器将使用 Rational 的构造函数将 2 隐式转换为 Rational(2, 1),即 2/1

      1. 实际的乘法运算

      一旦 2 被转换为 Rational(2, 1),代码等同于:

      Rational result = oneHalf * Rational(2, 1);
      
    • 为什么 Rational result = 2 * oneHalf; 无法编译通过?

      1. 缺少合适的运算符重载

      在 C++ 中,运算符重载是由对象的类型决定的。也就是说,2 * oneHalf 必须能够调用一个适用于 int 左操作数和 Rational 右操作数的 operator*

      但在当前的 Rational 类定义中,运算符重载 operator* 是作为 Rational 类的成员函数实现的:

      const Rational operator*(const Rational& rhs) const;
      

      这个 operator* 函数的左操作数(*this)必须是 Rational 对象。也就是说,这个运算符只能处理 Rational * Rational 的情况,无法处理 int * Rational 的情况。

      1. 编译器如何解析运算符重载

      当编译器遇到 2 * oneHalf 这样的表达式时,它会首先尝试在 int 类型上查找 operator*。但 int 类型是内置类型,无法定义自定义的成员函数或运算符重载。因此,编译器无法在 int 类型中找到适用于 int * Rational 的运算符重载。

      接着,编译器会考虑是否可以对右操作数 oneHalf 进行类型转换,以便匹配左操作数的类型。然而,C++ 的类型转换规则并不会自动将 Rational 对象转换为 int,因此这种尝试也失败了。

      最终,编译器没有找到任何合适的运算符重载来处理这个表达式,导致编译错误。

    解决办法:让 operator* 成为一个 non-member 函数

    class Rational {
    public:
            Rational(int numerator = 0, int denominator = 1);
            int numerator() const;
            int denominator() const;
    
            // const Rational operator*(const Rational& rhs) const;
    
    private:
            ...
    };
    
    const Rational operator*(const Rational& lhs, const Rational& rhs)
    {
            return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denomintor());
    }
    
    
    Rational oneHalf(1, 2);
    Rational oneEight(1, 8);
    // 正确
    Rational result = oneHalf * oneEight;
    Rational result = oneHalf * 2;
    Rational result = 2 * oneHalf;
    

Item 25:考虑写出一个不抛异常的 swap 函数

  • “当 std::swap 对你的类型效率不高时,提供一个 swap 成员函数,并确定这个函数不抛出异常。”

    std::swap 是一个通用的交换函数,它依赖于复制构造函数和赋值运算符来交换两个对象。对于一些复杂的类型,使用 std::swap 可能会导致性能低下,因为它需要创建临时对象并进行深度复制。如果自定义类型的交换操作能够通过简单的指针或引用操作来实现(例如交换资源的所有权),那么提供一个高效的成员函数 swap 会显著提升性能。并且,保证 swap 不抛出异常是为了确保在异常安全的情况下使用。

    class WidgetImpl {
    public:
    	...
    private:
    	int, a, b, c;
    	std::vector<double> v;
    	...
    };
    
    
    class Widget {
    public:
    	Widget(const Widget& rhs);
    	Widget& operator=(const Widget& rhs)
    	{
    		...
    		*pImpl = *(rhs.pImpl);
    		...
    	}
    	...
    private:
    	WidgetImpl* pImpl;
    };
    

    如果调用 std::swap 交换两个 Widget 对象值,它不仅会复制三个 Widgets,还复制三个 WidgetImpl 对象,非常缺乏效率。事实上,我们只需要交换其 pImpl 指针就行。

  • “如果你提供一个 member swap,也该提供一个 non-member swap 来调用前者,对于 classes(而非 templates),也请特化 std::swap。”

    • 成员 swap 函数:是类的成员函数,用来交换两个对象的状态。

    • 非成员 swap 函数:这是全局函数,通常是在类外部定义,并调用成员 swap。通过提供非成员 swap 函数,可以更好地与标准库进行集成,比如支持 std::swap 调用时自动使用自定义的 swap

    • 特化 std::swap:为了让 std::swap 使用你的自定义 swap 函数,你可以特化 std::swap。特化可以使 std::swap 对你的类型进行优化,提升性能。

  • “调用 swap 时应针对 std::swap 使用 using 声明式,然后调用 swap 并且不带任何 ‘命名空间资格修饰’。

    在调用 swap 时,推荐使用 using std::swap; 来确保当前命名空间的 swap 函数优先被调用。这种方式允许调用者在没有命名空间修饰的情况下调用 swap,如果有自定义的 swap 实现,编译器会优先调用自定义的 swap,否则会使用 std::swap。例如:

    using std::swap;
    swap(a, b);  // 首先寻找 a 和 b 类型的自定义 swap,然后回退到 std::swap
    
  • “为 ‘用户定义类型’ 进行 std templates 全特化是好的,但千万不要尝试在 std 内加入某些对 std 而言全新的东西。”

    特化标准库模板(如 std::swap)是可以接受的做法,尤其是当你想针对某个类型优化标准模板的行为时。例如,你可以为你的类型提供一个 std::swap 特化。但是,绝对不要尝试向 std 命名空间中添加全新的内容。C++ 标准明确规定 std 命名空间中的内容只能由标准库维护和修改,任何违反此规定的代码都是不安全的,并且可能导致不可预知的行为。