详谈C++中的类和对象
类和对象可以说是现代高级编程语言最重要的特性,甚至说C++可以看作是C语言+类和对象,其重要程度自然是不言而喻,本文就以C++为主要语言,来较为由浅入深的介绍一下什么是所谓的类和对象(注:本文会同时包含C和C++的语法知识)。
1.类和对象的简单概念
本章先来简单的介绍一下类和对象的概念,让读者对类这个东西有一个大概的认识。
1.1什么是类和面向对象
先抛开编程语言,直接想想看在生活中什么是类?本人最先想到的就是归类,相信不少读者也是如此,那么就先了解一下归类,可以发现,或许是出于人类的某种天性(可能是强迫症吧),我们天生就喜欢把具有相似属性的东西归为一类,比如把苹果,梨子,香蕉....归为水果类,把别墅,平房,高楼....归为房子类,并且把多种东西归为一类后也让我们的描述更加方便,可以看出,归类的本质是把事物的共性抽象出来,然后用一个统一的名称进行标识,把这种思想放到编程中,类就诞生了,即:类是对事物共性的抽象,是对一类具有相同属性和行为的事物的抽象描述。
再来看看什么是面向对象,要理解这个概念,最好就是与面向过程进行对比,举个栗子:张三在煮挂面,他先把面条放到矿泉水中,把水加热,面煮熟后就把面挑到了自己碗里面。这就是面向过程的描述,面向的是张三煮面的这一整个过程,再来看看从面向对象角度的理解,首先抽象出这个过程中的对象,即:张三,挂面,矿泉水,张三的碗,然后将每个对象视为一个主体,再给每个对象加入属性和动作,比如人的动作是可以把面丢进水里面,面条的属性是可以吃,可以被水煮熟,而水的动作是可以煮熟面条,加入这些属性和动作后,让各个对象之间相互协作,就可以完成煮面这个过程了,在这个例子中,面向过程的每一步都可以用函数完成,而面向对象就是使用类对每一个对象进行封装来完成了。从这个例子中也可以发现,面向对象的独立性更强,泛用性也更好,并且更加符合我们的生活习惯,比如在面向过程时煮面这个函数只能用于煮面,但是在用类进行描述后,张三,面条,水就可以有多种组合,比如张三可以直接喝水,泡澡,又或者他脑子抽了也可以实现干吃面条。
还是拿煮面条这个例子,来具体的说说什么是:类是对事物共性的抽象,在这个例子中,我们抽象出来的类是张三,挂面,矿泉水,张三的碗,但是这还不够抽象,如果是李四想要煮面呢,李四他就喜欢吃高山冰水融水煮的宽面,那难道我们还要再实现几个类吗?这也太挫了,因此我们应该抽象出的类是:人,面条,水,碗,这样就可以满足更多需求了。
现在让我们用上面的这些思想,不使用编程语言来简单实现一个人的类,首先这个类应该要分为两个部分,一个部分是人的属性,一个部分是人的动作,人的属性有身高,年龄,体重,名字.....人可以进行的动作是行走,吃东西,洗碗,下面条....然后再将它们组合起来:
类名称 人
{
人的属性(类型):
身高;
年龄;
体重;
名字;
......
人的动作(函数):
行走;
下面条;
洗碗;
吃东西;
......
}
我们使用语文语言实现了一个简单的类,然后只需要把其中这些信息翻译成编程语言,那么就是编程中所谓的类了。
1.2什么是实例化和对象
在上文已经说明:类是对一类具有相同属性和行为的事物的抽象描述,也就是说创建类的过程是化具象为抽象,但是我们在实际应用中是不会直接使用抽象的东西的,抽象出来的类就像是一张房子蓝图,我们当然不可能住在图纸里面,必须要把这个抽象的蓝图盖成具体的房子才能够使用,而这个把蓝图盖为房子的过程就是所谓的实例化,即把抽象的类重新变为具体的对象,那么对象是什么就非常明确了:我们把用抽象的类实例化出来的东西叫做这个类的对象。还是以上面的人的类为例,用语文来看看应该怎么实例化:
类名称 人
{
人的属性:
身高;
年龄;
体重;
名字;
......
人的动作(函数):
行走;
下面条;
洗碗;
吃东西;
......
}
//实例化
人 张三;
张三的年龄:18
张三的体重:20kg
张三的身高:180m
//调用函数
张三.行走()
张三.吃东西()
在将一个类实例化后,其中的动作函数才可以正常的使用,毕竟图纸是不会走路和吃东西的,具体实例化出来的人才会。
1.3类的意义是什么
从上面的概念就可以看出,类可以让编程语言更加便利的进行描述,是编程界的蓝图,利用这张图纸,我们就可以省去大量重复性的工作,比如在没有类时,张三吃面条和李四吃面条就需要重复写两段代码,但是有了类后,就只需要实例化出张三和李四,再调用类中的函数就可以完出吃面条了,并且什么王五,赵六,田七......不管有多少人,都可以使用这个类完成吃面条,也就是说,从工程实践的角度来看,类最大的意义就是让程序员几乎不用再造重复的轮子了。
概念已经谈完,下面就不说这些虚头八脑的东西了,笔者将从类最重要的三大特性(封装,继承,多态)入手,用具体的C和C++语言来看看类是个什么东西。
2.封装
2.1C语言的结构体
本人在学完C和C++后,有明显的体会,如果上来就直接从类起手,那就像是直接爬珠穆朗玛峰,但是如果使用C语言的结构体做过渡,那么难度就会大幅度降低,如果你已经对结构体有了深刻的理解又或者就是想去挑战珠穆朗玛峰,那么可以直接跳过本章。
2.1.1结构体的语法
有一点C语言基础的人应该都知道,C语言的规定中只提供了简单的内置类型:int,char,folat...等,这些简单的内置类型只能描述一些简单的东西,显然无法满足对复杂对象的描述,比如难道可以用int来描述一个人吗,难道可以使用char来描述一个房子吗?这当然是不可能的,而类型这个东西就是为了描述对象而存在,既然内置类型无法满足对复杂对象的描述,那么就是结构体出场的时候了,下面就来看看结构体的具体语法。
1.结构体的声明:
在C语言中,结构体能够把多个不同类型的变量定义在一起,其中的类型可以是普通类型,也可以是指针类型,我们使用struct关键字来定义一个结构体,具体方法是struct后面加结构体名,然后在下面的{}定义结构体中的变量,要注意的是不同于函数,结构体的{}后面必须加上;(分号),下面来看看具体的声明方法:
struct test
{
//普通类型
int a;
char b;
float c;
double d;
long e;
//.......
//指针类型
int* pa;
int** ppa;
char pb[100]; //相当于一个char* (数组名是首元素地址)
float (*pc)(); //也可以定义函数指针
double (*pd)[]; //数组指针也可以
//.......
// int b = 100; //不能直接在结构体中进行赋值
};
如上方代码,在关键字struct后面加上结构体的名称,然后下方再用{}阔起来,{}中填入简单的内置类型,要注意的是在其末尾必须加上;,这样一个结构体就定义完成了,可以发现,结构体中是可以定义一些比较复杂的类型的(比如函数指针,数组指针等,如果读者想要了解这些复杂的指针,可以去看本人写的:C语言的指针,其中有较为详细的讲解)。并且结构体中也是可以嵌套定义结构体的,来看看下方的代码:
struct fun
{
struct test i1;
struct test i2;
struct test* pi1;
struct test* pi2;
};
需要注意的是在C语言的结构体中不能定义静态成员(在C++的类中可以):
struct test
{
static int a;
};

2.结构体的实例化
结构体是用户自定义的类型,而一个类型被定义出来肯定是要进行实例化才又意义的,比如int本身是没什么用的,int a = 10才是有意义的,那么下面就来看看结构体要怎么进行实例化:
struct people
{
int age;
int high;
int weight;
char name[20];
int phone_numble[20];
};
int main()
{
struct people p1 = { 18, 180, 50, "张三", {1, 0, 0, 8, 6} };
}
方法非常的简单,和内置类型是差不多的,只需要用在{}中按顺序填入相对应的值就可以了,只不过在实例化时要加上struct,并且在实例化结构体中的数组时要注意加上""或{},下面来看看另一种实例化的方式:
struct people
{
int age;
int high;
int weight;
char name[20];
int phone_numble[20];
}p1 = { 1, 2, 3, "马牛逼", {1, 2, 3, 4, 5} }, p2 = { 2, 3, 4, "GGB", {1, 3, 4} };
如上方代码,可以直接在结构体声明的后方对结构体进行实例化,此时p1,p2就是结构体实例化出的对象,但是这种方法的可读性不高,而且一般结构体都是声明在全局的,使用这种方法实例化出的结构体对象就是全局变量,我们应该尽量减少使用全局变量,因此一般情况下很少采用这种方法。下面来看看另一段代码。
struct people
{
int age;
int high;
int weight;
char name[20];
int phone_numble[20];
};
int main()
{
struct people p1 = { 18, 180, 50, "张三", {1, 0, 0, 8, 6} };
p1.name = "李四";
p1.phone_numble = { 1, 3, 2, 4, 1 };
return 0;
}
上方的代码可以正常运行吗?似乎看起来没什么问题,来看看实际情况:

答案是不行,而且编译器报出了奇怪的错误,其实这个问题与结构体无关,是指针的问题,(在本人的:C语言的指针 中同样有详细的讲解,这里就不多说明了)
3.结构体的匿名声明
所谓匿名,就是没有名字,我们在定义一个结构体时在语法上是可以不给这个结构体命名的,但是这种匿名的结构体在定义后最好立刻使用,而且一般只能使用一次(因为如果再次定义出匿名结构体,编译器就会因为无法分辨而报错),具体操作来看看下方的代码:
struct
{
int a;
char b;
}n, p;
如上方代码,该结构体就没有名字,是匿名结构体,所谓的立即使用就是在声明该结构体后立刻进行定义,此时结构体末尾处的n和p就是匿名结构体定义出来的对象,所谓的立即使用就是在声明完匿名结构体后直接在后方进行定义,因为匿名结构体是不能像下方的代码那样使用的:
struct
{
int a;
char b;
};
int main()
{
struct i;
i.a = 10;
i.b = '9';
return 0;
}

匿名结构体由于没有具体的名字,必须在声明后立即进行定义,编译器是无法通过struct识别出匿名结构体的。
4.结构体成员变量的访问
结构体作为一个类型的集合,在使用时一般都会要访问结构体中包含的具体成员变量,访问的方法也非常的简单,来看看下方的代码:
struct people
{
int age;
int high;
int weight;
char name[20];
int phone_numble[20];
};
int main()
{
struct people p1 = { 18, 180, 50, "张三", {1, 0, 0, 8, 6} };
printf("name: %s, age: %d, high: %d, weight: %d\n", p1.name, p1.age, p1.high, p1.weight);
for (int i = 0; i < 5; i++)
{
printf("%d", p1.phone_numble[i]);
}
return 0;
}
如上方代码,对于一个普通的结构体对象,我们可以使用: 对象.变量 来访问对象中的指定变量,需要注意的就是在变量是数组时,要访问数组中的所有元素只能遍历数组。普通结构体对象访问其中变量的方法非常的简单,下面来看看要怎么通过结构体指针来访问结构体对象中的变量:
struct people
{
int age;
int high;
int weight;
char name[20];
int phone_numble[20];
};
void fun(struct people* po)
{
printf("name=%s age=%d\n", po->name, po->age);
}
int main()
{
struct people p1 = { 18, 180, 50, "张三", {1, 0, 0, 8, 6} };
fun(&p1);
return 0;
}

如上方代码,一般在把结构体进行传参时,就会采用传结构体地址的方法(在本人的C语言的指针中有详细说明,这里不再多说),此时就出现了用结构体指针访问结构体中的变量的需求,方法也非常的简单,即:结构体指针->成员变量,使用->符号进行访问就可以了,操作和使用结构体对象时用.访问成员变量差不多。
从上方的示例中,可以发现结构体确实可以比较具体描述一个复杂的对象,并且我们也可以使用同一个结构体来实例化出多个不同的对象,比如用结构体people可以实例化出张三,李四,王五....已经有一些类的味道了,差的仅仅只是对应的函数方法,下面来考虑一下:有什么办法可以在结构体中加上函数呢?
5.函数指针在结构体中的妙用
本节的标题已经回答了上方的问题,我们不能直接在结构体中定义一个函数,但是可以利用函数指针来实现差不多的功能,其原因是结构体中可以定义类型,不能定义函数,而指针类型也是类型,在结构体中当然可以定义指针类型,并且函数指针同样也是指针的一个类型,那么在结构体中当然就可以定义函数指针了(注:想要详细了解函数指针的读者可以去看本人写的:C语言的指针),具体操作且看下方代码:
void introduce(const char* name, int age)
{
printf("我是%s, 我今年%d岁了\n", name, age);
}
void eat(const char* name, const char* food)
{
printf("我是%s, 我喜欢吃%s\n", name, food);
}
struct people
{
int age;
int high;
int weight;
char name[20];
int phone_numble[20];
void (*introduce)(const char* name, int age);
void (*eat)(const char* name, const char* food);
};
如上方代码,我们在结构体people中加入了两个函数指针(如果需要的话可以加入更多),尽管我们不能直接把函数实现在结构体中,但是我们可以走曲线,即:在结构体外实现函数,然后再把函数的指针定义在结构体中(这就已经非常像所谓的类了),下面来看看具体要怎么用:
void introduce(const char* name, int age)
{
printf("我是%s, 我今年%d岁了\n", name, age);
}
void eat(const char* name, const char* food)
{
printf("我是%s, 我喜欢吃%s\n", name, food);
}
struct people
{
int age;
int high;
int weight;
char name[20];
int phone_numble[20];
void (*introduce)(const char* name, int age);
void (*eat)(const char* name, const char* food);
};
int main()
{
struct people zs = { 18, 180, 50, "张三", {1,0,0,8,6}, introduce, eat};
struct people ls = { 19, 170, 40, "李四", {6,6,6,6,6}, introduce, eat};
zs.introduce(zs.name, zs.age);
zs.eat(zs.name, "答辩");
printf("\n");
ls.introduce(ls.name, ls.age);
ls.eat(ls.name, "篮球");
return 0;
}
可以说我们使用了C语言的结构体模拟实现了一个简单类(并且使用结构体也可以模拟出类中继承和多态的特性,本人会在后方进行详细的说明),使用方法也与C++中的类非常相似,而且比类要好理解的多。所以并不是只有所谓的高级语言中才会有类和对象,在C语言中其实也有,高级语言的类和对象就是脱胎于C语言的结构体的,只是提供了比结构体更加安全和便利封装,在这里笔者要说明的是,在笔者看来,高级语言对底层的封装太好了,就导致学习高级语言时,你可能只学会了怎么使用轮子,但是你根本就不知道轮子是怎么造的,本人初学的第一门语言是python,对此就有很深的体会,学了半天你只能感叹别人造的东西真牛逼。在本人看来,对于不造重复的轮子的前提是:你已经大概知道这个轮子要怎么造了。而想要了解底层的原理,那就必须要学C语言了,可以说C语言才是一个优秀程序员的立生之本,这么说的另一个原因就是主流的大部分操作系统都是用C语言写的,比如Linux(几乎99%的计算机相关企业的后端使用的都是这个操作系统),操作系统可以说是一款最复杂的软件,如果把电脑比作成一家工厂,那么操作系统就是这个工厂的boss,是管理员,电脑上所有的软件和硬件都归它管,无论你用什么语言编程出来的程序,无论这个程序有多牛逼,这个程序最终都要归操作系统管,如果你不去了解操作系统,那么什么跨平台,进程,线程,网络.....这些东西你就别想去搞了,你编程出来的程序撑死了就只能在你自己的电脑上跑,而想要了解操作系统,学习C语言就是必要的,并且能编出操作系统本身就说明了C是一门非常厉害的语言,当然,以上只是笔者个人的一些看法,具体情况还是要以读者为准。
6.结构体的自引用
在上文已经提到过,结构体中可是可以包含结构体类型的,那么在一个结构体中可以包含该结构体自身吗?问题比较抽象,来看看下方的代码:
struct test
{
int i;
struct test t;
};
结构体类型也是类型,那么这种操作看起来好像是没什么问题的,但是仔细想想好像又有点问题:此时的这种写法就和套娃一样,struct test中包含了一个struct test,在包含的struct test中又会包含struct test.....那么此时struct test结构体就是无限大的,所以这么写是有问题的:

正确的结构体自引用是利用结构体的指针,简单来说就是在一个结构体中包含该结构体自身类型的指针,具体来看看下方的代码和示意图:
struct test
{
int i;
struct test* next;
};
int main()
{
struct test t1 = { 1, NULL };
struct test t2 = { 2, NULL };
struct test t3 = { 3, NULL };
t1.next = &t2;
t2.next = &t3;
return 0;
}

如上方代码,其中就是正确的结构体引用的方法,并且此时也不会出现之前层层套娃导致无限大的情况,因为指针类型的大小是固定的,并且此时只要有了t1,就可以通过t1中的结构体指针访问到t2和t3中的数据,这是一种非常重要的思路,许多重要的数据结构在底层就是通过这种方法实现的,比如:链表,队列,二叉树,红黑树.....并且在操作系统中也有大量的这种结构,Linux操作系统就是利用结构体中套结构体指针来实现复杂的结构的。
7.结构体中的柔性数组
柔性数组是C99标准中加入的新结构体特性,它允许将结构体的最后一个成员声明为一个大小未指定的数组。这使得结构体在运行时可以拥有可变长度的数据区域,是一种实现变长数据结构的高效方法,下面来看看具体的语法:
struct test
{
int a;
char buff[];
};
首先,柔性数组必须是结构体的最后一个成员,并且在结构体的柔性数组成员之前必须至少有一个非柔性数组成员,其次在标准写法中柔性数组的[]中不需要指定数组具体的大小,但是在一些旧编译器中也支持往[]中填入0,即char buff[0]的写法,为了提高代码的可移植性,最好还是采用标准写法,最后,在一个结构体中最多只能定义一个柔性数组,下面来看看柔性数组具体要怎么使用:
struct test1
{
int a;
long long b;
long long int c;
char* i;
char buff[];
};
int main()
{
struct test1* t = (struct test1*)malloc(sizeof(struct test1) + 100);
strcpy(t->buff, "hello world");
printf(t->buff);
return 0;
}

当结构体中包含柔性数组时,在编译时其大小未知,因此不能在栈上定义该结构体的变量,必须使用 malloc 等函数在堆上动态分配内存,并且分配的总空间需要大于结构体的基础大小,以容纳柔性数组所需的空间,比如在上方的代码的malloc中,sizeof(struct test1)就是结构体的基础大小(sizeof不计入柔性数组的大小,因为在编译时柔性数组的大小是无法确定的),在后面加上的100就是柔性数组char buff[]的大小了,从这里也可以明显的体现出柔性数组的大小确实是可变的,其大小完全由用户决定。
柔性数组看起来很高大上,但是实际上并不是什么非常牛逼的东西,在没有柔性数组这个语法前,在结构体中定义一个指针成员,在实例化时让该指针成员指向动态开辟的空间也可以实现柔性数组的效果,具体看下方的代码:
struct test1
{
int a;
long long b;
long long int c;
char* i;
char* buff;
};
int main()
{
struct test1 t;
t.buff = (char*)malloc(100);
strcpy(t.buff, "hello world");
printf(t.buff);
return 0;
}

此时没有使用柔性数组,但是完全可以达成柔性数组的效果,要说明的是:使用柔性数组的主要优势在于效率高和管理简便。对于使用了柔性数组的结构体,其所有成员都在堆区中,那么CPU的命中效率就会比较高(相当于一伙犯罪团伙都住在一个小公寓,警察一次就可以全部拿下),而对于上方的这种写法,此时结构体中一部分成员在栈区,动态开辟的空间又在堆区,CPU的访问命中率就会比较低(相当于一伙犯罪团伙一群在北京,一群在广东,警察需要跑两趟才能拿下),如果想要了解比较具体的内存划分的知识,可以去看看本人写到C语言的指针。
8.结构体的重命名
结构体可以说哪哪都好,设计也非常容易理解,就是类型的名称太长了,在定义时非常的不方便,因此一般都会对结构体和结构体指针进行重命名,下面来看看具体的方法:
//方法1
struct people
{
int age;
int high;
int weight;
char name[20];
int phone_numble[20];
};
typedef struct people person;
typedef struct people* Pperson;
//方法2
typedef struct dog
{
int age;
int high;
int weight;
char name[20];
int phone_numble[20];
}dog, *pdog;
int main()
{
person p1;
Pperson pp1;
dog d1;
pdog pd1;
return 0;
}
结构体重命名有两种方法,并且在重命名后实例化结构体对象或是使用结构体指针就非常方便了,在两种方法中,方法1可读性很高,非常容易理解,就是要多些几行代码,方法2的可读性较差,比较难理解,但是可以少写两行代码,在笔者见过的代码中,大部分采用的都是方法2,毕竟少写几行代码还是非常爽的,读者可以选用自己喜欢的方法。
2.2.3结构体计算内存大小
上方对结构体语法的讲解和让函数指针与结构体的结合是为类的语法和理解做渡,而本节计算结构体内存大小的规则就是为计算类的大小做过渡了,我们先不管什么规则,来直接猜测一下,结构体要怎么计算内存大小:
struct test
{
int a;
char b;
float c;
double d;
};
int main()
{
struct test i = { 1, '2', 3.0, 4.0 };
printf("%zu", sizeof(i));
return 0;
}
在上方的代码中,我们简单的定义了一个结构体test,其中定义了四个变量,分别是int类型(4字节),char类型(1字节),float类型(4字节),double类型(8字节),先往简单的想,或许结构体占用内存的大小就是把其中的每个变量占用的字节相加,那么就是17字节,来看看答案是不是:

显然,答案当然不会这么简单,要不然本人就没必要讲了,下面就来介绍一下,结构体真正的计算内存占用大小的规则:结构体内存对齐(在笔试和面试中都是常考点)
| 1.第一个成员变量在结构体中偏移量为0的地址处(就是结构体的首字节),成员变量与起始位置差了几个字节,该成员变量偏移量就是几(相当于第一个字节的偏移量是0,后面依次+1)。 |
| 2.其它成员的偏移量要对齐到该成员变量对齐数整数倍的地址处:对齐数=min(编译器默认的对齐数, 某个成员变量的大小(按字节算)) VS中的默认的对齐数是8,其它编译器大多是没有默认对齐数的。 |
| 3.结构体的总大小为该结构体最大对齐数的整数倍,也就是说每个成员变量都有自己的对齐数,部分编译器也会有自己的默认对齐数,在这所有的对齐数中取最大的那一个就是结构体的最大对齐数。 |
| 4.当结构体中包含数组时,数组的对齐数是数组的元素类型的对齐数(不是整个数组的类型大小) |
| 5.如果结构体中嵌套了结构体,那么嵌套的结构体的对齐数是该嵌套的结构体本身的所有成员变量的对齐数中的最大的那一个,结构体的大小就是最大对齐数(含嵌套结构体对齐数)的整数倍。 |
注:在部分地方可能会将对齐数称为对齐模数,这两者是一个东西
上方的规则非常重要,单从文字描述来看是非常不好理解的,下面本人就用具体的示例来对上方的规则一条条的进行介绍:
首先是第一条,先来看看偏移量到底是一个什么鬼东西:简单来说,可以与数组下标进行类比,偏移量就像是结构体中的下标,只不过该下标是以一个字节为单位的,比如一个结构体类型实例化后会占用12字节内存,那么首字节的偏移量就是0,第二个字节的偏移量就是1.....具体来看看下方的示意图:

在上方的示意图中,每一个小方块都表示结构体占用内存中的一个字节,那么所谓结构体中变量的偏移量是什么就非常明确了:在结构体中,第一个定义的变量的空间占用一定是从偏移量为0的位置开始的,在图中,第二个变量的偏移量就是4,第三个变量的偏移量就是8,非常的简单。
然后让我们看看第二条规则,先来看看对齐数是什么鬼东西:在结构体中定义的不同类型的变量是有不同的对齐数的,对于每一个变量,计算其对齐数的方法都是:对齐数==min(编译器默认的对齐数, 某个成员变量的大小(按字节算)),下面来举个例子,
struct test
{
char a;
int b;
float c;
};
如上方代码所定义的结构体,最先定义的变量就是char类型的a,由于结构体中第一个定义的变量一定是从0偏移的位置开始的,因此a就直接从偏移量为0的位置开始往后占用1字节,下一个变量是int类型的b,int类型是4字节,而vs中的默认对齐数是8,那么b的对齐数=min(4, 8),显然b的对齐数就是4,那么b就必须从偏移量为4的整数倍的位置开始放置,比如偏移量为0,4,8.....的位置都可以,在上方的结构体中,0号位置已经被a占用了,那么b就从偏移量为4的位置开始往后占用4字节空间,最后定义了一个float类型的变量,float同样是4字节,那么其对齐数就也是4了,由于0,4位置都被占用了,那么c就只能从8号偏移量开始放置,往后占用4字节空间,具体情况可以看看下方的示意图:

从上方的示意图可以明显的看出,在a和b之间有3字节空间没有使用,这3字节空间并不会被编译器拿去存放其它东西,就是简单的被浪费了,也因此计算结构体占用内存大小不能简单的把其成员变量占用空间的字节数直接相加,结构体的实际大小一定是大于等于直接相加的。最后要说明的是在其它编译器中大多是没有默认对齐数的,比如在linux操作系统中使用的主流编译器gcc就没有,此时,结构体中成员变量的对齐数就等于该成员变量的大小。
然后来看看第三条规则,第三条规则还是非常明确的,还是以上方的结构体为例:
struct test
{
char a;
int b;
float c;
};
按照上方说明过的对齐数计算方法,可以得出变量a的对齐数是1,变量b的对齐数是4,变量c的对齐数也是4,那么该结构体的最大对齐数显然就是4,那么该结构体实例化出的对象的总大小就只能是4的倍数,比如4,8,12,16....要注意的是,计算大小时,结构体的首字节要记为1,而不是偏移量的0(和计算数组的元素个数时,首元素是从1开始而不是从其下标0开始是一个道理)。通过上方介绍的3条规则,已经可以计算出一些比较简单的结构体了,就比如上方的结构体test,其大小显然就是12,下面来看看实际情况:
struct test
{
char a;
int b;
float c;
};
int main()
{
printf("%zu\n", sizeof(struct test));
return 0;
}
结果确实是12,在说明更加复杂的情况前,先来几个简单的练习来深刻的理解一下前3条规则吧:
struct a
{
int s1;
double s2;
char s3;
int s4;
};
struct b
{
double s1;
long long s2;
char* s3;
int s4;
};
struct c
{
short s1;
long s2;
void(*s3)(int);
size_t s4;
};
int main()
{
printf("a:%zu b:%zu c:%zu\n", sizeof(struct a), sizeof(struct b), sizeof(struct c));
return 0;
}
先来分析一下结构体a:其中有int类型(4字节),double类型(8字节),char类型(1字节),首先int s1一定是从偏移量为0的位置开始往后占用4字节空间的,根据对齐数的规则可以得知,s2的对齐数是8,由于0位置被占用了,因此s2就只能从8偏移量位置开始放置并往后占用8字节空间,而s3的对齐数显然是1,这里要说明的是,此时s1和s2之间有空位,但是s3是不能插空放置的,s3只能放在s2之后,也就是偏移量为16的位置,最后是s4,是int类型,对齐数显然是4,从前面的分析可知,16偏移量位置已经被s3占用了,因此s4就只能放置在偏移量为20的位置(4的倍数),最后从上方的分析可得,结构体a中成员变量的最大对齐数是8,因此结构体a的总大小一定是8的倍数,那么结构体a的大小显然就是24了。
然后是结构体b,其中有double类型(8字节),long long类型(8字节),char*类型(4字节),int类型(4字节),(注:本人在32为平台下跑的代码,因此指针类型是4字节,在64位下,指针类型会变为8字节,具体原因在本人的C语言的指针中有详细介绍,这里不再多说),首先double s1一定是从偏移量为0的位置开始放置并往后占用8字节空间,然后long long s2的对齐数是8,但是0被占用了因此s2会从偏移量为8的位置开始放置并往后占用8字节空间,然后是char* s3,其对齐数是4,因此会从偏移量为16的位置开始放置并往后占用4字节空间,然后是int s4,其对齐数是4,因此会从偏移量为20的位置开始放置并往后占用4字节空间,最后容易得出,结构体b中成员变量的最大对齐数是8,因此总大小必须是8的倍数,那么结构体b的大小显然就是24了
最后是结构体c,本人不再多说了,只说明一下其中成员变量的大小,short类型占用2字节,long类型占用4字节,void(*s3)(int)是函数指针,占用4字节(在本人的C语言的指针中有函数指针的详细介绍,这里不再多说),size_t s4是无符号整形,占用4字节,剩下的就都是老套路了,这里不再多说。
在计算结构体大小时,除非你已经非常熟练了,否则最好都画画图,从上方的讲解也可以看出,单纯用语言进行讲解是非常晦涩的,因此下面就来看看这三个结构体的内存实际占用情况的图示和它们实际的大小:

从上方的3个简单练习也可以看出,在计算偏移量时是从0开始的,计算结构体大小时是从1开始的,并且如果按照一定的顺序排布结构体中的成员变量,是可以实现节省内存的效果的,比如在结构体b中就没有浪费空间,但是在实际使用时,除非是在内存资源非常少的情况下(比如网络通信),一般我们并不会很在意结构体中的少量的内存浪费,不过有浪费总是不好的,因此在设计结构体时,可以采用: 尽量把结构体成员按照从大到小(如 double -> int -> char)的顺序排列,这样可以减少填充字节,避免因为对齐规则导致结构体“虚胖”。
然后让我们来看看当结构体中嵌套了结构体和在结构体中定义数组的情况(也就是第四,第五条规则):
首先是第四条规则:当结构体中包含数组时,数组的对齐数是数组的元素类型的对齐数(不是整个数组的类型大小),比如char arr[100]的对齐数是1(数组元素是char类型),double brr[100]的对齐数是8(数组元素是double类型),在实际计算大小时还是按照前三条规则进行计算的,非常容易理解,下面来举个例子:
struct test
{
short a;
int b[10];
double c;
};
int main()
{
printf("%zu", sizeof(struct test));
return 0;
}
如上方代码,首先结构体test中的第一个变量是short a,从0偏移量开始放置并往后占用2字节空间,然后就是一个数组,数组的元素是int类型,因此数组b的对齐数就是4,由于0偏移量位置被占用了,因此从4偏移量位置开始放置并往后占用40字节空间,就占用到了43号偏移量位置,最后是一个double类型的成员变量,其对齐数是8,由于前面的43个偏移量位置都被占用了,因此其只能从48号偏移量位置开始放置并往后占用8字节空间,最后可知,在结构体中成员变量的最大对齐数是8,因此最后结构体test的大小就是56。
最后是第五条规则,即结构体中嵌套其它结构体的情况,首先明确一点,计算结构体大小的关键就是其中成员变量的对齐数,因此对于结构体中嵌套结构体的情况,最关键的就是该嵌套结构体的对齐数,在第五条规则抽出最重要的就是:嵌套结构体的对齐数 = 它内部成员的最大对齐数。下面来举个例子:
struct tt
{
int a;
double b;
char c;
};
struct test
{
int i;
struct tt s;
long k;
};
int main()
{
printf("%zu", sizeof(struct test));
return 0;
}
如上方代码,在结构体test中就嵌套定义了结构体tt,那么为了计算出结构体test的大小,首先就要计算出结构体tt的大小和结构体tt的对齐数,计算结构体tt的大小非常简单,只需要按照前三条规则进行计算就可以了,结果是24,这里不再多说,关键是在结构体test中,结构体tt的对齐数是多少?,我们按照式子嵌套结构体的对齐数 = 它内部成员的最大对齐数进行计算,明显可以看出,结构体tt中所有成员变量中对齐最大的就是double b,其对齐数是8,因此在结构体test中,结构体tt的对齐数就是8,那么现在就可以把这个嵌套的结构体当成一个对齐数为8,空间占用为24字节的简单变量类型了,在结构体test中,第一个定义的成员变量是int i,其从0偏移量开始放置并往后占用4字节,然后就是结构体tt,其对齐数是8,因此必须从偏移量为8的整数倍的位置开始放置,由于0位置被占用了,因此结构体tt就只能从8偏移量位置开始放置并往后占用24字节,那么此时前31个偏移量位置就都被占用了,最后一个变量是long k,其对齐数是4,因此可以从32偏移量位置开始放置并往后占用4字节空间,由于结构体test中成员变量的最大对齐数就是其中嵌套的结构体tt的对齐数,因此结构体test的总大小就必须是8的倍数,因此可以计算出,结构体test的总大小是40,下面来看看实际情况:

在了解完结构体内存对齐规则后,相信大部分读者都会问:为什么要有这个规则,直接按顺序把结构体中的成员存放在内存中不香吗?在这里本人简单的说明一下,这本质上是一种以空间换时间的操作,首先,我们的程序最终一定都是要拿到CPU中执行的,但是CPU是不会一次把一个程序的所有的代码和数据都拿进来执行的,而是会一次读取几个字节(32为普通下是4字节,64为平台下是8字节),如果没有结构体内存对齐规则,那么在读取数据时就可能会出现只读到了一半的情况,比如在读取int时,第一次只读到了前两个字节,第二次又读到了后两个字节,那么CPU就还需要对数据进行拼接才可以得到正确的数据,此时就会大大降低CPU的效率,但是在有了结构体内存对齐规则后,CPU在读取数据时就很少会出现这种情况了,因此就提高了CPU的运行效率。这部分知识更多的和硬件相关,这里就不多介绍了。
最后补充几个细节,首先在VS编译器中用户是可以自己改变编译器默认的对齐数的,只需要使用下方的代码:
#pragma pack(4) //把默认对齐数改成4
要注意的是要:修改一般都是修改成2的某次方,否则读取数据时的CPU性能会下降。在修改后,计算结构体大小的方法是不变的,仅仅只是改了一下默认对齐数罢了。其次,当结构体中包含柔性数组时,计算结构体大小时不需要加上柔性数组,仅仅只需要按照内存对齐规则计算结构体中的其它成员的大小:
struct test1
{
int a;
long long b;
long long int c;
char* i;
char buff[];
};
struct test2
{
int a;
long long b;
long long int c;
char* i;
};
int main()
{
printf("test1:%zu test2:%zu", sizeof(struct test1), sizeof(struct test2));
return 0;
}

在上方的代码示例中long long和long long int完全是相同的类型,没有任何区别。
2.2.4结构体中体现的封装
上方的章节介绍了比较完整的介绍了结构体的语法和计算大小的方式,这些知识对于理解后面的类至关重要,比如计算类的大小的规则和结构体计算大小的规则几乎是一模一样的,在本节,本人将会介绍一下从结构体中体现出的封装思想(如果仅仅只是为了学习类的语法,那么可以跳过本节)。
向大部分学习过高级语言中的人提问高级语言的特性,回答一般都会是:封装,继承,多态,这个答案是没什么问题的,但是如果你继续提问:封装是什么,继承是什么,多态是什么,那多数人是说不出一个所以然的,他可能会用这些特性编写具体的代码,但是他无法总结出这些抽象的概念,在我们学习大部分知识是都是如此,比如数学中的函数,我们从小学到大,使用起来肯定是没什么问题的,但是如果让你说说看函数具体是一个什么东西,那好像总是会差那么点感觉,不要认为这些抽象的概念就不重要了,特别是在编程中,就说点实际的,在面试中就喜欢问这种比较开放的概念性问题,往往是这种问题才能看出一个程序员的水到底有多深。那么现在就让我们来看看,封装到底是一个什么东西:
在编程中,大部分概念起始都是来源于实际生活的,毕竟编程本身就是为了解决实际问题而诞生的,那么就让我们先抛开编程语言,从实际生活的角度来对所谓的封装有一个较为准确的感性认识:在生活中,封装这个概念是随处可见的,比如在收发快递时,就会用纸箱子把物品包装起来,那么为什么送快递时要把东西先包装起来呢,不能直接送物品吗?答案显而易见,首先,把物品包装起来可以保护物品,其次把物品包装起来后可以便于运输,最后把快递包装起来在分拣时就可以根据大小快速分类,在这个实际情景中,封装就是简单的把东西装起来,体现出的作用是:保护,提供便利和分类。
比如在吃饭时,我们会把没吃完的东西打包,并且在打包时,往往会把不同的菜打包到不同的盒子中,这也是一种封装,其中把不同的菜品打包到不同的盒子中在编程中就是一种解耦操作,如果把所有菜都打包到一个盒子中,此时只要有一个菜是坏的,那么所有的菜就都不能吃了,放到编程中,如果你定义了10个函数,这10个函数的功能都依赖了另一个函数,那么只要另一个函数一出问题,那么你定义的10个函数就都要完蛋,这就是耦合度太高导致的,在编程时,应该尽量降低代码的耦合度,在这个例子中,封装的作用就是降低耦合度。
下面再来看看另一个更加接近编程的实例,在几十年前,微信和支付宝还没有诞生的年代,每家银行都有自己的支付方式,比如:刷银行卡,使用U盾,发送随机数....从现在的视角来看,这些操作真是非常的麻烦,在不同的银行取钱还需要使用不同的方式,这个情况一直持续到支付宝诞生,那么支付宝做了什么呢?可以说支付宝先和每一家银行进行沟通,让这家银行把自己的支付方式提供给支付宝平台,最后,支付宝中就集成了所有银行的支付方式,然后再由开发支付宝的程序员对这些不同的支付方式进行封装,提供给用户一个统一的接口,即:扫码支付,在这个例子中,封装是利用中间层包装底层不同行为的接口,抹除差异,提供一个统一的使用方式给用户。
然后紧跟时事,来看看华为的鸿蒙系统是怎么利用封装强行抢占市场的,首先,一款操作系统被开发出来后,最重要的就是要有人使用,但这往往也是最难的一点,并且操作系统推出的时间越晚,抢占市场的难度就越大,但是开发操作系统又必须投入大量的时间,而且操作系统被开发出来后,如果第一时间没有大量的用户进行使用,那么这款操作系统很快就会被淘汰掉。如此大的难度,华为是怎么实现在Android,Windows,MacOS三足鼎立的情况下破围而出的呢?答案就是利用封装的思想,首先为了尽快的让自己的操作系统被用户知道,华为在鸿蒙系统还没有开发完成时就在自己的产品中说自己的系统是鸿蒙的(其实此时在底层用的还是安卓),然后再利用自身的硬件优势,把自己的产品卖向市场,此时尽管底层实际上用的还是Android系统,仅仅只是把名字换成了鸿蒙,但是此时鸿蒙就已经被用户所知晓了,这就保证了只要真正鸿蒙系统一推出,就立刻会有大量的用户群体,最后把底层的操作系统从Android系统换成自己的鸿蒙就是非常轻松的事情了,对于用户来说就只是一次系统更新,更新后只要在使用时没有变化,该有的功能还有,那么用户就会欣然接受鸿蒙系统了,在这个实例中,华为利用硬件封装软件,抢占到了市场,封装体现出的作用就是:就算底层的代码已经完全不同了,但是只要接口没有变化,对于用户就不会有任何影响。
最后让我们回到实际的代码,来看看结构体中体现的封装,首先没有结构体可以吗?答案是可以的,只不过在此时你就需要在每一个使用到该结构体的地方定义一堆类型了,因此结构体中体现的第一个封装功能就是提供便利,增强代码的复用性,其次,使用结构体封装类型后,如果在需要提供更多功能,就只需要在结构体中添加就可以了,然后在所有使用了该结构体的地方就都会获得该功能,又或者在代码中某一处使用该结构体的代码报错了,那么也可以快速定位到该结构体,此时结构体中体现出的第二个封装功能就是增强了代码的扩展性和可维护性,最后结构体包装不同类型的行为天生就体现出了封装中的分类思想,并且把不同的类型归为一类本身也降低了代码的耦合度。在实际情况中,结构体还会有更加复杂的封装行为(比如Linux操作系统中,其对于结构体的使用可谓是登峰造极),上方说的只是一些简单情况,但是更加复杂的情况就必须要配合实际代码来解释了,本人会在之后对于Linux操作系统的介绍中在来详谈。
2.2结构体的升级版:类
终于,我们来到了文章的主题:类,但是这并不意味着上方的知识就不重要了,在第一章中对类和对象概念的讲解是为了提供一个感性的认识,在第二章的第一节中对结构体语法的讲解是为了给类的语法进行过渡,那么下面废话不多说,先来看看C++中的类具体是什么样子的。
2.2.1类的声明
在C++中,可以使用两个不同关键字声明一个类,即:class和struct(这两者的区别本人在后文进行讲解),在大部分情况下会使用关键字class声明一个类,在关键字后面就要指定类的名称,然后就需要写上一个{},并在{}中定义类的主体,其同样要在{}后面加上一个;(分号),在{}中可以定义变量,也可以定义函数,在类中定义的变量称为该类的属性或成员变量,在类中定义的函数称为该类的方法或成员函数,下面来看看具体的使用方法:
class testc //方法1
{
public:
void fun()
{
cout << "hello world" << endl;
}
private:
int _a;
char _b;
double _c;
};
struct tests //方法2
{
public:
void fun()
{
cout << "hello world" << endl;
}
private:
int _a;
char _b;
double _c;
};
如上方代码,分别使用了关键字class和struct声明了一个类,这两种定义方法的效果有一些细微的差别,现在先抛开这些差别,只看语法,可以发现和结构体是非常像的(毕竟就连关键字都可以是struct),目前看来就只有两点不同,一是public和private,简单来说这东西叫访问限定符,其作用是保护类中的成员变量和成员函数(详细语法在后文再进行介绍),二是在一个类中可以定义具体的函数,在后文还会介绍其它差别,但是类和结构体最主要的差别就是这两个了,所以才说,类是结构体的升级版。在上方定义类的代码中,可以看到在类的每一个成员变量的前方都加上了_(下划线),这并不是语法强制规定的,而是一种约定俗成,其作用是区分开类的成员变量和普通变量(区分的方式不一定就必须是_)。
还需要注意的是直接定义在类中的成员函数默认为inline(内联),不过编译器通常只会将类中比较短小的函数变成实际上的内联函数,内联函数并不是本文的重点,因此这里只做简单的介绍:内联函数是C++中加入的新语法,其效果和C语言中的宏函数差不多,都是在调用函数的地方直接展开函数,并且和宏函数一样其只能在该文件内部使用(不能跨文件)。
2.2.2访问限定符
下面先来看看在类中出现的第一个陌生的东西:访问限定符,在结构体中,用户可以随意的访问结构体内部任意变量,这其实是一种比较危险的行为,在大多数情况下,我们并不希望用户接触到程序底层的代码,只会给用户提供特定的接口,因此在类中就加入了访问限定符,通过访问限定符限制访问权限就可以选择性的在类内部提供接口给用户使用。
在C++中,访问限定符有三个,即:public(共有), private(私有), protected(保护),其中被public修饰的成员在类外部可以直接进行访问,被private和protected修饰的成员在类外部不能被直接访问,private和protected的区别在类的继承中才会体现出来。在类中,一个访问限定符的作用范围是从该访问限定符出现的位置开始直到下一个访问出现的位置为止,如果一个访问限定符的后面没有其它访问限定符了,那么该访问限定符的作用范围就会到该类结束的位置为止。下面来看看具体的代码:
class test
{
public:
int _a;
int _b;
void fun1()
{
cout << "hello" << endl;
}
private:
int _c;
int _d;
void fun2()
{
cout << "world" << endl;
}
protected:
int _e;
int _f;
void fun3()
{
cout << "iii" << endl;
}
};
如上方定义的test类,其中第一个出现的访问限定符是public,那么该访问限定符的作用范围就是从public到其下方private之间,并且在访问限定符public和private之间的成员变量(_a, _b)和成员函数(fun1)就会受public限定,在类外部可以随意访问,同理,第二个访问限定符private的作用范围就是从private到其下方的protected之间,并且在访问限定符private和protected之间的成员变量(_c, _d)和成员函数(fun2)受private限定,在类外不能进行访问,而第三个出现的访问限定符是protected,其下方没有访问限定符了,那么其作用范围就是从protected开始到该test类结束为止,其中的成员变量(_e, _f)和成员函数(fun3)就受到protected的限定,在类外不能进行访问。要注意的是,访问限定符限定的只是在类外对成员变量和成员函数的使用,在类内部是可以任意使用所有成员变量和成员函数的。
在这里就可以说明使用class和struct声明类的区别了:对于使用class声明的类,其内部默认是使用了private修饰的,对于使用struct声明的类,其内部是默认使用public修饰的,也就是相当于声明时在第一行写入了不同的访问限定符,即:
class T
{
private:
}
struct T
{
public:
}
最后用压栈表格总结一下访问限定符的知识:
| 访问限定符 | 类内部可访问 | 类外部可访问 (通过对象) |
|---|---|---|
public(共有) |
✅ 是 | ✅ 是 |
protected(保护) |
✅ 是 | ❌ 否 |
private(私有) |
✅ 是 | ❌ 否 |
在没有继承时,可以认为private和protected是等价的,在继承中这两者的区别才会体现出来,具体细节在继承章节在进行介绍。
2.2.3类的实例化
类与结构体非常相似,都是比较复杂的自定义类型,都是编程中的图纸,而一张图纸被画出来后最重要的就是把它实例化,因此在本小节就来详细的介绍一下类要怎么进行实例化,首先要说明的是类的实例化方法与结构体非常相似,下面来看看具体代码:
class test
{
public:
int _a;
int _b;
void fun1()
{
cout << "hello" << endl;
}
private:
int _c;
int _d;
void fun2()
{
cout << "world" << endl;
}
protected:
int _e;
int _f;
void fun3()
{
cout << "iii" << endl;
}
};
int main()
{
test t1;
t1.fun1();
t1._a = 10;
t1._b = 100;
t1.fun2();
t1._e = 1000;
test* t2;
t2->_a = 10;
return 0;
}

如上方代码,对类test进行了实例化,需要注意的是在实例化时,前方不需要加上class(对于使用struct声明的类也是),只要使用类名进行实例化就可以了,并且和结构体类似,类实例化出的对象也是使用.来访问类中的具体成员,在此时访问限定符的作用就体现出来了,对于被public修饰的成员,实例化后在类外部可以随意使用,但是对于被private和protected修饰的成员,实例化后在类外部是不能进行访问的。并且和结构体类似,类同样可以使用指针进行实例化,实例化后也是使用->来访问类中具体的成员。最后要注意的是在C++中可以以使用new在堆区中进行类的实例化。
上方三小节的内容还是非常的简单的,如果你已经完全理解了上方的内容,那么就可以写出一些比较简单的类了,但是如果情况稍微复杂一点,那上方的知识就兜不住了,为了提高对类的控制能力,下面就要开始爬坡了。
2.2.4类域
在C++中存在4个域:函数局部域,全局域,类域,命名空间域,其中函数局部域和全局域会影响变量的生命周期,而类域和命名空间域不会影响变量的生命周期,本文着重介绍一下类域,一个类域从类的定义位置开始(class),到 };为止,在一个类域的内部,所有成员可以直接相互访问,不会受到访问限定符的限制。
类域在本质上也是一种封装,用于限定类中成员变量和成员函数的作用范围,在指定类域后编译器就会优先去指定的类中进行查找,如果想要实现类中成员函数的声明和定义分离,那么就需要指定类域了,下面来看看具体的代码:
class test
{
public:
void fun();
private:
int _a;
};
void test::fun()
{
_a = 100;
cout << "hello" << endl;
}
void fun()
{
cout << "world" << endl;
}
int main()
{
test t;
t.fun();
fun();
return 0;
}

如上方代码,我们在类test内部声明了函数,在类外部进行了函数的定义,此时就需要指定类域了,我们使用(类名::)来指定类域,就相当于告诉编译器,指定了类域的函数是属于类内部的,而没有指定类域的函数是类外部的,此时由于定义函数的作用域不同,因此并不会出现命名冲突。并且由于指定了类域的函数是属于类内部的,因此在函数中可以任意访问类中任何位置的成员变量和成员函数,不会受到访问限定符的限制。
还需要进行说明的是,如果类的成员函数的声明和定义分离了(声明在类内部,定义在类外部),此时成员函数是不会默认内联的,如果想要内联就必须在声明和定义处都显示的加上inline关键字。还要注意的是:不能直接使用::来使用类中声明的函数:
struct T
{
void fun()
{
cout << "hello world" << endl;
}
};
int main()
{
T::fun();
return 0;
}

如果要使用类中的成员函数,必须先进行实例化。
2.2.5类的前置声明
类似于函数可以通过前置声明的方式让代码上方的函数调用到代码下方的函数,类也有前置声明,通过类的前置声明也可以实现类似的效果,不过类的前置声明有不少限制,下面来具体看看:
void apple(int a);
void fun1()
{
apple(3);
}
void apple(int a)
{
cout << "有" << a << "个苹果" << endl;
}
/////////////////////////////////////////
class test;
void fun2()
{
test t;
}
class test
{
public:
void hello()
{
cout << "hello" << endl;
}
int _a;
};

如上方代码,同时使用了函数的前置声明和类的前置声明,其中函数的这种前置声明和使用是没什么问题的,至于类的前置声明和使用,看起来似乎没什么问题,但是却报错了,原因是这个声明只向编译器透露了类的名字,但没有透露类的大小、成员变量、成员函数等任何内部细节(这被称为“不完整类型”),此时编译器编译器只知道 test 是个类,但不知道它需要占用多少内存,那么就无法使用该前置声明给类对象分配正确的内存空间,因此前置声明不能这样使用,下面来看看类的前置声明的正确用法:
class test;
void fun2()
{
test* t = nullptr;
}
class test
{
public:
void hello()
{
cout << "hello" << endl;
}
int _a;
};
只需要使用类指针就可以了,因为无法使用类名进行定义的直接原因就是编译器无法确定类的实际大小,但是指针的大小是固定的(32位平台下是4字节,64位平台下是8字节),并且为了避免出现野指针,最好将其初始化为nullptr(效果类似于C语言中的NULL)要注意的是此时是不能直接使用该指针初始化或是访问类中的成员:
class test;
void fun2()
{
test* t = nullptr;
t->hello();
t->_a = 10;
}
class test
{
public:
void hello()
{
cout << "hello" << endl;
}
int _a;
};
具体原因并不是由于nullptr,而是前置声明仅仅只是告诉编译器类的名字,编译器无法通过前置声明得知类中具体有哪些成员,因此指针虽然能够定义,但是不能通过指针访问类中的成员。那么此时就有人会问了,这个类的前置声明有什么用呢?连访问成员都做不到,那么现在想象一种场景,你定义了两个类:A和B,在A中要定义类B,在B中要定义类A,此时就出现了循环依赖的问题,即:
class B;
class A
{
public:
void fun1()
{
cout << "fun1" << endl;
}
int _a1;
B _b1;
};
class B
{
public:
void fun2()
{
cout << "fun2" << endl;
}
int _a2;
A _b2;
};

此时直接使用前置声明是无法解决问题的,因此就必须使用指针来进行定义了,在实际使用类中这种场景还是比较常见的,下面来看看具体的代码:
class B;
class A
{
public:
void fun1()
{
cout << "fun1" << endl;
}
int _a1;
B* _b1;
};
class B
{
public:
void fun2()
{
cout << "fun2" << endl;
}
int _a2;
A _b2;
};
int main()
{
A a;
B b;
a._a1 = 1;
a._b1 = &b;
a.fun1();
b._a2 = 2;
b._b2 = a;
b.fun2();
return 0;
}
如上方代码,使用指针就可以解决循环依赖问题了,并且在实例化类时是在类的下方,因此此时编译器就可以确定类中具体有什么东西了,那么就可以正常的使用类来实例化对象了。前置声明的主要使用场景就是解决循环依赖问题,不过有时候在项目中也会使用前置声明来提高编译速度,在这里就不做介绍了。
2.2.6类大小的计算
在上文已经提到过,类大小的计算和结构体是非常相似的,唯一有点区别的就是在类中定义的函数的大小,下面先来看看类中没有定义函数的情况:
class test
{
public:
int _a;
int _b;
long long _c;
};
int main()
{
cout << sizeof(test) << endl;
return 0;
}

没有在类中定义函数时,访问限定符也不会占用内存,因此在此时计算类大小的规则和结构体中的内存对齐规则是一模一样的,这里不再多说。下面来看看在类中定义函数的情况:
class test
{
public:
int _a;
int _b;
long long _c;
void fun1()
{
cout << "hello" << endl;
}
void fun2()
{
cout << "world" << endl;
}
};
int main()
{
cout << sizeof(test) << endl;
test t;
cout << sizeof(t) << endl;
return 0;
}

结果有些出人意料,在类中定义的函数并不会占用该类实例的内存空间,其实这么做是非常有道理的,如果在类中定义的函数会占用该类实例的内存空间的话,那么此时实例化出10个对象,同样的函数就会在内存中出现10次,这显然是一种内存资源的浪费,那么现在的问题是:在类中定义的函数在类实例化后会被存放在哪里呢?这个问题单从语言层面是找不到答案的,答案在操作系统中,这里本人简单是说明一下:这些函数会被存放到代码的共享区,可以理解成把类中定义的函数都抽了出来放到了一个公共区域,然后类的实例在调用函数时就只需要去公共区域中调用就可以了,因此在类中定义的函数是不会占用该类的内存的。下面来看看另一种情况:空类
class test
{
};
int main()
{
cout << sizeof(test) << endl;
test t;
cout << sizeof(t) << endl;
return 0;
}

顾名思义,空类就是在类中没有任何成员,C++是允许空类存在的(在C语言中没有空结构体),此时可以看到,空类的大小为1,但是这个1仅仅只是为了让该类实例化出的对象有不同的地址,如果空类大小为 0,当定义该类的两个对象时,它们的地址就会重叠,这在 C++ 标准中是不允许的。以上只是一些简单的情况,在实际计算时还需要考虑继承和结构体中定义的静态成员的情况,这些情况本人会在之后进行详细的介绍。
2.2.7类中的成员函数
在上文已经说明,我们是可以直接在类中定义具体的函数的,并且在一个类中定义的函数实际上并不会存放在类的实例中,而是会统一放到一个公共区域,类的所有实例在调用成员函数时都会去这个公共区域进行调用,那么问题就来了:编译器是怎么确定具体是类的哪一个实例调用了公共区域的成员函数呢?并且在成员函数中修改成员变量时,编译器又要这么确定具体要修改哪一个类对象的成员变量呢?比如下方的代码:
class people
{
public:
void fun()
{
cout << "hello world" << endl;
int _a = 10;
}
public:
int _a;
};
int main()
{
people ZhangSan;
people LiSi;
ZhangSan.fun();
LiSi.fun();
}
此时程序员可以用眼睛看出来具体是哪一个实例调用了成员函数,但是编译器可没有眼睛,并且在类中定义的成员函数也不像结构体中的函数指针指向的是位于全局的函数,因此为了解决这个问题,在C++的类中的非静态成员函数都会有一个隐藏的参数:this,该参数是一个本类类型的指针,会指向类的实例(在Python的类的成员函数中也有类似的东西:self,不过self不是隐藏起来,但是原理是一样的),在传参就会默认传入具体对象的指针,通过这个指针编译器就可以确认出具体是哪一个类的对象调用了成员函数了,说起来比较抽象,下面来看看如果没有把这个参数隐藏起来是什么样子的:
class people
{
public:
void fun(people* const this)
{
cout << "hello world" << endl;
}
};
int main()
{
people ZhangSan;
people LiSi;
ZhangSan.fun(&ZhangSan);
LiSi.fun(&LiSi);
}
写明后就非常好理解了,其实就是多传了一个指向具体对象的指针,通过这个指针来确定具体是哪一个对象调用了成员函数,需要注意的是上方的这种写法是错误的,仅仅只是为了便于读者进行理解,在实际使用成员函数时是不能把这些东西显示的写出来的。还可以看到在这个隐藏起来的this参数是被cosnt修饰了的,此时这种修饰方法就是this本身不能修改,但是this指向的内容可以修改,这么设定是非常有道理的,毕竟在成员函数中,this一定是只能指向具体对象的,当然是不能修改的。
现在第一个问题:怎么确定具体是哪一个对象调用的成员函数已经解决了,下面来看看第二个问题:怎么确定在成员函数中修改的具体是哪一个对象的成员变量,其实答案已经可以大概猜测出来了,关键依旧是这个this指针,简单来说就是在成员函数内部访问成员变量或是调用其它成员函数时,在其前方都会有一个隐藏的this->,通过这个指向具体对象的指针就可以解决这个问题了,需要注意的是,在形参和实参处,this都不能显示的写出和传递,但是在成员函数内部是可以显示的写出this的,具体操作来看看下方的代码:
class test
{
public:
void fun1()
{
cout << "hello world" << endl;
}
void fun2()
{
this->_a = 10;
this->_b = 0;
this->fun1();
cout << "a:" << this->_a << " b:" << this->_b << endl;
}
void fun3()
{
_a = 10;
_b = 0;
fun1();
cout << "a:" << _a << " b:" << _b << endl;
}
private:
int _a;
int _b;
};
int main()
{
test t;
t.fun2();
t.fun3();
return 0;
}

如上方代码,在test类中成员函数fun2和fun3是完全等价的,只不过fun2在函数体中显示的写出了this->,这也进一步证明了在类的成员函数中确实会有一个隐藏的指向具体对象的this指针,而在成员函数中可以显示的写出this指针是因为在某些场景下,会有需要在成员函数中返回对象自身的需求,具体场景本人会在后面进行详细说明。
在上文已经提到过,在类的成员函数中这个隐藏起来的this指针是:类型* const this,但是由于this参数是隐藏起来的,那么就出现了一个问题:要怎么样才能用const修饰this指向的内容呢?(就是要这么把类型* const this变为const 类型* const this), 在实际使用时肯定是会出现不希望在成员函数内部修改类成员变量的情况的,此时就需要必须让this变为const 类型* const this了。其实答案非常简单,只需要在成员函数的后方加上一个const就可以了,下面来看看具体的代码:
class test
{
public:
void fun() const
{
cout << "hello world" << endl;
}
};
如上方代码,此时成员函数fun的形参中隐藏的test* const this就变为const test* const this了。
2.2.8静态成员变量和静态成员函数
在C++的类中可以定义静态成员变量和静态成员函数,即使用static修饰的成员变量和成员函数,此时成员变量和成员函数就会发生一些变化,而想要深刻的理解这些变化,就必须将静态和非静态进行对比,因此本人将从对比的角度进行切入。
先来看看静态成员变量和非静态成员变量的对比,首先非静态的成员变量是属于对象本身的,而静态成员变量是属于类本身的,其被类的所有实例共享,举实例来说,非静态成员变量就像是班级里面的课桌,每个学生都有独属于自己的课桌,而静态成员变量就像是班级中的投影仪,全班就只有一个,所有学生共享,下面来看看用具体代码举出的例子:
class people
{
public:
string _name;
int _age;
static string _clothes;
};
string people::_clothes = "背带裤";
int main()
{
people one;
one._name = "张三";
one._age = 18;
people two;
two._name = "李四";
two._age = 11;
cout << "one name:" << one._name << " one age:" << one._age << " one clothes" << one._clothes << endl;
cout << "two name:" << two._name << " two age:" << two._age << " two clothes" << two._clothes << endl;
cout << endl;
one._clothes = "背心";
cout << two._clothes << endl;
return 0;
}

如上方代码,在类people中成员变量_name和_age是非静态的,_clothes是静态的,通过后面的实例明显可以看出_name和_age是属于对象自身的,所谓的属于对象自身就是不可能使用对象two访问到对象one中的非静态变量,two.name只能打印出李四,不可能打印出张三。而使用_clothes定义的背带裤是所有对象共用的,one._clothes和two._clothes打印出的都是背带裤,并且通过对象one更改了_clothes后,对象two的_clothes也会改变。
通过上方的代码也可以发现,静态成员变量和非静态成员变量的定义方式是不同的,非静态成员变量必须通过具体的对象进行定义(一般会使用构造函数,在后文详细说明),而静态成员变量是直接通过指定类域的方式进行定义的,从这里也可以看出静态成员变量是属于类本身的,不属于任何具体的对象。下面来详细的说说定义静态成员变量的细节:在C++的类中声明的静态成员变量必须在类外通过指定类域的方式进行初始化定义(除非是内联静态或整形常量),也就是说不能这样进行定义:
class test
{
public:
int _a = 10;
static int _b = 100;
};

在上文提到的内联静态和整形常量是C++17加入的新特性,并不是本文的重点,本人将在之后对的静态变量的具体说明的博客中进行详细介绍。一般来说,在C++中非静态成员变量可以在类内部声明其值,而静态成员变量必须在类外通过指定类域的方式进行定义和初始化,即:
class test
{
public:
int _a = 10;
static int _b;
};
int test::_b = 100;
int main()
{
cout << test::_b << endl;
return 0;
}
要注意的是,在类外进行定义和初始化时,不需要再加上static,并且由于静态成员变量是属于类本身的,因此可以直接使用类名访问类中的静态成员变量(非静态成员变量必须使用具体的对象进行访问),但是这并不意味着静态成员变量在类中声明时就会直接开辟实际的内存空间,在类中声明的静态成员变量可以说是一个标记,编译器在编译,连接阶段才会识别这个标记并在全局数据区为其分配内存空间。在绝大多数情况下,类的声明仅仅就只是一个图纸,无论图纸中有多少东西,都不会占用实际的物理内存,仅有的两个特例是空类和类中出现虚函数的情况,编译器为了避免空类实例出的对象的地址发生重叠,会强行在空类声明时开辟1字节内存空间,至于虚函数在后面的继承章节再进行介绍。
下面来填上在类大小计算小节中挖的坑,来看看当类中出现静态成员变量时,类的大小要怎么计算:简单来说,由于类中的静态成员变量是属于类本身的,所以其并不会占用类对象的内存,因此在使用sizeof计算类大小时,不会计入类中的静态成员变量,下面来看看具体的代码:
class test1
{
public:
int _a = 10;
static int _b;
};
class test2
{
public:
int _a;
};
class test3
{
public:
static int _a;
static int _b;
static int _c;
};
int main()
{
cout << "test1:" << sizeof(test1) << " test2:" << sizeof(test2) << " test3:" << sizeof(test3) << endl;
return 0;
}
如上方代码,在test1比test2多定义了一个静态变量,但是其大小没变,并且如果类中定义的全部都是静态成员变量,那么该类就是一个空类了,因此在计算出现静态成员变量的类的大小时,只需要忽略掉静态成员变量,然后按照内存对齐的规则进行计算就可以了。最后使用一张表格来总结一下静态成员变量和非静态成员变量的区别:
| 特性 | 静态成员变量 (Static Field) | 非静态成员变量 (Instance Field) |
|---|---|---|
| 归属 | 属于类本身 | 属于具体的对象 (实例) |
| 副本数量 | 1份 (所有实例共享) | N份 (每个对象各有一份) |
| 内存位置 | 全局数据区 | 随对象分配,一般位于栈区 |
| 访问方式 | 类名::变量名 或 对象.变量名 |
只能对象.变量名 |
| 生命周期 | 程序启动时创建,程序结束时销毁 | 对象创建时诞生,对象销毁时消亡 |
| 初始化 | 在类外通过指定类域的方式进行初始化 | 对象创建时初始化 (构造函数) |
表格中提到的生命周期也是一个区别,简单来说就是静态成员变量的生命周期随项目,非静态成员变量的生命周期随对象。
下面来看看静态成员函数和非静态成员函数的对比,首先,非静态成员函数是属于对象的,而静态成员函数是属于类本身的,这一点实际体现在静态成员函数中没有隐藏的this指针,因为this指针就是为了指向实际的对象而存在的,既然静态成员变量不属于实际的对象,那么当然也就不需要this指针了。失去了this指针后,最明显的区别就是:由于非静态的成员变量和非静态成员函数是属于具体对象的,必须要通过指向具体对象的this指针才可以直接进行访问,因此静态成员函数不能直接访问类中的非静态成员变量和非静态成员函数了(可以通过传参的方式间接访问),但是由于静态成员变量也是属于类本身的,访问类自身的静态成员变量不需要this指针,因此静态成员函数可以直接访问到其它静态成员变量和静态成员函数,下面来看看具体的代码:
class test
{
public:
static void fun1()
{
fun2();
_b = 100;
}
static void fun2()
{
cout << "hello world" << endl;
}
void fun3()
{
cout << "hhh" << endl;
fun2();
fun3();
_b = 10;
}
private:
int _a;
static int _b;
};
如上方代码,fun1和fun2是就是静态成员函数,_b是静态成员变量;而fun3是非静态成员函数_a是非静态成员变量,在静态成员函数fun1中可以调用静态成员函数fun2,可以修改静态成员变量_a,在这里要补充的一点是,可以看到在类中可以使用上方的函数调用下方的函数,也可以使用下方的成员变量,并且不需要进行函数的声明,一般来说编译器在编译时都是从上往下进行编译的,因此如果上方的函数要调用下方的函数就必须在上方函数的更上方进行下方函数的声明,但是在类中不需要,可以认为类是一个整体,不会有上下之分,因此不需要在意位置,并且不只有成员函数如此,也就是说可以:
class test
{
public:
void fun()
{
i a = 10;
}
typedef int i;
};
总之只需要把类中的所有东西都当成一个整体就可以了,下面让我们回到正题,可以看到在非静态成员函数中既可以调用静态成员函数,也可以访问静态成员变量,下面来看看如果在静态成员函数中使用非静态成员函数会发生什么:
class test
{
public:
void fun1()
{
cout << "hello world" << endl;
}
static void fun2()
{
fun1();
}
};

错误信息中的调用非静态成员变量需要一个对象就是说静态成员函数中没有this指针,不属于具体的对象,仅仅只属于类本身,被所有的对象共享,因此编译器当然就不能在静态成员函数找到具体的对象了,在静态成员函数中访问非静态成员变量也会报出差不多的错误。
和静态成员变量类似,静态成员函数既可以直接使用类::函数来进行调用,也可以使用对象.函数进行调用,而非静态成员函数和非静态成员函数类似,由于在没有实例化前仅仅只是声明,因此只能通过具体的对象使用对象.函数进行调用,下面来看看实际的代码:
class test
{
public:
void fun1()
{
cout << "fun1" << endl;
}
static void fun2()
{
cout << "fun2" << endl;
}
};
int main()
{
test t1;
test::fun2();
t1.fun2();
test t2;
t2.fun1();
return 0;
}

从此处也可以明显的体现出所谓的静态成员函数是属于类本身的和被所有对象共享具体是什么意思。至于静态成员变量的内存占用,和非静态成员函数类似,都不会占用类或对象的内存,而是会被抽出来放到内存中的另一个区域,因此在如果在计算类大小时遇到了静态成员函数,只需要直接忽略就可以了。最后要说明的一点是由于静态成员函数的定义在类内部的,因此其不会受到访问限定符的限制(在类中声明或定义的东西都不会受到访问限定符的限制)。下面用一张表格来总结一下静态成员函数和非静态成员函数的区别:
| 特性 | 静态成员函数 (Static) | 非静态成员函数 (Non-Static / Instance) |
|---|---|---|
| 归属 | 属于类本身 | 属于对象 (实例) |
| 调用方式 | 类名::函数名()或对象.函数名()或对象指针->函数名() |
对象.函数名()或对象指针->函数名() |
| this 指针 | 没有 this 指针 | 有 this 指针 (指向调用它的对象) |
| 访问权限 | 只能直接访问静态成员 (变量/函数) | 可以访问所有成员 (静态和非静态) |
| 生命周期 | 随程序/类加载而存在,与对象无关 | 依赖于对象的生命周期 |
最后要说明的是静态成员也是类的成员,同样会受到类中访问限定符的限制。总之如果想要分清楚类中静态和非静态的区别,最重要的就是要搞明白静态的东西是属于类本身的,被类的所有对象共享,而非静态的东西是属于单独的对象的,绝对不能和其它对象进行共享。
2.2.9友元声明
1.友元函数
在实际使用类时,有时候会希望让类外部的函数直接访问类中私有成员或是保护成员(比如不能让函数的第一个参数为this时),此时就需要使用友元声明了,先从感性的角度来看看友元是个什么东西:所谓的友元,可以理解成就是让类外部的函数和类成为朋友,然后在该函数中定义的类的对象就可以访问类中的私有或保护成员了,比如说你是一个千万富翁,你家里面的800平大床和10000平的泳池都是你私有的,外人不能使用,某天张三想要去你家的泳池游泳,那么他当然不会直接去,而是先请你吃了几顿饭,送了几瓶酒,然后就和你成为了朋友,那么此时张三就可以以朋友的身份访问你的私人泳池了。
用更标准的话来说就是:在 C++ 中,友元声明(Friend Declaration)是一种机制,它允许你打破类的封装限制,授予非成员函数或其他类访问当前类的私有(private)和保护(protected)成员的特权。下面来看看实际的代码:
class test
{
private:
friend void fun();
int _a;
int _b;
void apple()
{
cout << "apple" << endl;
}
};
void fun()
{
test t;
t._a = 10;
t._b = 100;
t.apple();
cout << "a:" << t._a << " b:" << t._b << endl;
}
int main()
{
fun();
return 0;
}

如上方代码,友元声明的语法也非常简单,就是在类中的函数声明前加上一个friend,需要注意的是友元函数中没有this指针,因此在函数中只能使用具体的对象来访问类中的成员,并且在有了友元声明后,在该函数的对象中确实就可以访问类的私有成员了。还需要注意的是友元函数并不会被视为类内部的东西,也就是说如果在友元函数的定义中使用了对应的类来实例化出对象,那么该友元函数要么定义在类的下方,要么在其上方加上类的声明,不过通常就会直接定义在类下方,因为使用前置声明的话友元函数就无法访问到类中的成员了。总之友元函数并不是类的成员函数,可以将其理解成一个可以访问类中私有和保护成员的特殊的全局函数。
2.友元类
友元类和友元函数的效果类似,假设定义了类A和类B,类A成为了类B的友元类,那么在类A中定义的类B的对象就可以任意使用类B中的私有成员和保护成员,下面来看看具体的代码:
class B
{
friend class A;
private:
int _a;
int _b;
int _c;
void apple()
{
cout << "apple" << endl;
}
};
class A
{
public:
void fun()
{
B b;
b.apple();
b._a = 1;
b._b = 2;
b._c = 3;
}
};
效果一目了然,可以当A成为B的友元类后,在类A中定义的类B的对象就不会受到类B中访问限定符的限制了,在这里要注意的是在类的任何地方都可以进行友元声明,无论是声明友元函数还是友元类。不过在大部分情况中,并不会直接将一个类声明成另一个类的友元(严重破坏了封装性),而是会把类中的成员函数单独抽出来进行友元声明,不过此时要非常注意类之间的依赖问题,下面来看看具体要怎么做:
class A
{
public:
void fun();
};
class B
{
friend void A::fun();
private:
int _a;
int _b;
void bb()
{
cout << "bb" << endl;
}
};
void A::fun()
{
B b;
b.bb();
b._a = 10;
b._b = 20;
}
int main()
{
A a;
a.fun();
return 0;
}

如上方代码,首先先弄清楚需求:在类A中声明的成员函数fun希望能够访问到类B中的私有成员变量_a和_b,并调用到类B的私有成员函数bb,那么就需要让类A中的成员函数fun成为类B的友元,因此在类B中就使用了友元声明,要注意的是在进行类中成员函数的友元声明时必须要指定类域。本例只是以最简单的方式进行演示,在实际项目中至少都会有上百个类,类之间互相关联,友元关系就会变得非常复杂,此时从具体的需求入手就是一个不错的方法,首先要通过画图理顺逻辑,然后才是看具体的代码细节,上手直接死磕代码的话你脑袋看炸了都不一定能看懂。
最后再来看看友元类中一些不同于友元函数的东西:
| 单向性:在类中友元关系是单向的。如果类 A 是类 B 的友元,类 B 并不自动成为类 A 的友元。如果你想让它们互为友元,必须在两个类中都进行声明。 |
| 不具有传递性:如果类 A 是类 B 的友元,而类 B 是类 C 的友元,这并不意味着类 A 是类 C 的友元。 |
| 注:在继承中友元类还会有其它性质,在后文在进行详细说明。 |
可以发现,友元声明虽然使用起来非常的方便,但是其破坏了封装性,增加了代码的耦合度,这是友元机制最主要的缺点。它让外部函数可以直接读写类的私有成员。一旦类的私有成员发生改变,所有友元函数都可能需要修改。破坏封装性是一个比较大的问题,因此在非必要情况下,不建议使用友元声明。
2.2.10内部类(嵌套类)
顾名思义,所谓的内部类就是声明在一个类内部的类,内部类和声明在全局的类最大的区别就是内部类会受到外部类访问限定符的限制,并且内部类默认是外部类的友元类,也就是说内部类的成员函数可以直接访问外部类的私有和保护成员,其中对于外部类的静态成员,内部类可以直接访问(因为静态成员是属于类本身的)或是使用外部类的对象进行访问(静态成员所有对象共享),对于非静态成员,内部类只能通过外部类的对象进行访问(因为非静态成员是属于具体对象的) 。而对于外部类来说,其并不会默认成为内部类的友元,在外部类中使用内部类和使用全局类没有任何区别。下面来看看具体的代码:
class A
{
private:
class B
{
public:
void funB()
{
_a2 = 100;
A aa;
aa._a1 = 100;
aa._a2 = 1;
cout << "funB()->" << "_a1:" << aa._a1 << " _a2:" << _a2 << endl;
cout << "static _b2:" << _b2 << endl;
apple(); //A::apple()也可以
}
private:
char _b1;
static char _b2;
};
public:
void funA()
{;
B b;
b.funB();
}
private:
static void apple()
{
cout << "I like apple" << endl;
}
private:
int _a1;
static int _a2;
};
int A::_a2 = 0;
char A::B::_b2 = 'i';
int main()
{
A a;
a.funA();
return 0;
}

代码有一丢丢复杂,借着对这个代码的讲解,也来对前方的知识做一个小总结,首先从代码的运行逻辑进行分析:先执行main函数,在函数中定义了一个类A的对象a,然后使用对象a调用了类A中的非静态成员函数funA,由于该成员函数的访问限定符是public,所以可以正常调用,然后在成员函数funA中定义了一个内部类B的对象b,此时对于外部类A来说,内部类B和定义在全局没什么区别,因此对象b不能访问类B的私有成员,不过类B中的非静态成员函数funB是public的,因此可以正常的调用,最后进入对象b的成员函数funB中,此时类B是类A的内部类,那么类B就默认是类A的友元,因此在funB函数中定义的类A对象aa可以随意使用类A中的私有成员或保护成员,因此对象aa成功的定义了类A私有成员变量的值,并调用到了类A中的私有成员函数apple,打印出了最后的结果。
再来从语法的角度来说说,首先可以看到类B是类A的内部类,并且被类A用private修饰,这意味着在类A外部是无法使用类B定义对象的,此时类B只能给类A使用,然后来看看静态成员变量的情况,在上方的代码中,类A和类B都有一个静态成员变量,根据上文对静态成员变量的讲解,可以知道必须要在类外对这两个静态成员变量进行定义初始化,外部类A中的情况比较简单,只需要指定类域直接初始化就可以了,即int A::_a2 = 0;但是内部类B的情况比较复杂,首先内部类B是处于外部类A的类域中的,然后内部类B中的静态成员变量又处于内部类B的类域中,因此需要指定两层类域,即char A::B::_b2 = 'i';最后在内部类的成员函数fun2中,可以看到直接定义了静态成员变量_a2,这是因为静态成员变量是属于类本身的,不需要具体的对象也可以使用,在后方又使用了对象aa修改了_a2,此时_a2的值就会直接变为1,因为静态成员变量只会储存一份,被所有对象共享,同理,在fun2函数的最后也可以不使用具体的对象而直接调用类A的静态成员函数apple。
更多推荐






所有评论(0)