C++ 类设计规则及注意事项

C++ 类设计规则及注意事项

一、概述

C++可用于解决各种类型的编程问题,但不能将类设计简化成带编号的例程。下面将简单介绍类设计中的部分设计规则及注意事项。

二、编译器生成的成员函数

编译器会自动生成一些公有成员函数—特殊成员函数。这表明这些成员函数很重要,下面将一一说明回顾这些成员函数,并做简单记录。

2.1 默认构造函数

默认构造函数要么没有参数,要么所有的参数都有默认值:

<1> 如果没有定义任何构造函数,编译器将定义默认构造函数,让使用者能够创建对象。假设Star是一个类,则下述代码需要使用默认构造函数:

Star rigel;           // 创建一个不需要显式初始化的对象
Star pleiades[6];     // 创建一个对象数组

<2> 自动生成的默认构造函数的另一项功能是:调用基类的默认构造函数以及调用本身是对象的成员所属类的默认构造函数

<3> 如果派生类构造函数的成员初始化列表中没有显式调用基类构造函数,则编译器将使用基类的默认构造函数来构造派生类对象的基类部分。在这种情况下,如果基类没有构造函数,将导致编译阶段错误

<4> 如果定义了某种构造函数,编译器将不会定义默认构造函数。在这种情况下,如果需要默认构造函数,则必须自己提供。

为什么要提供构造函数或者提供构造函数的动机?:

<1> 提供构造函数的动机之一是确保对象总能被正确的初始化

<2> 如果类包含指针成员,则必须初始化这些成员。因此,最好提供一个显式默认构造函数,将所有的类数据成员都初始化为合理的值。

2.2 复制构造函数

复制构造函数接受其所属类的对象作为参数。例如,Star类的复制构造函数的原型如下:

Star(const Star &s);

在如下几种情况下将调用复制构造函数:

<1> 将新对象初始化为一个同类对象。

<2> 按值将对象传递给函数。

<3> 函数按值返回临时对象。

<4> 编译器生成临时对象。

复制构造函数的使用注意事项:

<1> 如果程序没有使用(显式或隐式)复制构造函数,编译器将提供原型,但不提供函数定义

<2> 如果程序隐式调用复制构造函数,编译器将提供一个执行成员初始化的复制构造函数。也就是说,新对象的每个成员都被初始化为原始对象相应的成员的值。

<3> 如果成员是类对象,则初始化该成员时,将使用成员相应类的复制构造函数。

<4> 在某些情况下,成员初始化是不合适的。例如,使用new初始化的成员指针通常要求执行深复制(深拷贝),或者类可能包含需要修改的静态变量。在这两种情况下,需要提供自定义的复制构造函数

2.3 赋值运算符

默认的赋值运算符用于处理同类对象之间的赋值不要将赋值与初始化混淆了, 具体分辨情况如下:

<1> 如果语句创建新的对象,则使用初始化。

<2> 如果语句修改已有对象的值,则是赋值。

Star sirius;
Star alpha = sirius;    // 初始化
Star dogstar;
dogstar = sirius;       // 赋值

赋值运算符使用注意事项:

<1> 默认赋值运算符为成员赋值

<2> 如果成员是类对象,则默认成员赋值将使用相应类的赋值运算符。

<3> 如果需要显式定义复制构造函数,则基于相同原因,也需要显式定义赋值运算符。

// Star类赋值运算符原型,赋值运算符函数返回一个Star对象引用
Star& Star::operator=(const Star &);

编译器不会生成将一种类型赋给另一种类型的赋值运算符,但是可由其它方式实现:

<1> 如果希望将字符串赋给Star对象,则方法之一是显式定义下面的运算符

Start& Star::operator=(const char *)
{
    // Do something
    ...
}

<2> 另一种方法是使用转换函数,将字符串转换成Star对象,然后使用将Star对象赋给Star对象的赋值运算符函数。

<3> 上述两种方法的对比优缺点:

  • 第一种方法(显式赋值运算符)运行速度较快,但需要的代码较多
  • 第二种方法(转换函数)可能导致编译器出现混乱

三、其它类方法注意事项

定义类时,还需要注意以下几点事项,后面将逐一说明各注意事项。

3.1 构造函数

构造函数注意事项如下:

<1> 构造函数不同于其他类方法,因为它创建新对象,而其它类方法只是被现有的对象调用。这是构造函数不被继承的原因之一。

<2> 继承意味着派生类对象可以使用基类的方法,然而构造函数在完成其工作之前,对象并不存在。

3.2 析构函数

析构函数注意事项如下:

<1> 一定要定义显式析构函数来释放类构造函数使用new分配的所有内存,并完成类对象所需的任何特殊的清理工作。

<2> 对于基类,即使他不需要析构函数,也应提供一个虚析构函数。

3.3 转换函数

将其它类型转换为类类型说明及注意事项:

<1> 使用一个参数就可以调用的构造函数定义了从参数类型到类类型的转换,如下所示:

// 将char*转换为Star
Star(const char*);
// 将Spectral转换为Star
Star(const Spectral&, int numbers = 1);

<2> 将可转换的类型传递给以类为参数的函数时,将调用转换构造函数,如下所示:

// 声明一个Star对象
Star north;
// 将字符串赋值给north对象时,先调用转换构造函数
// 再调用赋值运算符函数赋值给north对象
// 上述假设没有定义将char*赋值给Star的赋值运算符
north = "polaris";

<3> 在带一个参数的构造函数原型(声明时)使用explicit禁止进行隐式转换,仍能进行显示转换。 如下所示:

class Star
{
public:
    explicit Star(const char*);
};

// 声明一个Star对象
Star north;
// 不允许隐式转换,这样是错误的
north = "polaris";
// 允许显示转换,这样是正确的
north = Star("polaris");

将类类型转换为其它类型说明及注意事项:

<1> 要将类对象转换为其它类型,应定义转换函数

  • 转换函数可以是没有参数的类成员函数
  • 转换函数可以是返回类型被声明为目标类型的类成员函数
  • 即使没有声明返回类型,函数也应返回所需的转换值

示例如下:

// 将Star转换为double
Star::Star double()
{
    // ...
}
// 将Star转换为const char*
Star::Star const char*()
{
    // ...
}

<2> 应理智的使用转换函数, 仅当它们有帮助时才使用,因为对于某些类,包含转换函数将增加代码的二义性。如下所示:

// 假设已经为Star定义了double转换函数
Star ius(6.0);
// 1、编译器可以将ius转换为double类型,再通过double加法与16.6相加
// 2、编译器或者将16.6转换为Star,再通过Star的加法运算符与ius相加
// 3、但是编译器处理能指出二义性以外,其它什么也不做
Star lix = ius + 16.6;

<3> c++支持将关键字explicit用于转换函数。同构造函数一样,explicit允许使用显示转换,但不能进行隐式转换。

3.4 按值传递对象与传递引用

编写使用对象作为参数的函数时,应按引用而不是按值来传递对象。原因有如下几个方面:

<1> 为了提高效率。按值传递对象涉及到生成临时拷贝,即调用复制构造函数,然后调用析构函数。调用这些函数都需要执行时间,特别是复制大型对象比传递引用花费的时间要多的多。如果函数不修改对象(比方说对象的成员变量),应该将参数声明为const引用

<2> 继承时可接受派生类。在继承使用虚函数时,被定义为接受基类引用参数的函数可以接受派生类。

3.5 返回对象和返回引用

在实际开发过程中,会发现有的函数直接返回对象,有的函数则是直接返回引用。但是,有的函数必须返回对象,当不必非要返回对象时,则应更改为返回引用。具体说明如下:

<1> 编码方面,直接返回对象与返回引用之间唯一的区别在于函数原型与函数头,如下所示:

// 返回Star对象
Star noval(const Star &);
// 返回Star引用
Star& noval2(const Star &);

<2> 返回引用而不是返回对象的原因在于,返回对象涉及生成返回对象的临时副本,这是调用函数的程序可以使用的副本。因此,返回对象的时间成本包括调用复制构造函数生成副本所需的时间和调用析构函数删除副本所需的时间。返回引用则可以节省时间和内存。

<3> 直接返回对象与按值传递对象相似,他们都生成临时副本;返回引用与按引用传递对象相似,调用和被调用的函数对同一个对象进行操作。

<4> 并不是所有的函数都可以返回引用。函数不能返回在函数中创建的临时对象的引用,因为当函数结束时,临时对象会自动释放掉,因此返回这种引用将是非法的。这种情况下,就应该返回对象了。

返回对象与返回引用的通用规则:

<1> 如果函数返回在函数中创建的临时对象,则不要使用引用。如下所示:

// 重载的+运算符函数
// 通过Star构造函数(假设定义了此构造函数)创建一个新对象,然后返回该对象的副本
Star Star::operator+(const Star& b) const
{
    return Star(x + b.x, y + b.y);
}

<2> 如果函数返回的是通过引用或指针传递给它的对象,则应按引用返回对象。如下所示:

// 假设Star类中声明了total_val成员变量
// topval函数按引用返回调用函数的对象或作为参数传递给函数的对象
const Star& Star::topval(const Star& s) const
{
    if (s.total_val > total_val)
        return s;
    else
        return *this;
}
3.6 使用const

使用const的注意事项:

<1> 保证函数参数不被修改,使用const时应特别注意,可以使用它来确保方法不修改参数,如下所示:

// 不能改变s指向的字符串
Star::Star(const char* s)
{
    // ...
}

<2> 使用const确保方法不修改调用它的对象,如下所示:

// 不能改变调用函数的对象
// 这里的const表示const Star* this,而this指向调用的对象
void Star::show() const
{
    // ...
}

<3> 有时候可以将返回引用的函数放在赋值语句的左侧,这实际上意味着可以将赋值语句右侧的值赋给返回的引用的对象。所以需要使用const来确保返回的引用或指针不能被其它值修改。如下所示:

// 1、该方法返回对this或s的引用。
// 2、因为this和s都被声明为const,所以函数不能对它们进行修改,这意味着返回的引用也必
// 须被声明为const
const Star& Star::topval(const Star& s) const
{
    if (s.total_val > total_val)
        // 返回传递的参数对象
        return s;
    else
        // 返回调用函数的对象
        return *this;
}

<3> 如果函数将参数声明为指向const的引用或指针,则不能将该参数传递给另一个函数,除非后者也确保参数不会被修改。

四、公有继承考虑因素

在程序中使用继承时,有很多需要注意的问题,接下来详细说明各种注意事项。

4.1 is-a关系

<1> 在某些情况下,如果派生类不是一种特殊的基类,则不要使用公有派生。

<2> 在某些情况下,最好的方式可能是创建包含纯虚函数的抽象基类,并从它派生出其它类。

<3> is-a关系的方式之一是,无需进行显式类型转换,基类指针就可以指向派生类对象,基类引用可以引用派生类对象。

<4> 第三条说明,在反过来的情况下是不行的,即不能在不进行显式类型转换的情况下,将派生类指针或引用指向基类对象。这种显式类型转换(向下强制转换)有可能是没有意义的,取决于类声明方式。

4.2 什么不能被继承
4.2.1 构造函数是不能继承

<1> 创建派生类对象时,必须调用派生类的构造函数。然而,派生类构造函数通常使用成员初始化列表语法来调用基类构造函数, 以创建派生对象的基类部分。

<2> 如果派生类构造函数没有使用成员初始化列表语法显示调用基类构造函数,将使用基类的默认构造函数。

<3> 继承链中,每个类都可以使用成员初始化列表将信息传递给相邻的基类。

<4> C++11新增了一种能够继承构造函数的机制(后续再说明),但默认仍不继承构造函数。

4.2.2 析构函数不能被继承

<1> 释放对象时,程序将首先调用派生类的析构函数,然后调用基类的析构函数。

<2> 如果基类有默认析构函数,编译器将为派生类生成默认析构函数

<3> 对于基类,其析构函数应该设置为虚函数

4.2.3 赋值运算符不能被继承

<1> 派生类继承的方法的特征标与基类完全相同,但赋值运算符的特征标随类而异,这是因为赋值运算符包含一个类型为所属类的形参。

4.3 赋值运算符

<1> 如果编译器发现程序将一个对象赋给同一个类的另一个对象,他将自动为这个类提供一个默认的赋值运算符函数。这个赋值运算符的默认或隐式版本将采用成员赋值,即将原对象的相应成员赋给目标对象的相应成员。

<2> 如果对象属于派生类,编译器将使用基类赋值运算符来处理派生类对象中基类部分的赋值。如果显式的为基类提供了赋值运算符,将使用该赋值运算符。与此类似的是,如果对象中的成员是另一个类的对象,则对于改成员,将使用其所属类的赋值运算符。

<3> 如果派生类使用new来初始化指针,则必须提供一个显式赋值运算符。必须给类的每个成员提供赋值运算符,而不仅仅是新成员。如下所示:

// DerivedDMA:派生类  BaseDMA:基类
// style:char指针
DerivedDMA& DerivedDMA::operator=(const DerivedDMA& dv)
{
    if (this == &dv) 
        return *this;
    
    // 显式调用基类赋值运算符
    BaseDMA::operator=(dv);
    delete[] style;
    style = new char[std::strlen(dv.style) + 1];
    std::strcpy(style, dv.style);
    return *this;
}

<4> 将派生类对象赋值给基类对象将会怎样呢?这不同于将基类引用初始化为派生类对象

答:将派生类对象赋给基类对象,只涉及基类的成员赋值

// 基类对象
BaseDMA blips;
// 派生类对象,派生类具有自己独有的成员变量
DerivedDMA snips("Rafe Plosh", 12.3, 34.5, 345);
// 1、赋值语句将被转换为左边的对象调用的赋值运算符函数
// 2、blips.operator=(snips);
// 3、由于左边是基类对象,它将调用BaseDMA::operator=(const BaseDMA&)
// 4、is-a关系允许BaseDMA引用指向派生类对象,如snips
// 5、此类调用只会处理基类成员,忽略派生类成员
blips = snips;

<5> 将基类对象赋值给派生类对象又会怎样呢?

答:调用派生对象的赋值运算符时,派生类引用不能自动引用基类对象,代码不能运行

// 基类对象
BaseDMA blips("Griff Hexbait", 123, 321);
// 派生类对象
DerivedDMA snips;
// 1、赋值语句将被转换为左边的对象调用的赋值运算符函数
// 2、snips.operator=(blips);
// 3、由于左边是派生类对象,它将调用DerivedDMA::operator=(const DerivedDMA&)
// 4、问题出在参数类型上,派生类引用不能自动引用基类对象,因此此赋值代码不能运行
snips = blips;

<6> 将基类对象赋值给派生类对象可行性研究

再来回顾第5条提出的问题,“将基类对象赋值给派生类对象会怎样?”。如果派生类定义转换构造函数及定义了将基类对象赋给派生类对象的赋值运算符,则可以这样做。如果这两个条件都不满足,则不能这样做,除非进行显式强制类型转换。

// 方式一:转换构造函数
// 1、派生类可以定义一个转换构造函数,转换构造函数可以接受一个类型为基类的参数和其它参
// 数,条件是其它参数有默认值
// 2、如果派生类有转换构造函数,程序将通过转换构造函数根据基类对象(传递的参数)来创建
// 一个临时的派生类对象,然后将它用作赋值运算符的参数
DerivedDMA(const BaseDMA& ba, double ml = 100.0, double r = 0.1);

// 方式二:定义将基类赋给派生类的赋值运算符
// 1、该赋值运算符的参数类型与赋值语句匹配,因此无需进行类型转换
DerivedDMA& DerivedDMA::operator=(const BaseDMA &);
4.4 私有成员与保护成员

<1> 对派生类而言,保护成员类似于公有成员;但对于外部而言,保护成员与私有成员类似。

<2> 派生类可以直接访问基类的保护成员,但只能通过基类的成员函数来访问私有成员

<3> 将基类成员设置成私有的可以提高安全性,而将基类成员设置成保护的可以简化代码编写工作,提高访问速度。

4.5 虚方法

<1> 设计基类时,必须确定是否将类方法声明为虚的。

<2> 如果希望派生类能够重新定义方法,则应在基类中将方法声明为虚的,这样可以启用晚期联编(动态联编)

<3> 如果不希望重新定义方法,则不必将方法声明为虚的,这样虽然无法禁止他人重新定义方法,但是可以表示不想被重新定义的思想。

<4> 注意,不适当的代码将阻止动态联编。如下所示:

// 此函数按引用传递参数
void show(const BaseDMA& ba)
{
    ba.ViewAcct();
    std::cout << std::endl;
}

// 此函数按值传递参数
void inadequate(BaseDMA ba)
{
    ba.ViewAcct();
    std::cout << std::endl;
}

// 1、假设将派生类参数传递给上述两个函数调用
// 3、
DerivedDMA buzz("Buzz Parsec", 123.1, 342);
// 1、show函数调用使ba参数成为DerivedDMA类对象buzz的引用
// 2、ba.ViewAcct()被解释为DerivedDMA类的ViewAcct()函数,跟设计预想的一致。
show(buzz);
// 1、inadequate()函数是按值传递,传递参数后,ba参数成为BaseDMA(const BaseDMA &)构
// 造函数创建的一个BaseDMA对象
// 2、自动向上强制转换使的构造函数可以引用一个DerivedDMA对象
// 3、在inadequate()函数中,ba.ViewAcct()被解释为BaseDMA类的ViewAcct()函数,跟设
// 计预想的不一致
inadequate(buzz);
4.6 析构函数

基类的析构函数应该是虚的,这样当通过指向对象的基类指针或引用来删除派生类对象时,程序将首先调用派生类的析构函数,然后调用基类的析构函数,而不仅仅只是调用基类的析构函数。

4.7 友元函数

<1> 由于友元函数并非类成员,因此不能被继承。

<2> 如果希望派生类的友元函数能够使用基类的友元函数,可以通过强制类型转换,将派生类指针或引用转换为基类指针或引用,然后使用转换后的指针或引用来调用基类的友元函数。

// 定义<<运算符友元函数
std::ostream& operator<<(std::ostream& os, const DerivedDMA& dv)
{
    // 1、将DerivedDMA派生类的dv引用强制转换为BaseDMA基类引用
    // 2、可以使用运算符dynamic_cast<>来进行强制转换,转换方式为
    // os << dynamic_cast<BaseDMA &>(dv);
    // 3、转换完成后,调用基类<<运算符友元函数
    os << (const BaseDMA &)dv;
    os << "Style: " << dv.style << std::endl;
    return os;
}
4.8 有关使用基类方法的说明

以公有方式派生的类的对象可以通过多种方式来使用基类的方法,如下:

  1. 派生类对象自动使用继承而来的基类的方法,如果派生类没有重新定义该方法。
  2. 派生类的构造函数自动调用基类的构造函数
  3. 派生类的构造函数自动调用基类的默认构造函数,如果没有在成员初始化列表中指定其它构造函数。
  4. 派生类构造函数显式的调用成员初始化列表中指定的基类的构造函数
  5. 派生类方法可以使用作用域解析运算符来调用公有的和受保护的基类的方法
  6. 派生类的友元函数可以通过强制类型转换,将派生类引用或指针转换为基类的引用或指针,然后使用该引用或指针来调用基类的友元函数。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值