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

本文的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[ ]。遵循对称原则总没有错!

时间: 2024-08-29 07:10:08

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

类设计者的核查表

1.你的类需要一个构造函数吗 需要构造函数来隐藏类的内部工作方式. 2.你的数据成员是私有的吗 通常使用公有的数据成员不是什么好事,因为类设计者无法控制何时访问这些成员. 3.你的类需要一个无参的构造函数吗 为了可生成对象数组或类的对象不必显示的初始化类的构造函数,必须显示地写一个无参的构造函数. 4.是不是每个构造函数初始化所有的数据成员 每个构造函数都要负责为所有的数据成员设置经过明确定义的值. 5.类需要析构函数吗 看该类是否分配了资源,而这些资源又不会由成员函数自动释放. 6.类需要一个

OOD沉思录 --- 类和对象的关系 --- 使用关系原则

4.1 尽量减少类的协作的数量,即减少使用者和被使用者的数量. 协作意味着一定程度的耦合,但是完全没有协作的类也是没有意义的,最多只能作为一个库使用. 通过抽象,依赖接口,可以最大程度减少依赖的实现类,对使用者来说,只看到接口的依赖,而具体的实现的依赖可以通后后期绑定来配置依赖关系. 如 菜单 ----〉牛肉 ----〉羊肉 ----〉鸡肉       可以抽象为 菜单---->肉类 <===牛肉                       <===羊肉                 

《C++沉思录》——类设计核查表、代理类、句柄类

<C++沉思录>集中反映C++的关键思想和编程技术,讲述如何编程,讲述为什么要这么编程,讲述程序设计的原则和方法,讲述如何思考C++编程. 一.类设计核查表 1.你的类需要一个构造函数吗? 2.你的数据成员都是私有的合理吗? 3.你的类需要一个无参的构造函数吗? 是否需要生成类对象的数组! 4.你的每一个构造函数都初始化所有的数据成员了吗? 虽然这种说法未必总是正确,但是要积极思考! 5.你的类需要析构函数吗? 6.你的类需要一个虚析构函数吗? 7.你的类需要一个拷贝构造函数吗? 8.你的类需

C++沉思录之二——虚函数使用的时机

虚函数使用的时机 为什么虚函数不总是适用? 1. 虚函数有事会带来很大的消耗: 2. 虚函数不总是提供所需的行为: 3. 当我们不考虑继承当前类时,不必使用虚函数. 必须使用虚函数的情况: 1. 当你想删除一个表面上指向基类对象,实际却是指向派生类对象的指针,就需要虚析构函数. C++沉思录之二--虚函数使用的时机,布布扣,bubuko.com

迷你MVVM框架 avalonjs 沉思录 第1节 土耳其开局

#cnblogs_post_body p{ text-indent:2em; margin-top: 1em; } 正如一切传说的开端那样,有一远古巨神开天辟地,然后就是其他半神喧宾夺主.我们对最巨贡献与创建力的远古巨神懵懂不知,却对巫师们的话语津津乐道.这同样也是我们前端的现实. MVVM是来自.NET,另一个遥远的界域.前端,相对于后端,怎么看都是蛮夷之地.JS这个肩负着前端一切交互工作的语言,竟然被视为恶魔,屡屡被屏蔽禁用.些微可用的脚本,变量与函数没有组织地野蛮生长着,直到JAVA的传教

【C++沉思录】句柄1

1.在[C++沉思录]代理类中,使用了代理类,存在问题: a.代理复制,每次创建一个副本,这个开销有可能很大 b.有些对象不能轻易创建副本,比如文件2.怎么解决这个问题? 使用引用计数句柄,对动态资源封装,句柄包含指针,多个句柄可以指向同一个对象.复制的时候,只是复制句柄的指针.3.使用引用计数句柄,是为了避免不必要的对象复制,因此我们要知道有多少个句柄绑定到当前对象,也就是引用计数, 这样才能确定何时可以释放资源.4.需要注意的是:引用计数不能是句柄的一部分,如果怎么做,当前句柄必须知道指向同

&lt;&lt;C++ 沉思录&gt;&gt; 中文人民邮电出版 勘误

<<C++ 沉思录>> 中文人民邮电出版 勘误 这本中文版里面有各种坑爹的小错误. 比方说变量名的大小写, 同一个变量, 出现了大小写不一致, 等等问题都有. 然后今天感觉遇到了个语法问题. 关于继承权限的问题. 书中第八章的demo里面, 关于class Expr_node. 使用了protected关键字. 但是这里Expr_node是基类, 继承就会出现问题. 具体的代码如下: class Expr_node { friend ostream& operator &l

设计模式沉思录——互动出版网

这篇是计算机类的优质预售推荐>>>><设计模式沉思录> GoF成员.<设计模式>一书作者之一John M. Vlissides为你揭开模式设计的神秘面纱 内容简介 本书在GoF的<设计模式>一书的基础上进行了拓展,运用其中的概念,介绍了一些技巧,帮助读者决定在不同的情况下应该使用哪些模式,以及不应该使用哪些模式.本书不仅对已有的一些模式提出新的见解,还让读者见证开发新模式的整个过程. 本书适合使用设计模式的软件开发人员阅读. 作译者 作者介绍 J

C++沉思录第八章算数表达式树的面向对象问题的分析

刚开始看沉思录,觉得太枯燥.到了第八章,作者关于面向对象问题的分析,我follow书上的设计开发,理解了一些以前只是在书上看到的概念. 给自己做几点注解吧: 1.虚基类用来表达所有的继承类的共有特点,在这个例子中,所有的继承类都要有输出和求值计算,所以我们把这两个函数定义为虚函数. 2.虚基类必须至少含有一个纯虚函数.该纯虚函数可以定义也可以不定义. 3.我们要保证由虚基类派生出来的类的对象能被正确的析构,所以将虚基类的析构函数定义为虚函数. 4.对于虚函数如果没有定义,也应该使用一对{}来表明