C++学习笔记(7):类

类的使用

类的定义

类定义是以关键字class开头,后跟类的名称,类的主体是包含在一对花括号中的。类的主体又包括由访问修饰符(private/public/protected)标记的一系列成员(包括成员变量和成员函数)。如:

1
2
3
4
5
6
7
class Box
{
public:
double length; // 盒子的长度
double breadth; // 盒子的宽度
double height; // 盒子的高度
};

实例化类

我们定义类就是要使用它,使用它就要创建一个该类的对象,称为该类的实例。定义一个Box类的对象的最简单方法就像定义一个内置类型的变量一样:

1
Box box;

成员与成员函数

声明和定义成员

类的成员变量直接在类中定义,成员函数则一般在类内声明、类外定义,如:

1
2
3
4
5
6
7
8
9
10
11
12
class Box
{
public:
double length;
double breadth;
double height;
// 成员函数声明
double volume();
};
double Box::volume() {
return length*breadth*height;
}

成员变量可以在类内指定初始值,但是类内初始值必须用=来指定,不能使用其他方式初始化对象。

在类外定义成员函数时一定要指定命名空间(类名)。

通常情况下,类的定义在头文件中,成员函数的定义在源文件中

访问修饰符

访问修饰符有private/public/protected三种,分别给成员了不同的访问权限。访问修饰符使得类的封装和抽象特性成为了可能,即封装不必要的细节,只给用户暴露使用的接口。

  • public:表示是公开的,修饰对象可以直接调用的变量或者函数,在程序内可以直接访问。
  • protect:表示是保护性的,只有本类和子类函数能够访问(只是访问,本类对象和子类对象都不可以直接调用)。
  • private:只有在本类中能够访问(友元函数例外)。

一个类内可以包含0个或多个访问说明符,每个访问说明符也可以出现多次,其有效范围直到出现下一个访问说明符或到达类的结尾。

内联函数

类的成员函数也可以定义为内联的,主要有三种:

  • 直接在类内部定义的成员函数默认是内联的。
  • 在类内部使用inline关键字声明,在类外部定义。
  • 在类内部声明,在类外部使用inline关键字定义。

为规范代码,建议第二种和第三种方式合并,在类内部声明和类外部定义时都使用inline关键字,此时,内联函数的定义应该放在类定义的同一头文件中,而不是在源文件中,这样保证内联函数的定义在调用该函数的每个源文件中是可见的。

访问和使用成员

类的成员变量和成员函数都通过对象的成员访问运算符来使用,如:

1
2
box.length = 30;
box.volume();

struct类

struct类是为了兼容C语言的结构体而保留下来的特性,在C++中,struct类与class类大部分是相同的,只有几个细节不同:

  • 使用 class 时,类中的成员默认都是 private 属性的;而使用 struct 时,结构体中的成员默认都是 public 属性的。
  • class 继承默认是 private 继承,而 struct 继承默认是 public 继承。
  • class 可以使用模板,而 struct 不能。

类支持的特性

this标记

对成员函数的调用实际上是对象调用的,而一个类可以定义多个对象,那么在成员函数运行中,它需要知道是哪个对象调用了它。因此每个成员函数都有一个隐式的参数this,不要手动传参就可以直接在函数里使用,它实际上是调用成员函数的那个对象的指针,这样通过this指针就可以确定调用该成员函数的那个对象。

在成员函数内使用成员变量时并不需要通过成员访问运算符访问this的成员,而是可以直接使用名字。

const成员函数

在成员函数形参列表的后面、左花括号的前面加上const关键字即可声明、定义为const成员函数。

const成员函数的作用实际是改变了this的类型,如Box类型的this指针是个指针常量,即Box * const,因为this的值不能改变。而const函数将this指针变为了const Box * const,即指向常量的指针常量,既有底层const又有顶层const。

这样做的原因是如果this指针是Box * const类型,那么它无法绑定到一个常量对象上,因为会非法取得写权限,而const Box * const则是可以的。如果在成员函数中不会改变this指向对象的成员就应该这样声明,这样使得成员函数的灵活性更强了。

总之,常量对象及其引用和指针都只能调用其常量成员函数,否则this无法正确绑定。

类的作用域

类作用域

类本身就是一个作用域,类的成员函数的作用域嵌套在类作用域之中。

凡是嵌套的作用域,同名名称都遵循屏蔽机制。

假设Box类的成员函数volume在成员变量之前声明,而volume中访问了三个成员变量,这样看似是不可以的,因为此时三个成员变量好像还没定义,其实不是的,因为编译器会先编译类的定义(及其里面的成员声明),之后才编译成员函数的定义,此时成员变量已经被编译过了,就不会有问题。

在外部定义的成员函数必须与它的声明完全匹配。

返回this的成员函数

这里说返回this其实没什么难度,以Box为例,我们已经知道了this的类型是Box的指针,那么把函数的返回值类型定义为Box *就可以了。

而这样返回的是一个指针,我们可以通过返回*this直接返回这个对象本身,即解引用后再返回 ,那么返回值类型就应该定义为Box &了,返回的是对象的引用,可以直接作为左值使用。

类内别名

在类内也可以为类型取一个别名,也分为放在public和private下的情况,一般放在public下定义,如:

1
2
3
4
5
6
7
8
class screen {
public:
typedef std::string::size_type pos;
private:
pos cursor = 0;
pos height = 0, width = 0 ;
std: :string contents;
};

这样对于这个类来说,在类内类外都可以使用pos这个名字,相当于对外屏蔽了pos这个名字的实际细节。

除了typedef之外,使用using定义类型别名是等价的。

类型别名在类内,因此应当先声明后使用,因此常常把它放在类的开头。其实是,当类内已经使用了原来的名字之后,就不能再为该类型定义别名了。

可变数据成员

有时我们希望即使在const成员函数内我们依然能够改变某个成员变量的值,就可以把它声明为mutable变量。

mutable变量永远不可能是const的,即使它是const对象的成员,也就是const对象还是可以改变它的可变成员的值。

有如下的例子:

1
2
3
4
5
6
7
8
9
10
class screen {
public:
void some_member() const;
private:
mutable size_t access_ctr;
};
void screen::some_member() const
{
++access_ctr;
}

在类中定义了一个mutable成员access_ctr,并且在const函数中依然能改变它的值,从而实现记录该成员函数被调用了多少次。

类的声明

我们可以像函数一样把类的声明和定义分开,我们可以这样声明:

1
class Screen; 

这样的声明又叫做前向声明。它向程序中引入了名字Screen并且指明Screen是一种类类型。对于类型Screen来说,在它声明之后定义之前是一个不完全类型,即此时我们已知Screen是一个类类型,但是不清楚它到底包含哪些成员。

不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。

聚合类

聚合类是一种特殊的类,它满足以下条件:

  • 所有类成员都是public的。
  • 没有定义任何构造函数。
  • 没有类内初始值。
  • 没有基类,也没有virtual函数

比如以下类就都是聚合类:

1
2
3
4
5
6
7
8
9
struct Data1 {
int ival;
string s;
};
struct Data2 {
public:
int ival;
string s;
};

对于聚合类,我们是可以直接访问其所有数据成员的,并且我们可以使用列表来初始化它,如:

1
Data1 val1 = { 0"Anna" };

这里提供的初始值的顺序必须与声明的成员变量的顺序一致

与初始化数组元素的规则一样,如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化,且初始值列表的元素个数绝对不能超过类的成员数量。

聚合类最大的缺陷是它的初始化需要我们来操心,并且当类成员变量改变时,我们要修改每一条初始化的代码。

字面值常量类

之前我们说constexpr函数的参数和返回值必须是字面值类型,这里有一种特殊的类也属于字面值类型,即字面值常量类。

字面值常量类满足以下要求:

  • 是聚合类,同时它的数据成员都是字面值类型。
  • 不是聚合类,但满足以下条件:
    • 数据成员都必须是字面值类型。
    • 类必须至少含有一个constexpr构造函数。
    • 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
    • 类必须使用析构函数的默认定义,该成员负责销毁类的对象。

尽管构造函数不能是const的,但是字面值常量类的构造函数可以是constexpr函数,一个字面值常量类必须至少提供一个constexpr构造函数

constexpr构造函数可以声明成=default的形式(或者是删除函数的形式)。

constexpr构造函数必须既符合构造函数的要求(意味着不能包含返回语句),又符合constexpr函数的要求(意味着它能拥有的唯一可执行语句就是返回语句),因此综合这两点可知,constexpr构造函数体应该是空的。我们通过前置关键字constexpr就可以声明一个constexpr构造函数了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Debug {
public:
constexpr Debug(bool b = true) : hw(b), io (b), other(b) { }
constexpr Debug (bool h, bool i, bool o) :
hw(h), io(i), other(o) { }
constexpr bool any() { return hw || io || other;}
void set_io (bool b) { io = b; }
void set_hw(bool b) { hw = b; }
void set_other(bool b) { hw = b; }
private:
bool hw; // 硬件错误,而非IO错误
bool io; // IO错误
bool other; // 其他错误
};

构造函数体只能是空,因此能做的事情只有初始值列表了,并且constexpr构造函数必须初始化所有数据成员,初始值也只能使用constexpr表达式或者常量表达式。

静态成员

声明静态成员

在成员的声明之前加上static关键字使其成为静态成员,这样它就成了只与类相关的成员,由该类的所有对象共用

类的静态成员存在于任何对象之外,对象中不包含任何与静态成员有关的数据。同样的静态成员函数也不与任何对象绑定在一起,它们也不含this指针。(不含this指针也使得静态成员函数不能声明为const的)

我们无法在static函数体内使用this指针,不光是不能显式使用,也不能隐式使用,即无法调用非静态的成员函数。

使用静态成员

使用作用域运算符可以直接访问类的静态成员,如:

1
2
double r;
r = Account::rate();

此外,虽然静态成员不属于类的某个对象,但我们仍可以使用类的对象、引用或者指针来访问静态成员:

1
2
3
4
Account ac1;
Account *ac2 = &ac1;
r = ac1.rate();
r = ac2->rate();

类的成员函数不通过作用域运算符就可以直接使用静态成员,因为成员函数和静态成员本就是在同一作用域中的(类作用域)。

定义静态成员

定义静态成员函数和别的成员函数一样,既可在类内定义,也可在类外。在类外定义时要注意static关键字只能在类内声明时使用,在类外定义时不能重复使用。

而静态数据成员不属于任何一个对象,因此它们不能通过构造函数初始化,同时我们又不能在类内部初始化静态成员,因此静态数据成员也只能在类内声明、类外定义,如:

1
double Account::interestRate = initRate();

在这里,interestRate是Account类的成员变量,而initRate是其成员函数,可是这里没有对initRate加作用域运算符,是因为从interestRate前的作用域运算符开始,这条定义语句就已经是在Account作用域里了,因此其成员都可以直接使用,包括私有成员。

有一种特殊的静态成员可以在类内初始化,这要求静态成员必须是constexpr类型,初始值必须是常量表达式,如:

1
static constexpr int period = 30;

这样的静态成员可以作为constexpr用在任何常量表达式中。但是要注意,这样的类内定义使得该静态成员只能在类内使用,如果要在类外使用时,必须要在类外再次声明

1
constexpr int Account::period;

由于在类内提供了初始值,就不能在类外再次初始化了,而只能声明。

静态成员的独特用处

静态成员独立于任何对象,因此,在某些非静态数据成员可能非法的场合,静态成员却可以正常地使用。

(1)静态数据成员可以是不完全类型,比如静态成员的类型可以是它所属类的类型,而非静态类型只能声明为它所属类的指针或引用:

1
2
3
4
5
6
class Bar {
private:
static Bar mem1; // 正确:静态成员可以是不完全类型
Bar *mem2 ; // 正确:普通成员可以是不完全类型的指针
Bar mem3 ; // 错误:普通成员必须是完全类型
};

(2)我们可以使用静态成员作为成员函数的默认实参,而非静态成员不可以。因为静态成员的值的生命期是存在于整个程序运行过程的,而非静态成员的值属于对象,在某些时候用作默认实参时可能该成员还没有有意义的值。

构造函数

构造函数的定义

构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。

构造函数的名字和类名相同,构造函数没有返回类型。

类可以包含多个构造函数,即重载构造函数,不同的构造函数之间必须在参数数量或参数类型上有所区别。类的成员函数也是可以重载的。

不同于其他成员函数,构造函数不能被声明成const的

当创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性,因此,构造函数在const对象的构造过程中可以向其写值

默认构造函数

当定义一个对象时,没有给它提供任何值,那么就会调用默认构造函数来初始化对象,即默认构造函数就是没有形参的构造函数,这个构造函数分为用户显式定义的和编译器自动合成的。

没有为类指定构造函数时,编译器通过合成的默认构造函数来初始化对象,合成的默认构造函数做的事情有:

  • 如果存在类内初始值,用它来初始化成员。
  • 其他成员进行默认初始化。

但是要注意,这个合成的默认构造函数只在没有一个构造函数时才会自动合成,而如果定义了带参数的构造函数,则必须显式定义一个默认构造函数,否则没有形参地初始化一个对象时,将找不到对应的构造函数。

显式定义一个默认构造函数的另一个原因是合成的默认构造函数可能执行不合理的操作:默认构造函数将默认初始化没有初始值的成员变量,而默认初始化的成员变量是无意义的随机值,无法正常使用。

此外,某些情况下编译器可能无法为类合成默认构造函数,如类中含有某一类型的成员变量,而这种类型没有默认构造函数,那么就无法默认初始化这个成员变量,那么包含了这种类型的成员变量的类也无法合成默认初始化函数。

在C++11标准中我们可以在定义了其他构造函数的情况下让编译器依然生成合成的默认构造函数,只需要在形参列表后加上=default标记即可:

1
Box() = default;

如果=default标记在类内,那么该构造函数为内联的。

还有一种情况,我们说没有形参的构造函数为默认构造函数,但如果有一个构造函数的所有形参都指定了默认实参,那么这个函数也可以认为是默认构造函数,这个类就相当于定义了默认构造函数。

带参数的构造函数

带参数的构造函数可以大致分为两种:分别是只需要对成员变量进行初始化的构造函数和需要进行其他操作的构造函数。

(1)只需要对成员变量进行初始化的构造函数通常使用构造函数初始值列表来实现,并声明为内联函数。

构造函数初始值列表的使用方式如下:

构造函数名(形参列表) : 成员变量1(表达式1), 成员变量2(表达式2)… { }

即在形参列表后跟冒号,然后标注用来初始化各个成员变量的各个表达式,此外不需要进行其他操作,因此函数体为空,如:

1
sales_data (const std::string &s) : bookNo(s) { }

未标注的成员变量还将使用默认初始化的方式来进行初始化。

在某些情况下必须要用构造函数初始值的方式来定义构造函数,比如这个类:

1
2
3
4
5
6
7
8
class constRef {
public:
ConstRef(int ii);
private:
int i;
const int ci;
int &ri;
};

成员变量ci是const的,代表它不能使用赋值操作,只能进行初始化,而构造函数初始值就相当于对成员变量的初始化,如果不显式定义构造函数的话是不行的,因为const常量必须进行初始化,而不使用初始值的构造函数也是不行的,就像下面这样:

1
2
3
4
5
6
constRef: :ConstRef(int ii)
{
i = ii; // 正确:i可以被赋值
ci= ii; // 错误:不能给const赋值
ri = i; // 错误:引用也要初始化
}

由于const常量和引用都是必须初始化的,之后不能再赋值了,在这种情况下就是必须要使用带有初始值列表的构造函数。

总之,如果是const常量、引用或者是未提供默认构造函数类型的成员变量,都必须要有初始值来初始化,因此要使用带初始值列表的构造函数。此外,带初始值列表的构造函数的效率更高,一个是直接用初始值初始化,一个是先初始化之后再赋值。因此应该尽可能用构造函数初始值。

还有需要注意的一点是,初始值列表中的初始化顺序是未定义的,不应该使用一个成员变量来初始化另一个成员变量,因为它们的初始化顺序未知。

(2)需要进行其他操作的构造函数通常声明在类内,定义在类外,不作为内联函数,如:

1
2
3
sales_data::Sales_data(std::istream &is) {
read(is, *this);
}

同样,没有在函数中初始化的成员变量将执行默认初始化。

类外定义时要注意名字的命名空间。

委托构造函数

这是C++11的新标准,它扩展了构造函数的初始值的功能。

委托构造函数实际上就是委托其他已有的构造函数来完成一部分功能,有如下的例子:

1
2
3
4
5
6
7
8
9
10
11
class sales_data {
public:
// 非委托构造函数
sales_data(std::string s, unsigned cnt, double price) :
bookNo(s), units_sold(cnt), revenue (cnt*price) { }
// 其余构造函数全都委托给第一个构造函数
sales_data() : sales_data(""00){}
sales_data(std::string s) : sales_data(s, 0, 0) {}
sales_data(std::istream &is) : sales_data ()
{ read(is, *this); }
};

我们可以看到只有第一个构造函数不是委托构造函数,是用来使用初始值构造对象的,而其余三个构造函数都需要第一个函数提供的功能,就像委托第一个构造函数做了一部分任务一样,在我们看来这个过程(冒号后的过程)就像是函数调用一样。

当一个构造函数委托给另一个函数时,执行顺序为:受委托的函数的初始值列表、受委托的函数的函数体、委托者的函数体。

隐式的类类型转换

转换构造函数

如果类的构造函数只接收一个实参,那么这个构造函数被称为转换构造函数,这样就定义了从一种类型(参数的类型)转换为该类型的方式。

如在sales_data类中分别定义了接受string一个参数的构造函数和接收istream一个参数的构造函数,那么就相当于分别定义了这两种类型向sales_data类转换的规则,也就是在需要使用sales_data类的地方我们可以用string和istream代替,这时会自动进行隐式类型转换。但是这样的类型转换只允许进行一步,两步及以上的这种隐式类型转换是不行的,如:

1
item .combine ( "9-999-99999-9");

就是不行的,因为这需要把”9-999-99999-9”(char *)转换为string再转换为sales_data类型,需要两步转换,这就不可以了。原因是char *也许也能转换为其他某类型,不光能转换为string,而这个“其他某类型”也许也能转换为sales_data类型,那么就有了两条可选的路径:

  • char * -> 其他某类型 -> sales_data类型
  • char * -> string -> sales_data类型
  • ……

这样就导致了编译器并不知道应该选择哪条转换路径,而不同的转换路径得到的结果可能是不同的。

抑制隐式类型转换

如果想阻止只有一个参数的构造函数成为转换构造函数,只需要在声明时加上explicit,但是要注意,只有在类内声明时才能使用explicit关键字,在类外定义时就不能重复使用了。

由于explicit关键字阻止一个构造函数成为转换构造函数,因此只用于只有一个形参的构造函数,多个形参和没有形参的构造函数则不需要这样做,因为它们本来就不可能成为转换构造函数。

此外使用explicit关键字还会导致该构造函数只能用于直接初始化,而不能作为拷贝初始化,因为某种意义上来看,拷贝初始化似乎也是先进行了隐式类型转换。

如果想使用声明为explicit的构造函数来类型转换也是可以的,注意它只是抑制了隐式类型转换,依然是可以进行强制类型转换的,如:

1
2
item.combine(sales_data(null_book));
item.combine(static_cast<Sales_data>(cin));

在标准库中有两个常用的构造函数要知道:

  • 接受一个const char *参数的string构造函数不是explicit的。
  • 接受一个容量参数的vector构造函数是explicit的。

友元

友元函数

类本来不允许其他的类和函数访问它的私有成员的,但是通过友元可以实现。

如果想将函数声明为类的友元,则需要在类中(通常在类的开始)使用friend关键字对友元函数进行声明,就像声明函数一样,无非是多了个friend关键字,如:

1
friend std::istream &read(std::istream&, Sales_data&);

友元函数的声明是和函数声明独立的,函数还是要经过正常的声明才能使用。因此友元函数的外部声明也是要在类的声明之前声明一下的,我们通常把它们一起放在类的头文件中。

尽管重载函数的名字相同,但它们仍然是不同的函数。因此,如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明

友元类

类可以把其他类声明为友元,也可以把其他类的成员函数声明为友元。

将类声明为友元类的方式和函数差不多,也是使用friend关键字在类内声明一下,如:

1
2
3
class Screen {
friend class Window_mgr;
}

这样就把类Window_mgr声明为了类Screen的友元类,Window_mgr类内可以直接访问Screen的成员。

如果类Window_mgr中只有某些成员函数用到了Screen的成员,那就不需要把整个类声明为友元,而只需要把需要的成员函数声明为友元,如:

1
2
3
class screen {
friend void window_mgr::clear(screenIndex) ;
};

将一个名字声明为友元时,我们就认为该名字在当前作用域中是可见的,然而,该名字本身并不一定就能在当前类作用域内访问,因为要先声明才能访问,而友元声明不是声明,因此我们要在类外再把该友元类或友元函数单独声明一次,我们才能使用它。

甚至就算在类的内部定义该友元函数,该函数是默认内联的,我们也必须在类的外部提供相应的声明从而使得函数可见。有以下例子:

1
2
3
4
5
6
7
8
9
struct x {
friend void f() { /*内联的友元函数*/ }
x() { f(); } // 错误:f定义了但还没有被声明
void g();
void h();
};
void x::g() { return f(); } // 错误:f还没有被声明
void f(); // 这时f函数声明了
void x::h() { return f(); } // 正确:现在f已经声明了



* 你好,我是大森。如果文章内容帮到了你,你可通过下方付款二维码支持作者 *