C++ 01_C++类和对象

一、C++类(Class)和对象(Object)的概念

1.1 类(Class)

C++ 是一门面向对象的编程语言,理解 C++,首先要理解 类(Class)对象(Object) 这两个概念。

C++ 中的 类(Class) 可以看做是 C语言 中结构体(Struct)的升级版。C语言的结构体是一种构造类型,可以包含若干成员(数据类型字段),每个成员的类型可以不同;可以通过结构体来定义结构体变量,定义的同一种结构体变量拥有相同的性质(成员字段)。

C++ 中的 类(Class) 也是一种构造类型,但是进行了一些扩展,类的成员不但可以是 数据类型,还可以是 函数(称为类的方法)。

C语言中定义 struct 的成员只能包含数据类型字段,而 C++ 中的 class 除了可以包含数据类型字段,还可以包含函数。在C语言中,函数只能放在了 struct 外面,它和结构体成员变量是分离的;而在 C++ 中,可以将函数放在了 class 内部(叫做 类的方法),使它和成员变量聚集在一起,看起来更像一个整体。

类(Class) 的成员变量也称 属性(Property)类(Class) 的成员函数称为 方法(Method)

类只是一种复杂数据类型的声明,不占用内存空间。

1.2 对象(Object)

结构体(struct)和 类(class)都是一种由用户自己定义的复杂数据类型,在C语言中可以通过结构体名来定义变量,在 C++ 中可以通过类名来定义变量。不同的是,通过结构体定义出来的变量还是叫变量,而通过类定义出来的变量有了新的名称,叫做 对象(Object)

在 C++ 中,通过 类名 就可以创建 对象(Object),这个过程叫做"类的实例化",因此也称 对象(Object)类(Class) 的一个 实例(Instance)

类(Class) 是创建 对象(Object) 的模板,一个类可以创建多个对象,每个对象都是其类名类型的一个变量;创建对象的过程也叫类的实例化。每个对象都是类的一个具体实例(Instance),拥有类的成员变量和成员函数。

对象 是类这种数据类型的一个变量,或者说是通过类这种数据类型创建出来的一份实实在在的数据,所以对象占用了内存空间。

属性方法 都是类的成员,创建对象后就可以通过点号 . 来使用它们。

可以将 类(Class) 比喻成图纸,对象(Object) 比喻成零件,图纸说明了零件的参数(成员变量)及 其承担的任务(成员函数);一张图纸可以生产出多个具有相同性质的零件,不同图纸可以生产不同类型的零件。

与结构体一样,类(Class) 只是一种复杂数据类型的声明,不占用内存空间。而对象是类这种数据类型的一个变量,或者说是通过类这种数据类型创建出来的一份实实在在的数据,所以占用内存空间。

类(Class) 只是一张图纸,起到说明的作用,不占用内存空间;对象(Object) 才是具体的零件,要有地方来存放,才会占用内存空间。

1.3 面向对象编程(Object Oriented Programming,OOP)

什么是面向对象编程?

类是一个通用的概念,C++、Java、C#、PHP 等很多编程语言中都支持类,都可以通过类创建对象。可以将类看做是结构体的升级版,C语言的晚辈们看到了C语言的不足,尝试加以改善,继承了结构体的思想,并进行了升级,让程序员在开发或扩展大中型项目时更加容易。

因为 C++、Java、C#、PHP 等语言都支持类和对象,所以使用这些语言编写程序也被称为 面向对象编程,这些语言也被称为面向对象的编程语言。C语言因为不支持类和对象的概念,被称为面向过程的编程语言。

在C语言中,我们会把重复使用或具有某项功能的代码封装成一个函数,将拥有相关功能的多个函数放在一个源文件,再提供一个对应的头文件,这就是一个模块。使用模块时,引入对应的头文件就可以。

而在 C++ 中,多了一层封装,就是类(Class)。类由一组相关联的函数、变量组成,我们可以将一个类或多个类放在一个源文件,也提供一个对应的头文件,构成一个模块。使用时通过头文件引入对应的类就可以。下面是C和C++项目组织方式的对比:

图1:C语言中项目的组织方式(面向过程编程)

图2:C++中项目的组织方式(面向对象编程)

不要小看类(Class)这一层封装,它有很多特性,极大地方便了中大型程序的开发,它让 C++ 成为 面向对象的语言

面向对象编程 在代码执行效率上绝对没有任何优势,它的主要目的是方便程序员组织和管理代码,快速梳理编程思路,带来编程思想上的革新。

面向对象编程是针对开发中大规模的程序而提出来的,目的是提高软件开发的效率。不要把面向对象和面向过程对立起来,面向对象和面向过程不是矛盾的,而是各有用途、互为补充的。

二、C++类(Class)和对象(Object)基础详解

2.1 定义类(class)和 对象(Object)

2.1.1 定义类(class)

类(class) 是用户自定义的类型,如果程序中要用到类,必须提前说明,或者使用已存在的类(别人写好的类、标准库中的类等),C++语法本身并不提供现成的类的名称、结构和内容。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Student{
public:
    //成员变量
    char *name;
    int age;
    float score;
    //成员函数
    void say(){
        cout<<name<<"的年龄是"<<age<<",成绩是"<<score<<endl;
    }
};

class 是 C++ 中的关键字,专门用来定义类,与C语言中的 struct 相同,在 class 后面是 类的名称(类名),类名的首字母一般大写,以和其他的标识符区分开;{ }内部是类所包含的 成员变量 和 成员函数,它们统称为类的 成员(Member);由 { } 包围起来的部分有时也称为 类体,和函数体的概念类似。

C++ 中保留了C语言的 struct 关键字,并且加以扩充。在C语言中,struct 只能包含成员变量,不能包含成员函数。而在C++中,struct 类似于 class,既可以包含成员变量,又可以包含成员函数。 C++中的 struct 和 class 基本是通用的,唯有几个细节不同:

  • 使用 class 时,类中的成员默认都是 private 属性的;而使用 struct 时,结构体中的成员默认都是 public 属性的。
  • class 继承默认是 private 继承,而 struct 继承默认是 public 继承(《C++继承与派生》一章会讲解继承)。
  • class 可以使用模板,而 struct 不能(《模板、字符串和异常》一章会讲解模板)。

在编写C++代码时,建议使用 class 来定义类,使用 struct 来定义结构体,这样做语义更加明确。

public 是 C++ 的关键字,它只能用在类的定义中,表示类的成员变量或成员函数具有“公开”的访问权限。

Tips: 在定义类时,C++通过 public、protected、private 三个关键字来控制成员变量和成员函数的访问权限,它们分别表示公有的、受保护的、私有的,被称为 成员访问限定符。所谓访问权限,就是你能不能使用该类中的成员。

注意:在类定义的最后有一个分号;,它是类定义的一部分,表示类定义结束了(C++ 语句结束符),不能省略。

类是一种新的数据类型,该数据类型的名称是 用户定义时指定的标识符。与 char、int、float 等基本数据类型不同的是,用户定义的类 是一种复杂数据类型,可以包含基本类型,而且还有很多基本类型中没有的特性,后续逐步讲解。

类只是一个模板(Template),编译后不占用内存空间,所以在定义类时不能对成员变量进行初始化,因为没有地方存储数据。只有在创建对象以后才会给成员变量分配内存,这个时候就可以赋值了。

Tips: 类通常定义在函数外面,当然也可以定义在函数内部,不过很少这样使用。

2.1.2 定义类的对象(Object)

定义好一个类后就可以使用这个类名来创建 **对象(object)**了, 这和使用基本类型定义变量的形式类似,从这个角度考虑,我们可以把 一个类名 看做一种新的数据类型。 C++中有两种创建对象的方式:

  • 一种是在栈上(局部对象) 或 data段上(全局或静态对象)创建,形式和定义普通变量类似;
  • 一种是在堆上使用 new 关键字创建,必须要用一个指针指向它,读者要记得 delete 掉不再使用的对象。

通过对象名字访问成员使用 点号 .,通过对象指针访问成员使用 箭头 ->,这和结构体非常类似。

  • C++类创建对象

使用类名来创建对象时,class 关键字可要可不要,但是出于习惯我们通常会省略掉 class 关键字,除了可以使用类名来创建单个对象,我们还可以创建对象数组。

在栈上(局部对象) 或 data段上(全局或静态对象) 创建对象 和 对象数组:

1
2
3
4
5
6
// 创建单个对象
class Student stu;  // 正确
Student stu;  // 同样正确

//创建对象数组
Student allStu[100];  // 该语句创建了一个 allStu 数组,它拥有100个元素,每个元素都是 Student 类型的对象

创建的对象是在 栈上(局部对象) 或 data段上(全局或静态对象) 分配内存的。

也可以在堆上创建对象,这个时候就需要使用C++的 new关键字了(C++ new和delete运算符简介)。 例如:

1
Student *pStu = new Student; // 使用C++的 ```new```关键字在堆上创建对象

在栈上创建出来的对象一般都有一个名字,比如 stu,使用指针指向它不是必须的。 但是通过 new 创建出来的对象是在堆上分配内存的,它没有名字,只能得到一个指向它的指针,所以必须使用一个指针变量来接收这个指针,否则创建后也无法找到这个对象了,更没有办法使用它。 也就是说,使用 new 在堆上创建出来的对象是匿名的,没法直接使用,必须要用一个指针指向它,再借助指针来访问它的成员变量或成员函数。

Tips:

  • 栈内存是程序自动管理的,不能使用 delete 删除在栈上创建的对象;
  • 堆内存由程序员管理,对象使用完毕后可以通过 delete 删除。
  • 在实际开发中,new 和 delete 往往成对出现,以保证及时删除不再使用的对象,防止无用内存消耗和堆积。
  • C++访问对象的成员

创建对象以后,使用点号 . 来访问 成员变量 和 成员函数,这和通过结构体变量来访问它的成员类似; 也可创建对象指针,通过箭头 -> 来访问对象的成员变量和成员函数,这和通过结构体指针来访问它的成员类似。

Tips:C语言中经典的指针在 C++ 中仍然广泛使用,尤其是指向对象的指针,没有它就不能实现某些功能。

2.2 类的成员变量和成员函数详解

类(class) 是一种自定义数据类型,它类似于普通的数据类型(有普通数据类型的特性),但是又有别于普通的数据类型(有自身特殊的特性)。类这种数据类型是一个包含 成员变量成员函数 的集合。

2.2.1 类的成员变量

类的成员变量 也称作 类的属性,它和普通变量一样,也有数据类型和名称,并占用固定长度的内存。但是,在定义类的时候不能对成员变量赋值,因为类只是一种数据类型或者说是一种模板,本身不占用内存空间,只有类实例化创建对象(变量或常量)后成员变量才实际占用内存空间。

类的成员变量 可以是任意确定的类型、包括类类型 及 指针类型等,类型可以出现多次(同数据类型的 变量/属性 名不同即可)。

2.2.2 类的成员函数

类的成员函数 一般称做 类的方法,它们和普通函数一样,都有函数名、返回值 和 参数列表等,它们与一般函数的区别是:成员函数是一个类的成员,出现在类体中,它的作用范围由类来决定;而普通函数是独立的,作用范围是全局的,或位于某个命名空间内。

类的方法 可以在类体中定义(包含函数名、返回类型、函数参数 和函数体),也可以只在类体中声明方法签名(只包含函数名、返回类型 和 函数参数,而不包括函数体),将函数定义放在类体外面。

在类体外定义方法时 必须要在方法名前面加上类名予以限定作用域,类名与方法名之间使用 ::(被称为域解析符、也称作用域运算符 或 作用域限定符)来连接,以指明定义的方法属于哪个类。

在类体中直接定义方法时 不需要在方法名前面加上类名,因为函数属于哪一个类是不言而喻的。

成员函数必须先在类体中作原型声明,才可以在类外定义,也就是说类体的位置应在函数定义之前。

在类体中和类体外定义成员函数的区别:

  • 在类体中定义的成员函数编译时编译器会自动成将其当做 内联函数 进行编译。
  • 在类体外定义的成员函数编译时编译器不会自动成将其当做 内联函数 进行编译,在类体内部定义的成员函数需要在定义处额外添加 inline 关键字进行修饰才能被当做内联函数来镜像处理。

Tips: 内联函数一般不是我们所期望的,它会将函数调用处用函数体替代,所以如果不要把成员函数当做内联函数处理时,应该在类体内部对成员函数作声明,在类体外部进行定义,这是一种良好的编程习惯,实际开发中大家也是这样做的。

一般是在函数比较短小,为了避免函数调用时的性能开开销(函数调用时需要创函数调用栈来保存函数参数、返回地址等)才会把函数定义为内联函数。

如果你既希望将函数定义在类体外部,又希望它是内联函数,那么可以在定义函数时加 inline 关键字。当然你也可以在函数声明处加 inline,不过这样做没有效果,编译器会忽略函数声明处的 inline。

在类体外定义 inline 函数的方式,必须将类的定义和成员函数的定义都放在同一个头文件中(或者同一个源文件中),否则编译时无法进行嵌入(将函数代码的嵌入到函数调用出)。

类定义示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Student{
public:
    //成员变量
    char *m_name;
    int m_age;
    float m_score;


    void read(){  // 在类体中直接定义 read方法, 编译时编译器自动成将其当做 内联函数 进行编译
        std::cout << m_name << "的年龄是" << m_age <<",成绩是" << m_score << std::endl;
    }

    // 在 Student 类体中声明 方法,
    void setAge(int age);
    void write(); 
};
inline void Student::setAge(int age) // 在类体外定义 setAge方法, setAge方法需先在 Student 类体中声明, 需要使用 inline 关键字 明确告诉编译器把该函数当做内联函数进行编译(编译时编译器不会自动成将其当做 内联函数 进行编译)
{
    m_age = age;
}

void Student::write(){  // 在类体外定义 write方法, write方法需先在 Student 类体中声明,  编译时编译器不会自动成将其当做 内联函数 进行编译
    std::cout << m_name << "的年龄是" << m_age <<",成绩是" << m_score << std::endl;
}

2.2.3 C++类成员的访问权限

C++中定义类时,通过 public、protected、private 三个关键字来控制 成员变量成员函数访问权限,它们分别表示公有的、受保护的、私有的,被称为 成员访问限定符。所谓访问权限,就是你能不能使用该类中的成员。

C++定义类时,在类体中public、protected、private成员访问限定符对成员按访问权限进行分组:

  • public限定符 之后定义的成员为公有的,直到遇到其它限定符或类定义结束为止;
  • protected限定符 之后定义的成员为受保护的(类继承是再介绍),直到遇到其它限定符或类定义结束为止;
  • private限定符 之后定义的成员为私有的,直到遇到其它限定符或类定义结束为止;

C++ 中的 public、protected、private 只能在类定义时修饰类的成员 或 在类派生时修饰基类的继承方式(控制基础访问权限) ,不能独立的修饰类,C++中的类没有公有私有之分。

类的声明和成员函数的定义都是类定义的一部分,在实际开发中,我们通常将类的声明放在头文件中,而将成员函数的定义放在源文件中。

在类的内部(定义类的代码内部),无论成员被声明为 public、protected 还是 private,都是可以互相访问的,没有访问权限的限制。

在类的外部(定义类的代码之外),只能通过对象访问成员,并且通过对象只能访问 public 属性的成员,不能访问 private、protected 属性的成员。 示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <iostream>
using namespace std;
//类的声明
class Student{
private:  // 私有的
    char *m_name;
    int m_age;
public:  // 共有的
    void setname(char *name);
    void setage(int age);
    void show();

    float m_score;
};
//成员函数的定义, 在类的内部(定义类的代码内部),无论成员被声明为 public、protected 还是 private,都是可以互相访问的,没有访问权限的限制。
void Student::setname(char *name){
    m_name = name;
}
void Student::setage(int age){
    m_age = age;
}
void Student::show(){
    cout<<m_name<<"的年龄是"<<m_age<<",成绩是"<<m_score<<endl;
}

// 在类的外部(定义类的代码之外),只能通过对象访问成员,并且通过对象只能访问 public 属性的成员,不能访问 private、protected 属性的成员。
int main(){
    //在栈上创建对象
    Student stu;
    stu.setname("小明");
    stu.setage(15);
    stu.m_score = 92.5f;
    stu.show();
    //在堆上创建对象
    Student *pstu = new Student;
    pstu->setname("李华");
    pstu->setage(16);
    pstu->m_score = 96;
    pstu->show();
    return 0;
}

声明为 private 、protected 和 public 的成员的次序是任意的,既可以先出现 private 部分,也可以先出现 public 或 protected部分。如果既不写 private 也不写 public 或 protected,就默认为 private。

Tips:

  • class 定义类时 不写访问修饰符类成员默认为 private;
  • struct 定义类时 不写访问修饰符类成员默认为 public;

在一个类体中,private、protected 和 public 可以分别出现多次。每个部分的有效范围到出现另一个访问限定符或类体结束时(最后一个右花括号)为止。但是为了使程序清晰,应该养成这样的习惯,使每一种成员访问限定符在类定义体中只出现一次。

2.3 类的封装

封装 是指在定义类时尽量隐藏类的内部实现,只向用户提供有用的成员函数。

C++ 定义类时使用 private 和 protected关键字 来隐藏类的内部实现,使用 public关键字 来向用户提供有用的成员函数,不希望外部知道、或者只在类内部使用的、或者对外部没有影响的成员,都建议声明为 private。

Tops:

  • C++ 定义类时不希望外部知道、或者只在类内部使用的、或者对外部没有影响的成员,都建议声明为 private。
  • 根据C++软件设计规范,实际项目开发中的成员变量以及只在类内部使用的成员函数(只被成员函数调用的成员函数)都建议声明为 private,而只将允许通过对象调用的成员函数声明为 public。

这种将成员变量声明为 private、将部分成员函数声明为 public 的做法体现了 类的封装性

给成员变量赋值的函数通常称为 set 函数,它们的名字通常以set开头,后跟成员变量的名字;读取成员变量的值的函数通常称为 get 函数,它们的名字通常以get开头,后跟成员变量的名字。

除了 set 函数和 get 函数,在创建对象时还可以调用构造函数来初始化各个成员变量,我们将在《C++构造函数》一节中展开讨论。不过构造函数只能给成员变量赋值一次,以后再修改还得借助 set 函数。

类其实也是一种作用域,每个类都会定义它自己的作用域。 在类的作用域之外,普通的成员(public 成员)只能通过对象(可以是对象本身,也可以是对象指针或对象引用)来访问,静态成员既可以通过对象访问,又可以通过类访问,而 typedef 定义的类型只能通过类来访问。

一个类就是一个作用域的事实能够很好的解释为什么我们在类的外部定义成员函数时必须同时提供类名和函数名。在类的外部,类内部成员的名字是不可见的。

一旦遇到类名,定义的剩余部分就在类的作用域之内了,这里的剩余部分包括参数列表和函数体。结果就是,我们可以直接使用类的其他成员而无需再次授权了。请看下面的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include<iostream>
using namespace std;

class A
{
public:
    typedef char* PCHAR;
public:
    void show(PCHAR str);
private:
    int n;
};
void A::show(PCHAR str)
{
    cout<<str<<endl;
    n = 10;
}

int main()
{
    A obj;
    obj.show("http://www.gamecolg.com");

    return 0;
}

我们在定义 show() 函数时用到了类 A 中定义的一种类型 PCHAR,因为前面已经指明了当前正位于 A 类的作用域中,所以不用再使用 A::PCHAR 这样的冗余形式。同理,编译器也知道函数体中用到的变量 n 也位于 A 类的作用域。

另一方面,函数的返回值类型出现在函数名之前,当成员函数定义在类的外部时,返回值类型中使用的名字都位于类的作用域之外,此时必须指明该名字是哪个类的成员。修改上面的 show() 函数,让它的返回值类型为 PCHAR:

1
2
3
4
5
6
PCHAR A::show(PCHAR str)
{
    cout<<str<<endl;
    n = 10;
    return str;
}

这种写法是错误的。因为返回值类型 PCHAR 出现在类名之前,所以事实上它是位于 A 类的作用域之外的。这种情况下要想使用 PCHAR 作为返回值类型,就必须指明哪个类定义了它,正确的写法如下所示:

1
2
3
4
5
6
A::PCHAR A::show(PCHAR str)
{
    cout<<str<<endl;
    n = 10;
    return str;
}

2.4 简单类的对象内存模型

C++ 中类是创建对象的模板,不占用内存空间,不存在于编译后的可执行文件中;而对象是实实在在的数据,需要内存空间来存储。对象被创建时会在data段、栈区 或者 堆区 分配内存。

不同对象的成员变量的值一般不同,编译时需要单独分配内存空间来存储。但是不同对象的成员函数的代码是一样的,如果为每一个对象分配单独的内存空间(在text 段)来存储成员函数,可能会浪费了不少空间,编译器会将成员变量和成员函数分开存储:分别为每个对象的成员变量分配内存,但是所有对象都共享同一段函数代码。

成员变量在 data 区、 堆区 或 栈区 分配内存,成员函数在 代码区 分配内存。

使用 sizeof 获取对象所占内存的大小不包含成员函数所占内存大小。

类是一种复杂的数据类型,也可以使用 sizeof 求得该类型 或该类型对象的内存大小。在使用 sizeof 计算类这种类型或对象的大小时,只计算了成员变量的大小,并没有把成员函数也包含在内。

2.5 成员函数的编译和实现

上一节介绍到对象的内存模型中只保留了成员变量,除此之外没有任何其他信息,C++程序运行时究竟是如何通过对象调用成员函数的呢?

C++ 对象成员函数的调用:

在 《C++基础概述》的第八节 “C++中的函数” 中讲到,成员函数最终被编译成与对象无关的全局函数(通过把C++源码编译为汇编代码可以查看到),如果函数体中没有使用成员变量,那问题就很简单,不用对函数做任何处理,直接调用即可。

如果成员函数中使用到了成员变量该怎么办呢?成员变量的作用域不是全局的,不经任何处理就无法在函数内部访问。

C++规定,编译成员函数时要额外添加一个参数,把当前对象的指针传递进去,这样成员函数体内就可以通过指针来访问成员变量了。

这样通过传递对象指针就完成了成员函数和成员变量的关联。这与我们从表明上看到的刚好相反,通过对象调用成员函数时,不是通过对象找函数,而是通过函数参数找对象。

这一切都是隐式完成的,对程序员来说完全透明,就好像这个额外的参数不存在一样。

这个 隐式 添加给成员函数的对象指正是 const 指针,不能被修改、只能指向当前对象、不能指向其它对象, 这个编译器自动添加的隐式指针在C++ 中称作 this指针, 我们在类成员函数中可以直接使用(无需定义)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
using namespace std;
//类的声明
class Student{
private:  // 私有的
    char *m_name;
    int m_age;
public:  // 共有的
    void setName(char *name);
    char * getName();
    void show();

    float m_score;
};
//成员函数的定义, 在类的内部(定义类的代码内部,包括类外部成员函数定义的函数体类),无论成员被声明为 public、protected 还是 private,都是可以访问的,没有访问权限的限制
void Student::setName(char *name){
    this->m_name = name;
}
char * Student::getName(){
    return this->m_name;
}
void Student::show(){
    cout << this->getName() << "的年龄是" << this->m_age << ",成绩是" << this->m_score << endl;
}
// 注意: this 只能在成员函数体内使用

2.6 类的构造函数

2.6.1构造函数的定义

在C++中定义的类有一种特殊的成员函数,它的名字和类名相同,没有返回值,不需要用户显式调用(用户也不能调用),而是在创建对象时自动执行,这种特殊的成员函数就叫作 构造函数(Constructor)

构造函数 是在创建对象的同时为成员变量赋值(初始化)的特殊成员函数。

构造函数必须是 public 属性的,否则创建对象时无法调用。当然,设置为 private、protected 属性也不会报错,但是没有意义。

构造函数没有返回值,因为没有变量来接收返回值,即使有也毫无用处,这意味着:

  • 不管是声明还是定义,函数名前面都不能出现返回值类型,即使是 void 也不允许;
  • 函数体中不能有 return 语句。

构造函数的调用是强制性的,一旦在类中定义了构造函数,那么创建对象时就一定要调用,不调用是错误的。如果有多个重载的构造函数,那么创建对象时提供的实参必须和其中的一个构造函数匹配;反过来说,创建对象时只有一个构造函数会被调用。

2.6.2 默认构造函数

定义类时,如果用户自己没有定义构造函数,那么编译器会自动生成一个默认的构造函数,只是这个构造函数的函数体是空的,也没有形参,也不执行任何操作。比如上面示例的 Student 类,默认生成的构造函数如下:

1
Student(){}

一个类必须有构造函数,要么用户自己定义,要么编译器自动生成。一旦用户自己定义了构造函数,不管有几个,也不管形参如何,编译器都不再自动生成。

Tips: 实际上编译器只有在必要的时候才会生成默认构造函数,而且它的函数体一般不为空。默认构造函数的目的是帮助编译器做初始化工作,而不是帮助程序员。

最后需要注意的一点是: 调用没有参数的构造函数也可以省略括号。 对于上面示例的 Student 类,在栈上创建对象可以写作 Student stu();Student stu;,在堆上创建对象可以写作 Student *pstu = new Student();Student *pstu = new Student;,它们都会调用构造函数 Student(){}

2.6.3 构造函数的重载

和普通成员函数一样,构造函数是允许重载的。一个类可以有多个重载的构造函数,创建对象时根据传递的实参来判断调用哪一个构造函数。

构造函数在实际开发中会大量使用,它往往用来做一些初始化工作,例如对成员变量赋值、预先打开文件等。

2.6.4 构造函数初始化列表

构造函数的一项重要功能是对成员变量进行初始化,为了达到这个目的,可以在构造函数的函数体中对成员变量一一赋值,还可以采用 初始化列表

C++构造函数的初始化列表使得代码更加简洁,请看下面的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
using namespace std;
class Student{
private:
    char *m_name;
    int m_age;
    float m_score;
public:
    Student(char *name, int age, float score);
    void show();
};
// 采用初始化列表
Student::Student(char *name, int age, float score): m_name(name), m_age(age), m_score(score){
    // TODO:
}
void Student::show(){
    cout<<m_name<<"的年龄是"<<m_age<<",成绩是"<<m_score<<endl;
}
int main(){
    Student stu("小明", 15, 92.5f);
    stu.show();
    Student *pstu = new Student("李华", 16, 96);
    pstu -> show();
    return 0;
}

如本例所示,定义构造函数时并没有在函数体中对成员变量一一赋值,其函数体为空(当然也可以有其他语句),而是在函数首部与函数体之间添加了一个冒号 :,后面紧跟 m_name(name), m_age(age), m_score(score) 语句,这个语句的意思相当于函数体内部的 m_name = name; m_age = age; m_score = score; 语句,也是赋值的意思。

使用构造函数初始化列表并没有效率上的优势,仅仅是书写方便,尤其是成员变量较多时,这种写法非常简单明了。

初始化列表可以用于全部成员变量,也可以只用于部分成员变量。下面的示例只对 m_name 使用初始化列表,其他成员变量还是一一赋值:

1
2
3
4
Student::Student(char *name, int age, float score): m_name(name){
    m_age = age;
    m_score = score;
}

注意,成员变量的初始化顺序与初始化列表中列出的变量的顺序无关,它只与成员变量在类中声明的顺序有关。

成员变量的赋值顺序由它们在类中的声明d的先后顺序决定。

初始化 const 成员变量: 构造函数初始化列表还有一个很重要的作用,那就是初始化 const 成员变量。初始化 const 成员变量的唯一方法就是使用初始化列表。

2.7 类的析构函数

创建对象时系统会自动调用构造函数进行初始化工作,同样,销毁对象时系统也会自动调用一个函数来进行清理工作,例如释放分配的内存、关闭打开的文件等,这个函数就是析构函数。

析构函数(Destructor) 也是类的一种特殊的成员函数,它没有返回值,不需要程序员显式调用(程序员也没法显式调用),而是在销毁对象时自动执行。构造函数的名字和类名相同,而析构函数的名字是在类名前面加一个 ```~`` 符号。

Tips: 注意, 析构函数没有参数,不能被重载,因此一个类只能有一个析构函数。如果用户没有定义,编译器会自动生成一个默认的析构函数。

函数名是标识符的一种,原则上标识符的命名中不允许出现 ~ 符号,在析构函数的名字中出现的 ~ 可以认为是一种特殊情况,目的是为了和构造函数的名字加以对比和区分。

析构函数的执行时机 析构函数在对象被销毁时调用,而对象的销毁时机与它所在的内存区域有关:

  • 在所有函数之外创建的对象是全局对象,它和全局变量类似,位于内存分区中的全局数据区,程序在结束执行时会调用这些对象的析构函数;
  • 在函数内部创建的对象是局部对象,它和局部变量类似,位于栈区,函数执行结束时会调用这些对象的析构函数;
  • new 创建的对象位于堆区,通过 delete 删除时才会调用析构函数;如果没有 delete,析构函数就不会被执行;

Tips: C++ 中的 new 和 delete 分别用来分配和释放内存,它们与C语言中 malloc()、free() 最大的一个不同之处在于:用 new 分配内存时会调用构造函数,用 delete 释放内存时会调用析构函数。构造函数和析构函数对于类来说是不可或缺的,所以在C++中我们非常鼓励使用 new 和 delete。

2.8 成员对象和封闭类详解

一个类的成员变量如果是另一个类的对象,就称之为 成员对象。包含成员对象的类叫封闭类(enclosed class)

1、成员对象的初始化

创建封闭类的对象时,它包含的成员对象也需要被创建,这就会引发成员对象构造函数的调用。如何让编译器知道,成员对象到底是用哪个构造函数初始化的呢?这就需要借助封闭类构造函数的 初始化列表。

对于基本类型的成员变量,“参数表”中只有一个值,就是初始值,在调用构造函数时,会把这个初始值直接赋给成员变量。 但是对于成员对象,“参数表”中存放的是构造函数的参数,它可能是一个值,也可能是多个值,它指明了该成员对象如何被初始化。

总之,生成封闭类对象的语句一定要让编译器能够弄明白其成员对象是如何初始化的,否则就会编译错误。

封闭类对象生成时,先执行所有成员对象的构造函数,然后才执行封闭类自己的构造函数。成员对象构造函数的执行次序和成员对象在类定义中的次序一致,与它们在构造函数初始化列表中出现的次序无关。

2、成员对象的消亡 当封闭类对象消亡时,先执行封闭类的析构函数,然后再执行成员对象的析构函数,成员对象析构函数的执行次序和构造函数的执行次序相反,即先构造的后析构,这是 C++ 处理此类次序问题的一般规律。

2.9 this指针详解

1、this 指针的概念

this 是 C++ 中的一个关键字,也是一个 const 指针,它指向当前对象,通过它可以访问当前对象的所有成员。

this 只能用在类的内部,通过 this 可以访问类的所有成员,包括 private、protected、public 属性的。

Tips: 注意,this 是一个指针,要用->来访问成员变量或成员函数。

this 虽然用在类的内部,但是只有在对象被创建以后才会给 this 赋值,并且这个赋值的过程是编译器自动完成的,不需要用户干预,用户也不能显式地给 this 赋值。本例中,this 的值和 pstu 的值是相同的。

this 指向了当前对象,其值是一个内存地址,其值为当前对象的地址,可以使用标注输出打印出来,而且对于不同的对象,this 的值也不一样。

注意:

  • this 是 const 指针,它的值是不能被修改的,一切企图修改该指针的操作,如赋值、递增、递减等都是不允许的。
  • this 只能在成员函数内部使用,用在其他地方没有意义,也是非法的。
  • 只有当对象被创建后 this 才有意义,因此不能在 static 成员函数中使用(后续会讲到 static 成员)。

2、this 到底是什么

this 实际上是成员函数的一个形参,在调用成员函数时将对象的地址作为实参传递给 this。不过 this 这个形参是隐式的,它并不出现在代码中,而是在编译阶段由编译器默默地将它添加到参数列表中。

this 作为隐式形参,本质上是成员函数的局部变量,所以只能用在成员函数的内部,并且只有在通过对象调用成员函数时才给 this 赋值。

在 《成员函数》一节中讲到,成员函数最终被编译成与对象无关的普通函数,除了成员变量,会丢失所有信息,所以编译时要在成员函数中添加一个额外的参数,把当前对象的首地址传入,以此来关联成员函数和成员变量。这个额外的参数,实际上就是 this,它是成员函数和成员变量关联的桥梁。

2.10 static静态成员变量 和 static静态成员函数详解

1 static静态成员变量

静态成员变量 是一种特殊的成员变量,它被关键字static修饰,static 成员变量属于类,不属于某个具体的对象,即使创建多个对象,也只为 静态成员变量 分配一份内存(在静态存储区分配),所有对象使用的都是这份内存中的数据。当某个对象修改了 静态成员变量,也会影响到其他对象。

static 成员变量必须在类声明的外部初始化,具体形式为:

1
type class::name = value;

type 是变量的类型,class 是类名,name 是变量名,value 是初始值。

静态成员变量在初始化时不能再加 static,但必须要有数据类型。被 private、protected、public 修饰的静态成员变量都可以用这种方式初始化。

注意:static 成员变量的内存既不是在声明类时分配,也不是在创建对象时分配,而是在(类外)初始化时分配。反过来说,没有在类外初始化的 static 成员变量不能使用。

static 成员变量既可以通过对象来访问,也可以通过类来访问。

注意:static 成员变量不占用对象的内存,而是在所有对象之外开辟内存,即使不创建对象也可以访问。具体来说,static 成员变量和普通的 static 变量类似,都在内存分区中的全局数据区分配内存。

几点说明

  1. 一个类中可以有一个或多个静态成员变量,所有的对象都共享这些静态成员变量,都可以引用它。
  2. static 成员变量和普通 static 变量一样,都在内存分区中的全局数据区分配内存,到程序结束时才释放。这就意味着,static 成员变量不随对象的创建而分配内存,也不随对象的销毁而释放内存。而普通成员变量在对象创建时分配内存,在对象销毁时释放内存。
  3. 静态成员变量必须初始化,而且只能在类体外进行。初始化时可以赋初值,也可以不赋值。如果不赋值,那么会被默认初始化为 0。全局数据区的变量都有默认的初始值 0,而动态数据区(堆区、栈区)变量的默认值是不确定的,一般认为是垃圾值。
  4. 静态成员变量既可以通过对象名访问,也可以通过类名访问,但要遵循 private、protected 和 public 关键字的访问权限限制。当通过对象名访问时,对于不同的对象,访问的是同一份内存。

2、static静态成员函数

在类中,static 除了可以声明静态成员变量,还可以声明静态成员函数。普通成员函数可以访问所有成员(包括成员变量和成员函数),静态成员函数只能访问静态成员。

编译器在编译一个普通成员函数时,会隐式地增加一个形参 this,并把当前对象的地址赋值给 this,所以普通成员函数只能在创建对象后通过对象来调用,因为它需要当前对象的地址。

而静态成员函数可以通过类来直接调用,编译器不会为它增加形参 this,它不需要当前对象的地址,所以不管有没有创建对象,都可以调用静态成员函数。

普通成员变量占用对象的内存,静态成员函数没有 this 指针,不知道指向哪个对象,无法访问对象的成员变量,也就是说静态成员函数不能访问普通成员变量,只能访问静态成员变量。

普通成员函数必须通过对象才能调用,而静态成员函数没有 this 指针,无法在函数体内部访问某个对象,所以不能调用普通成员函数,只能调用静态成员函数。

Tips: 静态成员函数与普通成员函数的根本区别在于:

  • 普通成员函数有 this 指针,可以访问类中的任意成员;
  • 而静态成员函数没有 this 指针,只能访问静态成员(包括静态成员变量和静态成员函数)。

在C++中,静态成员函数的主要目的是访问静态成员。

和静态成员变量类似,静态成员函数在声明时要加 static,在定义时不能加 static。静态成员函数可以通过类来调用(一般都是这样做),也可以通过对象来调用,上例仅仅演示了如何通过类来调用。

2.11 const成员变量 、成员函数(常成员函数)和 对象(常对象)

1、const成员变量

在类中,如果你不希望某些数据被修改,可以使用const关键字加以限定。const 可以用来修饰成员变量和成员函数。

const 成员变量的用法和普通 const 变量的用法相似,只需要在声明时加上 const 关键字。初始化 const 成员变量只有一种方法,就是通过构造函数的初始化列表

2、const成员函数(常成员函数)

const 成员函数可以使用类中的所有成员变量,但是不能修改它们的值,这种措施主要还是为了保护数据而设置的。const 成员函数也称为常成员函数。

常成员函数需要在声明和定义的时候在函数头部的结尾加上 const 关键字。

需要强调的是,必须在成员函数的声明和定义处同时加上 const 关键字。如果只在一个地方加 const 会导致声明和定义处的函数原型冲突。

最后再来区分一下 const 的位置:

  • 函数开头的 const 用来修饰函数的返回值,表示返回值是 const 类型,也就是不能被修改,例如const char * getname()。
  • 函数头部的结尾加上 const 表示常成员函数,这种函数只能读取成员变量的值,而不能修改成员变量的值,例如char * getname() const。

3、const对象(常对象)

在 C++ 中,const 也可以用来修饰对象,称为常对象。一旦将对象定义为常对象之后,就只能调用类的 const 成员(包括 const 成员变量和 const 成员函数)了。

const对象(常对象)必须在定义的时候进行初始化,不管是显示的还是隐式的初始化,之后就不能再修改了。

定义常对象的语法和定义常量的语法类似:

1
2
const  class  object(params);
class const object(params);

当然你也可以定义 const 指针:

1
2
const class *p = new class(params);
class const *p = new class(params);

class为类名,object为对象名,params为实参列表,p为指针名。两种方式定义出来的对象都是常对象。

一旦将对象定义为常对象之后,不管是哪种形式(常对象以及常对象指针),该对象就只能调用被 const 修饰的成员了(包括 const 成员变量和 const 成员函数),因为调用非 const 成员可能会修改对象的数据(编译器也会这样假设),C++禁止这样做。

2.12 C++友元函数和友元类(C++ friend关键字)

在 C++ 中,一个类中可以有 public、protected、private 三种属性的成员,通过对象可以访问 public 成员,一般情况下只有类中的成员函数可以访问本类的 private 等成员, 除此之外,还有一种例外情况 —— 友元(friend)。借助友元(friend),可以使得普通函数、其他类中的成员函数 可以访问当前类的 private 等成员。

1、友元函数

在当前类以外定义的、不属于当前类的函数也可以在类中声明,但要在前面加 friend 关键字,这样就构成了友元函数。友元函数可以是不属于任何类的非成员函数,也可以是其他类的成员函数。

友元函数可以访问当前类中的所有成员,包括 public、protected、private 属性的。

Tips:

  • 友元函数不同于类的成员函数,在友元函数中不能直接访问类的成员,必须要借助对象;

成员函数在调用时会隐式地增加 this 指针,指向调用它的对象,从而使用该对象的成员;友元函数不是本类的成员函数,没有本类的 this 指针,编译器不知道使用哪个对象的成员,要想明确这一点,就必须通过参数传递对象(可以直接传递对象,也可以传递对象指针或对象引用),并在访问成员时指明对象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include <iostream>
using namespace std;

class Address;  //提前声明Address类

class Student{
public:
    Student(char *name, int age, float score);
public:
    friend void show(Student *pstu);  //将show()声明为友元函数

    //将Student类中的成员函数showAddr()声明为友元函数
    void showAddr(Address *addr);
private:
    char *m_name;
    int m_age;
    float m_score;
};

Student::Student(char *name, int age, float score): m_name(name), m_age(age), m_score(score){ }

void Student::show(Address *addr){
    cout<<m_name<<"的年龄是 "<<m_age<<",成绩是 "<<m_score<<endl;
    cout<<"家庭住址:"<<addr->m_province<<"省"<<addr->m_city<<"市"<<addr->m_district<<"区"<<endl;
}

//声明Address类
class Address{
private:
    char *m_province;  //省份
    char *m_city;  //城市
    char *m_district;  //区(市区)
public:
    Address(char *province, char *city, char *district);
    // 将Student类中的成员函数showAddr()声明为Address类的友元函数
    friend void Student::showAddr(Address *addr);
};
// 实现Address类
Address::Address(char *province, char *city, char *district){
    m_province = province;
    m_city = city;
    m_district = district;
}

//非成员函数
void show(Student *pstu){
    cout<<pstu->m_name<<"的年龄是 "<<pstu->m_age<<",成绩是 "<<pstu->m_score<<endl;
}

int main(){
    Student stu("小明", 15, 90.6);
    show(&stu);  // 调用友元函数
    Student *pstu = new Student("李磊", 16, 80.5);
    show(pstu);  // 调用友元函数

    Address addr("陕西", "西安", "雁塔");
    stu.showAddr(&addr);


    pstu->showAddr(&addr);

    return 0;
}

一个函数可以被多个类声明为友元函数,这样就可以访问多个类中的 private 成员。

2、友元类

不仅可以将一个函数声明为一个类的“朋友”,还可以将整个类声明为另一个类的“朋友”,这就是友元类。友元类中的所有成员函数都是另外一个类的友元函数。

例如将类 B 声明为类 A 的友元类,那么类 B 中的所有成员函数都是类 A 的友元函数,可以访问类 A 的所有成员,包括 public、protected、private 属性的。

关于友元,有两点需要说明:

  • 友元的关系是单向的而不是双向的。如果声明了类 B 是类 A 的友元类,不等于类 A 是类 B 的友元类,类 A 中的成员函数不能访问类 B 中的 private 成员。
  • 友元的关系不能传递。如果类 B 是类 A 的友元类,类 C 是类 B 的友元类,不等于类 C 是类 A 的友元类。

除非有必要,一般不建议把整个类声明为友元类,而只将某些成员函数声明为友元函数,这样更安全一些。

三、借助指针突破类成员变量访问权限的限制

3.1 突破类成员变量访问权限的限制

C++ 中不允许通过对象来访问 private、protected 属性的成员变量,如下示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;

class A
{
public:
    A(int a, int b, int c);
private:
    int m_a;
    int m_b;
    int m_c;
};
A::A(int a, int b, int c): m_a(a), m_b(b), m_c(c){ }

int main()
{
    A obj(10, 20, 30);
    int a = obj.m_a;  //Compile Error
    A *p = new A(40, 50, 60);
    int b = p->m_b;  //Compile Error

    return 0;
}

这段代码说明了,无论通过对象变量还是对象指针,都不能访问 private 属性的成员变量。

不过 C++ 的这种限制仅仅是语法层面的,通过指针的某种“蹩脚”的方法,我们能够突破访问权限的限制,访问到 privateprotected 属性的成员变量,赋予我们这种“特异功能”的,正是强大而又灵活的指针(Pointer)。

1、使用偏移

在对象的内存模型中(详见 《C++对象的内存模型》 章节介绍),成员变量和对象的开头位置会有一定的距离。以上面的 obj 为例,它的内存模型为如下图: 图中假设 obj 对象的起始地址为 0X1000,m_a、m_b、m_c 与对象开头分别相距 0、4、8 个字节,我们将这段距离称为 偏移(Offset)。一旦知道了对象的起始地址(指针),再加上偏移就能够求得成员变量的地址,知道了成员变量的地址和类型,也就能够轻而易举地知道它的值 或 修改它的值。

当通过对象指针访问成员变量时,编译器实际上也是使用这种方式来取得它的值。为了说明问题,我们将上例中成员变量的访问权限改为 public,再来执行 int b = p->m_b; 行的语句,此时编译器内部会发生类似下面的转换:

1
int b = *(int*)( (int)p + sizeof(int) );

p 是对象 obj 的指针, (int)p 将指针转换为一个整数,这样才能进行加法运算;sizeof(int) 用来计算 m_b 的偏移; (int)p + sizeof(int) 得到的就是 m_b 的地址,不过因为此时是 int 类型,所以还需要强制转换为 int *类型;开头的 * 用来获取地址上的数据。

如果通过 p 指针访问 m_a:

1
int a = p -> m_a;

那么将被转换为下面的形式:

1
int a = * (int*) ( (int)p + 0 );

经过简化以后为:

1
int a = * (int*)p ;

2、突破访问权限的限制

上述的转换过程是编译器自动完成的,当成员变量的访问权限为 private 时,我们也可以手动转换,只要能正确计算偏移即可,这样就突破了访问权限的限制。

修改上例中的代码,借助偏移来访问 private 属性的成员变量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
using namespace std;

class A
{
public:
    A(int a, int b, int c);
private:
    int m_a;
    int m_b;
    int m_c;
};
A::A(int a, int b, int c): m_a(a), m_b(b), m_c(c){ }

int main()
{
    A obj(10, 20, 30);
    int a1 = *(int*)&obj;
    int b = *(int*)( (int)&obj + sizeof(int) );

    A *p = new A(40, 50, 60);
    int a2 = *(int*)p;
    int c = *(int*)( (int)p + sizeof(int)*2 );
   
    cout<<"a1="<<a1<<", a2="<<a2<<", b="<<b<<", c="<<c<<endl;

    return 0;
}

运行结果:

1
a1=10, a2=40, b=20, c=60

前面我们说 C++ 的成员访问权限仅仅是语法层面上的,是指访问权限仅对取成员运算符 .-> 起作用,而无法防止直接通过指针来访问。你可以认为这是指针的强大,也可以认为是 C++ 语言设计的漏洞。

Licensed under CC BY-NC-SA 4.0
最后更新于 2023-04-29 23:13 CST