《C++沉思录》:类设计者的核查表——有关class的11问

来源:互联网 发布:java视频文件上传 编辑:程序博客网 时间:2024/04/27 14:45

本文的11个问题提取自《C++沉思录》第四章。所有问题的说明均为自己补充。

1 你的类需要一个构造函数吗?

——正确的定义构造函数,把握好构造函数的职能范围


  • 有些类太简单,它们的结构就是它们的接口,所以不需要构造函数。
class print{    void print1(){cout<<"1"<<endl;}    void print2(){cout<<"2"<<endl;}    void print3(){cout<<"3"<<endl;}};
  • 有些类很复杂,构造过程就有很复杂的行为,所以必须设计一个构造函数,甚至多个重载构造函数。
class string{public:    string(){_buf_ptr = new char [15];}    string(const string &);    string(const char *);private:    char *_buf_ptr;};
  • 我常常困扰在这样的问题上:
    “我应该把这部分操作放在构造函数中,还是另外声明一个函数单独执行?”
    例如:
class socket_connect{public:    socket_connect();    PowerOn();private:    int sock_fd;};socket_connect::socket_connect(){    sock_fd = socket(AF_INET,SOCK_STREAM,0);    init_sockaddr_in();}void socket_connect::PowerOn(){    int ret = bind(sock_fd,(const sockaddr*)&servaddr,sizeof(servaddr));    PERROR(ret,"bind failed!");    ret = listen(sock_fd,BACK_LOG);    PERROR(ret,"listen failed!");}

在这里,根据我们的需求,socket本身在初始化的时候就应该开始监听了吧?所以我们完全可以把bind()listen()放到构造函数里面去嘛!

但是我还是单独提出来了一个函数PowerOn(),这是有理由的。因为我倾向于以全局变量的方式创建socket,而且不希望在我完成一系列socket之外的初始化前,就有客户端试图连接。

嗯,听起来都很有道理。第一种做法赋予了构造函数更全面的“构造”功能;第二种做法将操作细化,可以更好地控制类的行为。

  • 那么。构造函数究竟该执行到哪一步呢?

    1. 一种说法是:PowerOn()或者单独的init()这样的函数容易被忘记调用,我们应该让class在构造后就能愉快的跑起来!
    2. 另一种声音说:太过复杂的构造函数是不可靠的!你不该过分的信任构造函数!如果你在调用构造函数时出现了问题,后果会很严重的!
  • 目前来看。我还没有找到一个严格的限定。但是对于复杂的类来说,下面的两条一定没有错!
    - 空的构造函数是愚蠢的行为!你至少应该把数据赋值成0嘛
    - 实现太多功能的构造函数同样是愚蠢的行为!过于复杂的构造函数会让class看起来很凌乱
    这额外的一条是未经过考究的:
    - 如果有init()这样的一个函数作为构造函数的补充,起码应该保证当我忘记调用init()时会通过某种方式给出警告或提醒!


2 你的数据成员是私有的吗?

——pubicprivate 还是 protected?


在逐渐熟练的使用C++的过程中,我越来越倾向于将所有的数据成员都隐藏起来这样的做法。看这样一个例子:

class SOURCE{public:    void init(const char*,unsigned int, unsigned int,void*);    inline void increase() {source_amount++;};    inline int decrease(unsigned int val){source_amount -= val};    unsigned int get_sleeptime() const {return sleep_time;}    unsigned int get_amount() const {return source_amount;}    unsigned int get_speed() const {return source_speed; }    char* get_name() const { return name; }private:    Mutex source_lock    char name[20];    unsigned int source_amount;    unsigned int sleep_time;    unsigned int source_speed;};

看!我把所有的数据成员的访问权限都设定为private了!
如果你很懒。将数据成员直接暴露在public也并不是错误的做法,但是这样的话有两个致命缺点

  • 失去了对数据成员变化的完全控制
    . 我们不知道在何处,在哪里,数据成员被修改了!暴露数据成员意味着会发生原本希望进行一次++操作,却意外的被清零这样的类似问题!
    . 所以说,这就相当于你把root权限给了所有用户,多可怕的一件事!

  • 不易于修改
    . 假设:今天我们的需求是: 每次把这个东西+1,明天需求可能就变成了: 每次把这个东西-1!
    . 难道说每一次你都要在所有可能出现修改类成员的地方都把++改为–吗?这显然是不现实的!
    . 如果我们把数据成员隐藏,仅仅提供一个访问接口,那事情就变得简单了!我只需要修改这个函数的行为就可以了!
    . 在上面的例子中,我可以在接口内进行mutex_lock()mutex_unlock()操作,修改成线程安全的自增操作!这很酷!

因此藏起来所有希望保护的数据成员可能是一个不错的习惯!(尽管定义各种代替访问行为的函数接口会增加工作量)


3 你的类需要一个无参的构造函数吗?

这个问题和问题1相近,构造函数执行到什么程度算好呢?
显然这个问题没有标准答案,决定因素是:你对这个class的设计意图

class Example{    Example(int p,int q){cout<<p+q<<endl;}};class Example_2{    Expamle_2() {cout<<"nothing"<<endl;}};

如果只提供一个带参数的constructor,会有哪些损失?

Example eg;Example_2 eg_2;
  • 显然第一个式子是错误的。作为类的设计者,你了解关于这个类的一切,可能不会犯这样的错误。但是如果别人需要使用你的类呢?这样的创建对象显然会带来一些小问题。
  • 考虑Example a[100],同样的道理,只提供一个有参构造函数有时候会带来小麻烦。

好吧,到底该怎么做呢?

  • 如果你的设计意图很明确:我不希望编译器给我创建默认构造函数,我希望严格控制这个class对象的构造行为
  • 那么放心的仅仅提供有参构造函数,一定没错!(但是注意把这个要求传达给使用者)

  • 如果你思考后,觉得提供一个无参构造函数并不被你的设计意图所排斥,而且不会因为没有赋给对象一个有意义的初值导致程序崩溃
  • 那么提供一个无参构造函数会让你的类使用起来更方便!

看看STL吧,string s; string s(const string&);
这是一种很好的参考模型。

  • 另外,提供默认值的有参构造函数也可以被用于无参的调用!这也不失为好的解决办法
class A{public:    A(int a = 10) {}};...int main(){    A a(11);//合法    A b;//合法}

4 是不是构造函数应该初始化所有的数据成员?

看起来这个问题似乎有些奇怪,如果用下面的这种问法,应该会好回答一些:

是不是每个数据成员都应该被构造函数初始化?

显然应该对每个出现的成员进行一个合理的初始化操作。不然出现未定义的行为会让程序出现意料之外的错误。
几个常用的例子有:
int data = 0;
char *ptr = NULL;
我想大多数人都知道像上面这样做以避免“未初始化”这样的错误!那么,构造函数中也理应如此。

不过,也别做的那么绝对。书中提到:

有时,类会有一些数据成员,它们只在它们的对象存在了一定时间后才有意义。

对于这种成员,要不要初始化?这就需要留到实际问题中去思考了!不过,养成把指针初始化成NULL的习惯一定不会有错!


5 类需要析构函数吗?

这个问题不难回答,我常在析构函数中做的就是使用delete来释放new创建的对象。(同理mallocfree也是如此!)

保证一个new匹配一个delete,不要多也不要少。
有时候唯一需要注意的是,用delete还是delete []


6 类需要虚析构函数吗?

这个问题比问题5有意义多了!
首先应该知道:

绝不会用作基类的类是不需要虚析构函数的!

那么,为什么析构函数有时候需要被声明为virtual?
什么时候应该声明析构函数为virtual?

class B {};class D: public B {};int main(){    B *b_ptr = new D; //这是正确的    delete b_ptr; //可能会造成错误}

只要有人可能会对实际指向基类D对象的、但类型确实B*类型的指针执行delete表达式,就应该给B添加一个虚析构函数!

为什么这样做?这是我的解读

  • 首先,你一定知道:派生类内部是存在一个完整的基类对象的,派生类做的一切变化都是在这个基类对象后面添加的。
  • 可以这样理解:基类B1楼,派生类D1楼+2楼。现在我们的指针是B *b_ptr,它的作用范围只有1楼;但是因为B *b_ptr = new D;,所以这个指针实际指向的对象拥有1楼+2楼
  • 如果我们delete b_ptr,很显然,因为指针被编译器限制作用范围为1楼,所以只会是1楼被delete了!但是别忘了,我们的对象是有2楼的!
  • 那么1楼被拆掉,2楼会怎样呢?当然是直接悬空!不用怀疑,这是相当危险的!残余的2楼不仅没有被安全释放,还因为1楼已经消失,使得我们无法再通过1楼上到2楼。

因此,虚析构函数是有必要的!对于一个使用不恰当的指针(上面的b_ptr),我们一旦发现这个指针是不合理的,就通过动态绑定的方式,给他一个假的楼层让它去析构。如此就避免了2楼还在1楼却没了的情况!

虚析构函数通常是空的


7 你的类需要一个赋值操作符吗?

等号=太常用了,以至于我们经常忘记确定class是否有=操作就去使用它。
这的后果是什么呢?

class A{public:    A(){name = new char[100];}    ~A(){delete [] name;}//注意这里private:    char *name;};void function(const A& base){    A copy = base;}//function结束后调用析构函数int main(){    A base;    function(base);}//main结束后调用析构函数

上面的代码中,function()函数内,创建了一个名为copy的对象,并使用了=操作。尝试运行一下,会发生什么?

————————崩溃啦!!!

为什么?

  • 因为默认的=操作符只是简单地赋值了一份内存中的。copybase的数据成员char *name值是相等的,指向同一片内存!
  • 这就造成,在function()调用结束后,析构copyname指向的内存已被删除。
  • main函数结束后,还需要析构base一次。而此时name已经被释放了!再次delete必然崩溃!

给我们的启示:

  • 动态分配的资源,一定要考虑重载=
    这里可以这样修改:
A& A::operator=(const A&base){    char *temp = new char [100];    strcpy(temp,base.name);    name = temp;    //others = base.others;    return *this;}
  • 良好的指针习惯可以让你在类设计不完善时避免程序崩溃!
    ~A(){delete [] name;name = NULL;}

——拒绝悬垂指针,从我做起!

上面的操作虽然没有达到预期的目的,但是至少不会崩溃嘛!

TIPS:

通常operator=应该返回一个引用class&,并且由return *this结束以保证与内建的复制操作符一致。


8 你的类需要一个复制构造函数吗?

如果需要一个复制构造函数,那么你多半也需要定义一个=吧。即使你不需要一个=,它们的实现方法也是一致的。所以请参见问题7

(当然,如果你的复制构造函数(copy constructor)设计的足够好的话,你完全可以用copy constructor来实现operator=而不是用=来实现copy constructor)


9 你的赋值操作符能正确的将对象赋给对象本身吗?

——复制自己带来的麻烦


记得《剑指offer》最开始的面试题吗?

如下类型A的声明,为该类添加一个赋值运算符函数。

class A{public:    A(char *pData = NULL);//pData一般用new动态分配    A(const A&str);    ~A();private:    char *pData;};

还记得问题7,8吗?

我们的思路很明确:在赋值运算符函数内,先调用delete,释放原来的字符串,然后再new一个字符串,用strcpy拷贝过来!(再次强调:delete后先赋值为NULL是好习惯

那么想一想,下面的语句,会发生什么呢?

A origin;origin = origin;

origin 先执行delete,然后拷贝自己。很显然,origin玩脱了!复制自己的时候,已经delete过了,显然无法完成正确的赋值!

核心问题就是这个啦——防止复制自身!

解决方案如下:

A& A::operator =(const A& origin){    if(this==&origin)        return *this;    delete [] pData;    pData = NULL;    pData = new char [strlen(origin.pData)+1];    strcpy(pData,origion.pData);    return *this;}

另一个可行,且较好的方案是,用temp临时保存起来origin的值。(调换语句顺序)

A& A::operator =(const A& origin){    char *temp = new char[strlen(origion.pData)+1];    strcpy(temp,origin.pData);    delete [] pData;    pData = temp;    return *this;}

既然说到了这里,同样要提到copy constructor里面的坑——下面哪个复制构造函数是正确的?

class A{public:    A(A origion);//1    A(A& origion);//2    A(const A& origion);//3};
  • 第一个通不过编译!

    A的复制构造函数禁止带有和A类型的参数!
    这是一个值得思考的问题。先有鸡还是先有蛋呢。。。

  • 第二个可以通过编译,但是不好,下个问题会详细说明。

  • 第三个是推荐的!

10 const总是很重要!

问题9的末尾,已经提到了 在赋值运算符和复制构造函数中使用const限定符 有关的东西。

我们使用const是为了防止该变量被修改

原则上来讲,凡是不希望改变数据成员的函数我们都给它声明为const

那么,当你真的使用const限定符后,对于某些成员函数,添加const同样也是必须的!

假如一个class被限定为const,编译器会判定一切非const的成员函数的调用为非法!
因为这些函数可能会拥有改变数据成员值的行为!
即使它实际上并没有改变数据成员的想法。

这个例子是一个很好的说明:

class Vec{public:    int len_const() const {return len;}    int len(){return len;}    int len;};void use_Vec(const Vec& origin){    int ret1 = origin.len_const();//合法的    int ret2 = len();//error:不兼容的类型限定符}

以上的问题常常被忽略,所幸IDE自带语法检查,让我们能够及时发现这类错误!


11 删除数组时你记住了用delete []吗?

——new和delete的匹配

这条实在是简单的很,但是实际应用起来却不知忘记了多少次!

每一个new都要匹配一个delete。除非你确认程序马上就会结束。
每一个new [ ] 都应该匹配一个delete[ ]。遵循对称原则总没有错!

0 0
原创粉丝点击