运行期内存分布
运行期的内存分布,大概分为4块内容,其中栈、堆、代码区、全局区,那么熟悉每一块内存在哪个位置,是非常重要。
例如:
const char* p = “abc”;
char q[10] = “abc”;
其中p是一个指针,它指向的地址是在哪儿呢?
q是一个数组指针,指向的地址在哪儿呢?
首先字符串常量,是存储在代码区,因此上诉“abc”是存储在代码区,代码区是不可改(非常规手段不可改)。
而p指向的就是代码区,常量区一个地址,而p本身这个指针需要4个字节,这4个字节存储在栈区。
因此第一行代码,是在栈区声明了4个字节,用于存储在代码区常量字符串的首地址。
C++中的字符串常量,如果内容一样,则地址一样。不管你在哪儿声明一个”abc”, 指向它的地址都一样(不能跨模块)。
而第二行代码,q指向地址在栈区,但”abc”仍然是代码区,
第二行代码,是在栈区声明了10个字节地址,同时将代码区的“abc”拷贝到栈区,同时在默认拷贝了一个0。
而q本身没有东西存储,如果存储了,一定是代码区。
因此第一行和第二行,有以下几点区别。
- p和q指向的内容分别在代码区和栈区,p(存储在常量区的字符串
"abc")
一个指向的内容不可改,q(是常量区字符串的拷贝)一个指向的内容可改。 - p和q本身,一个存储在栈区,一
个为常量(可理解存在代码区) “退化为” 指向数组首元素的指针,同样一个可改,一个不可改。
修正 :
1、字符串常量的地址唯一性:在 C++ 中,相同内容的字符串常量,在同一个编译单元(一般理解为同一个 .cpp
文件及其包含的头文件构成的编译范围,不跨模块情况下)内,只会存储一份,所有指向它的指针都会指向同一个地址。这是编译器为了节省内存空间,进行的一种优化策略。
2、关于 q
本身:q
是数组名,在大多数情况下,它会 “退化为” 指向数组首元素的指针,但它并不是指针变量。说 “q
本身没有东西存储,如果存储了,一定是代码区” 这种表述不太准确。q
代表的是在栈区分配的 10 个字节数组空间的首地址,它本身不是常量,也不存储在代码区 ,只是它的值(即指向的地址)是固定的,指向数组在栈区的起始位置。
栈区的内容,由C++本身自己维护,自己申请、自己释放,符合栈的先进后出、后进先出原则,一般写一个
大括号都会压一个栈,调用一个函数也会压栈。而堆区的东西,由开发者自己申请,自己释放,堆和栈,一个地址
从低到高,一个从高到低,因为它两,用的内存,是在运行期需要大量用到的内存,它们两之间没有明确的界限,
就看谁用的多一点,理论上这两是不会交叉的,这两交叉,内存早就爆了。
全局区,用以存储全局变量和静态变量。
这里我们着重讲解一些代码区,到底存了些啥,顾名思义,肯定是存了编译后的代码,但具体存了哪些东西了?
- 函数区
包括普通函数、静态函数、类成员函数,所有的函数都存储一块,尤其值得值得注意的是,类的成员函数
它也是存储在函数区的,也就说类的代码并不是单独一个模块存储的。
2. 常量区
例如: 字符串常量,以及其它一些被优化之后的常量,可能这个常量区,就是上面的全局区哈,这玩意也不需要弄明白
不过全局区的内容是可改的,常量区的东西,是不可改,更符合代码区的特征。
3. 类信息区
类的一些描述信息和继承信息,dynamic_cast这个关键字就需要使用类信息中的一些继承关系,进行索引判断,然后再进行转换的。
4. 虚函数表区
虚函数表,单独存储在一个模块,虚函数表中就存储了一系列函数地址,这些地址指向的内容在函数区,虚函数的调用
是严格的依赖虚函数表的。
其它的,我也不知道,还存了一些啥。
编译期和运行期
这里说的编译期和运行期,并不是想说清楚,这两个时期分别干了些啥,而是想说明清楚哪些东西是在编译期已经处理好的,
那些东西是在编译期不能处理或者处理不了的,而放在运行期的。其实也就是静态和动态的区别。 在其它的文章也讲过这些,
首先一点,我们需要弄明白,编译期能处理好的,运行期肯定效率更快,之所以放在运行期,肯定是编译期它处理不了,因此静态与
动态的核心区别,就是在编译期处理不了。
- 虚函数重载
之前在讲多态时,分为静态多态和动态多态,其中的动态多态,就是指编译期处理不了,需要在运行期去抉择。例如:
class A{
public:
void normalFunc() {}
virtual void func(){}
};
class B{
public:
virtual void func(){}
};
A* a = new B;
a→normalFunc(); // 代码执行到此处时,已经是jmp + 函数地址的一个命令,编译期已经决定好,运行到此处,跳转到哪儿。
a→func(); // 代码执行到此处时,需要动态的索引虚函数表,才知道具体跳转到哪儿。
当然有人可能有疑问,这里的a就是一个B的指针,编译期想想办法,还是可以编程jmp的嘛。
如果写成这样呢?
QList<A*> _list;
_list.append(new A);
_list.append(new B);
for(A* a : _list){
a→func(); //运行到这的时候,咋办?运行期既执行了A::func,也执行了B::func。
}
因此虚函数的调用,需要依赖虚函数表,而一个指针变量,具体指向的是什么对象,运行期才能确认,因此编译期是无法处理的。
除了虚函数重载以外,其它的普通函数重载、类成员函数重载,都属于静态多态范畴,静态多态就是该函数在编译期就已经翻译好,
运行期,具体跳转到哪个函数。
2. dynamic_cast与static_cast
同虚函数的原理一样,static_cast在和C++的强转是一个效果,同样它是编译期能决定的,如果地址转换需要发生偏移,那么也是在
编译期,就知道具体需要偏移多少,编译的源码就是改成对应的指针加上的偏移量。而dynamic_cast就不一样,需要在运行期索引虚函数表,
然后找到对应的类信息表,才能界定是否可以转换,这里可以知道static_cast与dynamic_cast之前的效率,是完全两个级别的,可以理解
static_cast只有一个指令,而dynamic_cast可能有成千上万个指令,他们之前效率差别是成千上万倍,因此写代码的时候,非必要调用
dynamic_cast就不要调用调用dynamic_cast。
另外,模板的实例化是发生在编译期的,写模板的时候,一定需要理解哪些方法是在编译期能够使用的,不要将运行期的东西写在模板中,
否则编译会报错,这里不细说。
头文件包含和编译过程
头文件包含,是一个简单的概念,但我估摸着大部分,都不知道头文件包含,本质是什么。
例如:
// aaa.h
return 0;
//main.cpp
int main(int argc, const char* argv[]){
#include “aaa.h”
}
上诉表示, 定义了一个头文件,头文件只写了一个return 0;
而main中却在函数体内包含了该头文件,很奇葩,但这是可以正常运行的代码。
所以头文件包含,实质上就是将对应的头文件拷贝了到对应的包含的位置。
因此一个源文件,到底内容有多大,我们需要把所有的头文件拷贝进行,一层一层的包含进来才知道。
我们可以单独打开一个源文件的属性面板。
在后面的命令行中加上一个指令 : /P
然后ctrl+F7单独编译这个源文件,则会在本地生成一个对应的.i文件。例如:
这里可以看出这个文件有多大,打开这个文件的内容,忽略掉那些头文件包含,就是当前的这个源文件的所有包含。
注意,最后要去掉这个 /P 命令,否则程序运行不来的哈。
总而言之,我们已经知道包含的本质,我们再来理解,以前的头文件包含的方法。
#ifnodef AAAA_H
#define AAAA_H
#endif
这种为什么能够防止重复包含,你试着拷贝过去,就知道为什么了。
最新的指令是:#pragma once
这个命令,应该是只对单个文件有效。
因此,我们包含的文件,未必需要是.h结尾,常见的还有 .hpp, .inl , 甚至没有后缀,因为头文件的后缀是个啥,
不重要,只有它不是.cpp, .c , .cc等结尾就好(因为这些后缀的文件会参与编译,头文件是不参与编译的,后面会讲到)。
了解头文件包含的本质之后,我们在来了解编译过程, 如果我们编译(非Qt工程)普通的工程时,你会发现编译的输出
只编译源文件,并且最后在做链接,也就说压根就没有单独的去编译头文件,由于头文件都是被源文件包含的,因此
头文件才会参与编译。
如果一个头文件被N个源文件(直接或者间接)包含, 那么这个头文件,则会参与多次编译,当然编译期,可能会尝试
去优化这个编译过程,否则你看到上面那个main.i文件,你就知道,main.cpp将会编译很久很久,具体咋优化的,咱
也不需要弄明白。
因此,非Qt工程中,我们可以将所有头文件从IDE中删掉,然后再编译,也是能够通过的。
所以,我们反过来在去看一个头文件。例如:
那这个头文件的第一行是 “HSM.h”吗? (请忽略#pragma once), 显然是的,但对于编译期来说,不是的。
因为编译器,只编译源文件,它在编译一个源文件时,如果这个源文件包含了此头文件,且包含了这个头文件,前面
还包含了其它的头文件,你就可以理解,这个头文件,包含其他的头文件。
但是问题来了,例如上诉的截图中,并没有包含,QObject,如果一个源文件在包含此头文件时,并没有包含QObject,
会不会报错了?
当然,会报错,可是如果有的源文件在包含此头文件时,包含了QObject, 有的却没有包含,是不是包含了不会报错,
没有包含的会报错了??
对的,当然表现该如此,所以通常情况下,一个头文件用的什么,我们需要在其头部,包含什么。
但是VS提供了一个特殊的东西,叫做预编译头,它是强制我们所有的源文件,都需要在第一行包含“stdafx.h”(可以是其它名字)。
那么是不是可以理解,其实每一个头文件,已经默认包含了预编译头文件??
因为,任何一个源文件在包含其他头文件之前,已经包含了“stdafx.h”。
对头,确实是如此,所以,一定不要在头文件主动再去包含“stdafx.h”, 因为没有必要了。
我们再来整理头文件包含:
- 头文件的包含,本质上就是一个拷贝。
- 编译期只编译源文件,头文件是被间接编译的。
- 头文件的第一行,对于编译期来说,并非第一行。
- 头文件被直接或者间接被多个源文件包含,表示它会参与多少次编译。
- 预编译头会参与所有源文件的编译,也会被所有头文件间接包含,因此不需要在头文件主动包含。
所以,为什么我们已经弄明白了,为啥我们需要简化头文件。比如:
- 头文件包含,尽量在源文件,而不是在头文件,降低一个头文件被引用的范围,防止一些不必要的包含。
因为头文件包含属于一个属性结构,因此,包含关系呈幂级数关系,减少了一些不必要的头文件包含,
会极大减少头文件影响的编译范围。从而提高团队的开发效率。
2. 简化头文件,极限方式,就是接口化编程,只对外暴露接口,其它的私有函数和实现过程全部内置化。
一些关键字的作用
public、protected、private
对于这些关键字,大家都知道它的具体作用,这里提到它们,是为了挖掘它更深层次的意义,甚至在设计思想上的一些作用,
因为发现大多数人在使用这些关键字的时候,仅仅只是,把成员变量私有化,把所有的函数全部公有化。
就好像觉得这就是一个模板,别人这么用,我也这么用,类就是这个鸟样。
C++设计这些关键字,到底出于什么目的呢?这些关键字的直接作用,就是限制访问权限,好比生活中的红绿灯一样,它就是
一个枷锁,一些规则,它的存在,会带来一些效率的降低,但这是它作用的下限,它的上限,则是交通的安全性,同样这些关键字
的目的,也是会增加安全性,这也是C为什么不能开发大型程序的最大问题之一,语言本身不够安全。
我们举一个简单的例子:
class A{
public:
void func(){
// 做了很多复杂事情,下面需要统计该函数被调用的次数
++m_count;
}
int m_count = 0;
}
//上诉的例子非常简单,就是调用函数func,后续会统计调用次数,
但是由于m_count是共有的,因此,外部是有权限直接对m_count进行修改的。
那么,任何其它人使用这个头文件的类,都可能有权限修改,一旦出了问题,范围扩散就非常宽。
甚至无从排查,根本原因,是因为当前类,设计的不安全。
因此上诉关键字的作用,是为了限制对外的权限,从而增加自身的安全性,如果本身除了问题,基本可以锁定在类的内部实现中,
而没有扩散到类的外部。
我见过有人实现的类,类的内容用到QMutex, 结果还提供一个方法,让外部获取此对象,同样的问题,显然QMutex是为了保证
类内部的数据的安全性,这个类提供了一个方法,让外部能够获取这个类的数据,但是类的内部,在访问这些数据,使用QMutex,
现在数据暴露出去了,锁不暴露,那别使用这些数据,可能就会崩溃,于是就锁也给暴露了。但是这样合理吗?
我们再来总结一下,上诉的关键字,是为了限制外面的访问权限,不让外面的手伸的那么伸,从而保证内部的安全, 这就是典型的门面
与内聚的概念。
就好比开一个超市,只需要一个门面或者两个门面,不管里面有多少商品,只要一个保安守住门口,就能够一定程度上避免有人偷东西。
如果你为了方便,多开几道门,就需要多请几个保安,如果你把所有墙咋了,你得安排一个保安墙,成本太高了。
我们在来回顾上诉说的那个问题,把Mutex外抛,会带来什么问题呢?
- 如果有人在没有细看接口的情况下,很容易会忽略还有一个外置Mutex接口,字节获取数据遍历,看似正常,实则容易崩溃。
- 即便是正常的使用,可能会导致与外部锁的顺序问题,而导致嵌套死锁。
而上诉,没有限制情况下,需要一遍一遍的进行修复。
另外我们反过来思考一个问题,当我们在源文件写一个私有类(结构体时), 它还需要严格遵守类的一般写法吗?
该暴露的暴露,不该暴露的,私有化。
这其实已经没有太大的必要性了,因为源文件的类已经具备高度的保密性,不会对外公开,这个时候,怎么方便怎么来,如果还设置一些
条条框框,不仅增加代码量,使代码变得可读性差,还增加的代码的调用层级。
之前在Q_Q和Q_D一篇有讲到,它的目的就是为了简化头文件,将私有的成员与函数,写在源文件,那么这个私有化的类,没必要再遵循
类的常规写法,这个时候,更多的是需要保证我们代码看起来更简单和流畅。
总结一下:
- 一个良好的类包装,需要做到仅需要暴露必须暴露的接口。
- 而暴露的接口,需要保证其安全性和使用简单性,接口之间,尽量不要有依赖关系。
- 那些本身是辅助性的类,尽量实现在源文件,且不需要遵循那些接口暴露原则,怎么简单怎么来。
- 内聚性封装,能够极大的程度保证问题闭合性,即便内部是是一团糟,至少好的对外输出,不会影响到外部。
组合与继承
组合优于继承,这是设计原则中一句话,我之前对这句话有点嗤之以鼻,显然这句话太绝对了,C++设计了类,类的最核心的功能就是
继承,那显然我不能无脑的屏蔽的类继承的使用,改为用组合。
组合与继承的区别是非常明显的,我们更多的需要弄清楚,他们各自的劣势是什么?
我们在学习继承时,所学习的第一个例子就是,Dog继承自Animation, 显然狗作为动物的一种,他具备动物的所有特征,通过继承
可以一次性继承动物类所有的方法,这非常的方便,不可厚非。
但如果当我们真正意义上去实现整个动物系统时,就单狗的品种,就有成千上万个品种,如果我还需要描述鸟类,鸟的种类更是品类繁多。
这样整个派生下来,你发现你的类的数量变得非常非常的多,当我们去看这到这些类名时,将会变得眼花缭乱。
那我们改为组合的方式?怎么组合呢?
其实继承的唯一好处就是让一个新的类型能够完整的继承自原有的类,但这也是其最大的缺陷,极大的降低其灵活性。
我们在用生活的常规思路,来区分一下,哈士奇与泰迪的区别。
- 体型
- 皮毛的颜色和长度
- 活泼性
- 聪明度等等
当我们从不同品类的狗中提取10-20个特征时,我们假定特征都是2态,以20个特征为列,那么总共能够组合的数量是2^20次方,这有100万+
的数量,这很夸张,当然并非所有特征组合都是有效的,如果你随意组合,你也不知道会组合出一个什么怪物品种出来。
但是通过这种特征组合法,已经从很大程度上简化了类的裂变数量。
如果某一个特征本身过于复杂,还可以再次对齐进行子特征分解,当然,这个时候的如果裂变数量并不多的时候,也可以考虑使用继承。
上诉这个例子已经很好的描述组合和继承的区别,同时也表述这两者之间的转换。
Qt中就有很好例子,比如:model-view-delegate,虽然它是一个设计模式的变种,但我们仔细一下,它是不是一种横向的组合拆分,而
这种拆分,不仅仅增强了其复用性,其实从一定程度降低了纵向的裂变。
评论(0)