C++的55个条款——设计与声明

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/fancynece/article/details/79740724

设计与声明

本章主要讲解 C++接口的良好设计与声明


条款18:让接口容易被正确使用,不易被误用

①想要开发一个“容易被正确使用,不易被误用”的接口,首先必须考虑客户可能做出什么样的错误

比如我们为一个表现日期的类设计一个构造函数。

class Date
{
public:
    Date(int d,int m,int y):day(d),month(m),year(y){}
private:
    int day, month, year;
};

Date d(30,4,1997);    //正确
Date d(30,2,1997);    //错误,2月没有30天,但是不会报错
Date d(2,15,1997);    //错误,没有15个月,但是不会报错

导入新类型确定每个参数的类型正确, 对于实现接口不被误用很有效果。

//导入新类型Day,Month,Year预防接口被误用

class Day{
public:
    explicit Day(int d):day(d){}
private:
    int day;
};

class Month{
public:
    explicit Month(int m):month(m){}
private:
    int month;
};

class Year{
public:
    explicit Year(int y):year(y){}
private:
    int year;
};

class Date{
public:
    Date(const Day& d,const Month& m,const Year& y);
};

Date d(30,4,1997);      //错误
Date d(Month(3),Day(30),Year(2000));   //错误,类型错误
Date d(Day(30),Month(3),Year(2000));   //正确

② 想要开发一个“容易被正确使用,不易被误用”的接口,其次应该考虑的是 限制类型内什么事可做,什么事不可做

常见的限制是加上const,例如我们前面提到过的*运算符返回一个const,以预防if(a * b = c)的代码书写错误。

③ 想要开发一个“容易被正确使用,不易被误用”的接口,还应该 尽量令你类型的行为与内置类型一致

没有什么 比一致性 使得 接口容易被正确使用,没有什么比 不一致性 使得 接口容易被误用。例如C++的STL,对于任何容器而言,要想获取容器内元素个数的大小,都使用size()函数,而java就不同。

④ 想要开发一个“容易被正确使用,不易被误用”的接口,还应该 不要让用户管理资源

在前面我们已经学习了使用资源管理类来管理资源,其中shared_ptr是最为常用的资源管理类,它比原始指针大且慢,而且使用辅助动态内存。在很多程序中这些额外的执行成本并不显著,然而其降低客户错误的成效却是每个人都看得到。

总结:

  • “促进正确使用”的方法:接口的一致性,与内置类型行为尽量一致
  • “防止误用”的方法:引入新类型确保类型正确,使用const限定行为,不让客户管理资源
  • shared_ptr支持定制删除器,可以防止DLL问题。

条款19:设计class犹如设计type

定义一个新的class,就是定义了一个新的type。重载函数和操作符、控制内存的分配和归还、定义对象的初始化和终结……一个都不能少。

如何设计高效的class呢?我们来思考以下问题。

  • 新type的对象应该如何被创建和销毁?这会影响到构造函数、析构函数、内存管理函数的实现。
  • 对象的初始化和对象的赋值应该有什么样的差别?
  • 新type对象如果被以值传递,会发生什么?这会影响到拷贝构造函数的实现。
  • 什么是新type的合法值?这会影响你的函数(尤其是构造函数、赋值运算符)需要做什么样的检查工作。
  • 新type需要配合某个继承体系吗?若继承自其它类,则会受到基类的束缚,特别是关于是不是虚函数的束缚。若它允许被继承,则要将某些函数设为虚函数。
  • 什么样的函数和操作符对新type而言是合理的?这会影响到你要定义什么样的接口,这些接口是类内成员还是非类内成员。
  • 新type需要什么样的转换?如果允许进行类型转换,则需要写类型转换函数。

条款20:以pass-by-reference-to-const替换pass-by-value

缺省情况下C++以传值的方式传递参数,也就是说,函数形参是实参的副本,函数返回值也是返回对象的副本。而拷贝过程是由函数的拷贝构造函数完成的,并且在函数结束时会调用析构函数销毁实参副本,这些都有额外的时间开销。

class Person{
public:
    person();
    ~person();
private:
    string name;
    string add;
};

class Student:public Person{
public:
    Student();
    ~Student();
private:
    string school;
};  

倘若现在,我们有一个函数接收Student类型的实参。那么我们要对形参进行拷贝构造,在函数结束时要调用析构函数销毁。并且,在Student类中有一个string成员,这意味着string的拷贝构造函数和析构函数也将被调用一次。而且,Student类有一个基类Person,那么基类的拷贝构造函数和析构函数也将被调用。……因此,我们只是以值传递了一个Student类型的对象,却需要调用5次拷贝构造函数,5次析构函数!

如果参数为const的引用const Student& s 而不是Student s,则没有新副本产生,也不会调用拷贝构造函数和析构函数。那么为什么一定要用const修饰呢?因为以值传递时,我们知道s只是对象的副本,对s的操作不会改变原本的对象;而传const的引用,传递的是对象本身,以const修饰保证对象不会被改变。

并且,传const的引用可以解决对象切割的问题。

class Window{
public:
    string name() const;
    virtual void dispaly() const;   //显示窗口
};

class WindowWithMe:public Window{
public:
    void display() const override;
};  

void show(Window w)
{
    cout << w.name() << endl;
    w.display();
}       

当我们将派生类的对象传递给函数show时,w的静态类型为Window,编译器会调用Window的构造函数来构造w,尽管它实际上应该是个派生类对象,这个时候对象被切割了,导致display()显示的界面不是我们想要的。

但不是所有参数的传递都是传址更高效,比如内置类型、STL的迭代器、函数对象,采用的都是传值。

总结:

  • 一般而言,传址比传值 更高效(省去了拷贝副本、销毁副本的开销),并且可以避免对象切割
  • 对于内置类型,STL的迭代器,函数对象,传值更合适。

条款21:必须返回对象时,别妄想返回它的引用

虽然传值的效率非常低,但我们不可一味用引用来代替传值,有时候我们会犯一个致命的错误:传递不存在的对象的reference在任何时候我们看到reference时,都应该立刻问自己它是谁的别名?

我们有一个表现有理数的类Rational。

class Rational{
private:
    int n,d;          //分子,分母
    friend const Rational operator*(const Rational& lhs,const Rational& rhs)
    {  //函数①
        return Rational(lhs.n * rhs.n,lhs.d * rhs.d);
    }
    friend const Rational& operator*(const Rational& lhs,const Rational& rhs)
    {  //函数②
        Rational temp(lhs.n * rhs.n,lhs.d * rhs.d);
        return temp;    //函数结束时已经被销毁
    }
    friend const Rational& operator*(const Rational& lhs,const Rational& rhs)
    {  //函数③
        Rational *temp = new Rational(lhs.n * rhs.n,lhs.d * rhs.d); //在哪里delete?
        return *temp;
    }
    friend const Rational& operator*(const Rational& lhs,const Rational& rhs)
    {  //函数④
        static Rational temp;
        temp = Rational(lhs.n * rhs.n,lhs.d * rhs.d); 
        return temp;
    }
}   

若我们以函数①来实现*运算符,返回const Rational类型,显然需要进行一次拷贝,有构造和析构上的时间开销。

但若我们返回const Rational&类型,需要注意的是,我们依然要创建一个对象来存储相乘的结果。并且如函数②所示,局部变量在函数结束之前被销毁,我们返回的是一个被销毁的对象的引用。倘若我们不将它设置成局部变量,而将它建立在堆上,如函数③所示,这时出现了一个新的问题:何时将它delete掉?如果出现以下代码,则会造成资源泄露。

Rational w,x,y,z;
w = x * y * z;
// 这里使用了两次*,new了两个对象,但是没有合理的方式取得new的指针进行资源释放
//而程序员也往往不记得进行资源释放。

这样我们自然而然想到,可以用static变量来解决局部变量被销毁的问题,如函数④所示,但如果是出现以下代码if((a * b) == (c * d)),那么if语句将永远为true,虽然它们都改变了static变量的值,但由于传回的是static的引用,所以它们一直是相等的。

总结:

  • 当在返回一个reference和返回一个object之间选择时,要做出最合理的判断。一般情况下,像=、[ ]运算符要求必须获得那个对象则返回reference。
  • 不要返回一个局部变量的引用和指针(会被销毁),不要返回建立在堆上的引用和指针(可能会造成资源泄露),不要返回静态变量的引用和指针(需要多个静态变量对象时会出错)。

条款22:将成员变量声明为private

为什么不将成员变量声明为public?答案显而易见:封装

public意味着不封装,将成员变量直接暴露给客户,若某一成员变量删除或修改,客户代码将进行大面积的修改。

如果成员变量不是public,客户唯一能访问到成员变量的方式是通过函数。这样一来,我们保护了成员变量只允许使用函数访问它们,并且可以自有变更函数的实现代码而无需更改客户代码。

某些东西的封装性 与 其内容改变时可能造成的代码破坏量 成反比,成员变量的封装性与成员变量改变时代码破坏量成反比。假设我们使用了一个public变量,而最终取消了它,那么所有使用它的客户代码都被破坏;如果我们使用了一个protected变量,而最终取消了它,那么所有使用它的派生类代码都会被破坏。因此,protected成员与public成员一样缺乏封装性。

总结:

  • 切记将成员变量声明为private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证、提供class作者充分的实现弹性。
  • protected不比public有更好的封装性。

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

假如有一个类WebBroser,类内有三个成员函数来清除一些记录。

class WebBroser{
public:
    void clearCache();   //清除缓存
    void clearHistory(); //清除历史记录
    void clearCookie();  //清除所有cookie
};

而一些用户想要一整个进行清除操作,这就有两种实现方法,non-member与member。

//member函数
class WebBroser{
public:
    ...
    void clearAll()
    {
        clearCache();
        clearHistory();
        clearCookie();
    }
};

//non-member函数
void clearAll(WebBroser& w)
{
    w.clearCache();
    w.clearHistory();
    w.clearCookie();
}

那么哪一个比较好呢?member函数还是non-member函数呢?答案是non-member函数较好,因为它具有更好的封装性

在上一个条款中,我们已经了解到,将成员变量设为private是为了更好的封装。那么就只有成员函数和友元函数可以访问数据。而越多的函数可以访问数据,数据的封装性就越低。member函数可以访问到类的私有数据成员、私有函数、enum等等,而non-member函数都无法访问到。

再者,一个类可能会有多个便捷操作,如WebBroser类可能会有与书签有关的、与打印有关的、与Cookie管理有关的等等,而很多时候,用户并不想使用全部的便捷操作,可能只想使用一种,这个时候,我们就可以 将每类操作定义在同一命名空间下的不同的头文件内,使客户只对他们所用的那一小部分系统形成编译相依,也可以方便客户扩展这一组便利函数。

//头文件"webbrowser.h"
namespace WebBrowserStuff{
    class WebBroser{};     
    ...                 //核心机能,客户都需要的
}

//头文件"webbrowserbookmarks.h"
namespace WebBrowserStuff{
    ...                //与书签相关的便捷函数
}

//头文件"webbrowsercookies.h"
namespace WebBrowserStuff{
    ...                //与cookie相关的便捷函数
}

这正是C++标准程序库的组织方式,在std命名空间内,有数十个头文件< vector > < map >等,每个头文件声明std的某些机能。如果我们只想要使用vector,那只需要引入vector头文件即可。

当客户想要增加便利函数时,直接向相应的头文件内加入即可,而若是将函数声明为member函数,客户没有权利向class中添加成员函数。

总结:

  • 用non-member、non-friend来替换member函数。这样可以增加封装性和机能扩充性。

猜你喜欢

转载自blog.csdn.net/fancynece/article/details/79740724
今日推荐