C++Primer 学习(类 一)类的基础

今天学习的是类这一章的第一部分,本部分主要从以下几个知识点:

1 定义抽象数据类型

  1. 引入 this

  2. 引入const成员函数

  3. 类作用域和成员函数

  4. 定义一个返回this对象的函数

2. 定义类相关的非成员函数

3. 构造函数

  1. 合成的默认构造函数

  2. =default 的含义

  3. 构造函数初始值列表

4. 拷贝、赋值和析构

  1. 某些类不能依赖于合成的版本

**类的基本思想是数据抽象(data abstraction)和封装(encapsulation)。**数据抽象是一种依赖于接口(interface)和实现(implementation)分离的编程(以及设计)技术。

类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。封装实现了类的接口和实现的分离。

封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。

类要想实现数据抽象和封装,需要首先定义一个抽象数据类型(abstract data type)。在抽象数据类型中,由类的设计者负责考虑类的实现过程;使用该类的程序员则只需要抽象地思考类型做了什么,而无须了解类型的工作细节。

1 定义抽象数据类型

定义在类内部的函数是隐式的inline函数。

1.1 引入 this

让我们再一次观察对isbn成员函数的调用:

total.isbn ()

在这里,我们使用了点运算符来访问total对象的isbn成员,然后调用它。隐式地返回 total.bookNo。

**成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。**当我们调用一个成员函数时,用请求该函数的对象地址初始化this。例如,如果调用total.isbn ()则编译器负责把total的地址传递给isbn的隐式形参this,可以等价地认为编译器将该调用重写成了如下的形式:

//伪代码,用于说明调用成员函数的实际执行过程
Sales_data: :isbn (&total)

其中,调用Sales data的isbn成员时传入了total的地址。在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符来做到这一点,因为this所指的正是这个对象。

**任何对类成员的直接访问都被看作this的隐式引用。**我们也可以在成员函数体内部使用this,因此尽管没有必要,但我们还是能把isbn定义成如下的形式:

std::string isbn () const {return this->bookNo;}

因为this的目的总是指向“这个”对象,所以this是一个常量指针,我们不允许改变this中保存的地址。

1.2 引入const成员函数

isbn函数的另一个关键之处是紧随参数列表之后的const关键字,const的作用是修改隐式this指针的类型。默认情况下,this的类型是指向类类型非常量版本的常量指针。例如在Sales data成员函数中,this的类型是Sales data *const。

尽管this是隐式的,但它仍然需要遵循初始化规则,因此在默认情况下我们不能把this绑定到一个常量对象上。这一情况也就使得我们不能在一个常量对象上调用普通的成员函数。

如果isbn是一个普通函数而且this是一个普通的指针参数,则我们应该把this声明成 const Sales data *const。毕竞,在isbn的函数体内不会改变this所指的对象,所以把this设置为指向常量的指针有助于提高函数的灵活性。

然而,this是隐式的并且不会出现在参数列表中,所以在哪儿将this声明成指向常量的指针就成为我们必须面对的问题。

**C++语言的做法是允许把const关键字放在成员函数的参数列表之后,,此时,紧跟在参数列表后面的const表示this是一个指向常量的指针。**像这样使用const的成员函数被称作常量成员函数(const member function)。可以把isbn的函数体想象成如下的形式:

//伪代码,说明隐式的this指针是如何使用的
//下面的代码是非法的:因为我们不能显式地定义自己的this指针
//谨记此处的this是一个指向常量的指针,因为isbn是一个常量成员
std: :string Sales_data::isbn (const Sales_data *const this)
{
 return this->isbn; 
}

因为this是指向常量的指针,所以常量成员函数不能改变调用它的对象的内容。在上例中,isbn可以读取调用它的对象的数据成员,但是不能写入新值。注意:常量对象,以及常量对象的引用或指针都只能调用常量成员函数。

1.3 类作用域和成员函数

类本身就是一个作用域。类的成员函数的定义嵌套在类的作用域之内,因此, isbn中用到的名字bookNo其实就是定义在Sales data内的数据成员。值得注意的是,即使bookNo定义在isbn之后,isbn也还是能够使用bookNo。

**因为:编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体。**因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。

1.4 定义一个返回this对象的函数

函数combine的设计初衷类似于复合赋值运算符+=,调用该函数的对象代表的是赋值运算符左侧的运算对象,右侧运算对象则通过显式的实参被传入函数:

Sales_data& Sales_data::combine (const Sales_data &rhs)
{
  units_sold += rhs.units_sold; //把rhs的成员加到this对象的成员上
  revenue += rhs. revenue;
  return *this;//返回调用该函数的对象
}

total. combine (trans);//更新变量total当前的值

total的地址被绑定到隐式的this参数上,,而rhs绑定到了trans上。因此,当combine执行下面的语句时:

units_sold += rhs.units_sold; //把rhs的成员添加到this对象的成员中

效果等同于求total.units sold和trans.unit sold的和,然后把结果保存到total.units sold中。

**该函数一个值得关注的部分是它的返回类型和返回语句。**一般来说,当我们定义的函数类似于某个内置运算符时,应该令该函数的行为尽量模仿这个运算符。内置的赋值运算符把它的左侧运算对象当成左值返回,因此为了与它保持一致,combine函数必须返回引用类型。因为此时的左侧运算对象是一个Sales data的对象,所以返回类型应该是Sales_data&

如前所述,我们无须使用隐式的this指针访问函数调用者的某个具体成员,而是需要把调用函数的对象当成一个整体来访问:

return *this;//返回调用该函数的对象

其中,return语句解引用this指针以获得执行该函数的对象,也就是说:上面的这个调用返回total的引用。

2. 定义类相关的非成员函数

类的作者常常需要定义一些辅助函数,比如add、read和print等。尽管这些函数定义的操作从概念上来说属于类的接口的组成部分,但它们实际上并不属于类本身。我们定义非成员函数的方式与定义其他函数一样,通常把函数的声明和定义分离开来。如果函数在概念上属于类但是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件内。在这种方式下,用户使用接口的任何部分都只需要引入一个文件。

一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类在同一个头文件内。

3. 构造函数

每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数(constructor)。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。

构造函数的名字和类名相同和其他函数不一样的是,构造函数没有返回类型;除此之外类似于其他的函数,构造函数也有一个(可能为空的)参数列表和一个(可能为空的)函数体。

类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。不同于其他成员函数,构造函数不能被声明成const的。当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性。因此,构造函数在const对象的构造过程中可以向其写值。

3.1 合成的默认构造函数

但是之前的Sales data类并没有定义任何构造函数,可是之前使用了Sales data对象的程序仍然可以正确地编译和运行。比如定义了两个对象:

Sales_data total;//保存当前求和结果的变量
Sales_data trans;//保存下一条交易数据的变量

那么 total和trans是如何初始化的呢?我们没有为这些对象提供初始值,因此我们知道它们执行了默认初始化。类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数(default constructor)。默认构造函数无须任何实参。

默认构造函数在很多方面都有其特殊性。其中之一是,如果我们的类没有显式地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。编译器创建的构造函数又被称为合成的默认构造函数( synthesized defaultconstructor)

对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:如果存在类内的初始值用它来初始化成员,否则,默认初始化该成员。

我们来为Scale_data定义构造函数:

struct Sales_data
{
    //新增的构造函数
    Sales_data () = default;
    Sales_data (const std: :string &s): bookNo (s) {}
    Sales_data (const std: :string &s, unsigned n, double p) :bookNo (s), units_sold (n), revenue (p*n) {}
    Sales data (std: :istream &);
    //之前已有的其他成员
    std: :string isbn () const{return bookNo;} 
    Sales_data& combine (const Sales_data&) ;
    double avg_price () const;
    std: :string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};

3.2 =default 的含义

下面这种写法:

Sales_data () = default;

首先请明确一点:因为该构造函数不接受任何实参,所以它是一个默认构造函数。我们定义这个构造函数的目的:仅仅是因为我们既需要其他形式的构造函数,也需要默认的构造函数。我们希望这个函数的作用完全等同于之前使用的合成默认构造函数。

在C1+11新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上=default来要求编译器生成构造函数。

其中,= default既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果= default在类的内部,则默认构造函数是内联的;如果它在类的外部,则该成员默认情况下不是内联的。

注意:

上面的默认构造函数之所以对Sales data有效,是因为我们为内置类型的数据成员提供了初始值。如果你的编译器不支持类内初始值,那么你的默认构造函数就应该使用构造函数初始值列表来初始化类的每个成员。

3.3 构造函数初始值列表

接下来我们介绍类中定义的另外两个构造函数:

Sales_data (const std: :string &s) : bookNo (s){}
Sales_data (const std: :string &s, unsigned n, double p) :bookNo (s), units_sold (n), revenue (p*n) {}

我们把新出现的部分称为构造函数初始值列表(constructor initialize list),它负责为新创建的对象的一个或几个数据成员赋初值。

units sold和revenue则没有显式地初始化。当某个效据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化。

在此例中,这样的成员使用类内初始值初始化,因此只接受一个string参数的构造函数等价于:

//与上面定义的那个构造函数效果相同
Sales_data (const std: :string &s):bookNo (s), units_sold (0), revenue (0){}

通常情况下,构造函数使用类内初始值不失为一种好的选择,,因为只要这样的初始值存在我们就能确保为成员赋予了一个正确的值。

不过,如果你的编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。

注意:

构造函数不应该轻易覆盖掉类内的初始值,除非新赋的值与原值不同。如果你不能使用类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。

4. 拷贝、赋值和析构

除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为。

对象在几种情况下会被拷贝,如我们初始化变量以及以值的方式传递或返回一个对象等。

当我们使用了赋值运算符时会发生对象的赋值操作。

当对象不再存在时执行销毁的操作,比如一个局部对象会在创建它的块结束时被销毁,当vector对象(或者数组)销毁时存储在其中的对象也会被销毁。如果我们不主动定义这些操作,则编译器将替我们合成它们。一般来说,,编译器生成的版本将对对象的每个成员执行拷贝、赋值和销毁操作。

4.1 某些类不能依赖于合成的版本

尽管编译器能替我们合成拷贝、赋值和销毁的操作,,但是必须要清楚的一点是,对于某些类来说合成的版本无法正常工作。特别是,当类需要分配类对象之外的资源时;合成的版本常常会失效。比如:管理动态内存的类通常不能依赖于上述操作的合成版本。

不过值得注意的是,很多需要动态内存的类能(而且应该)使用vector对象或者string对象管理必要的存储空间。使用vector或者string的类能避免分配和释放内存带来的复杂性。

进一步讲,如果类包含vector或者string成员,则其拷贝、赋值和销毁的合成版本能够正常工作。当我们对含有vector成员的对象执行拷贝或者赋值操作时,vector类会设法拷贝或者赋值成员中的元素。当这样的对象被销毁时,将销毁vector对象,也就是依次销毁vector中的每一个元素。这一点与string是非常类似的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Cpp编程小茶馆

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值