10.1.1 程序的编译和执行
- 以#开头的代码都属于预处理器处理的步骤
- #include 将头文件的内容包含进入当前源文件中
- #define 展开宏定义
- #ifdef 处理条件编译指令(#ifdef、ifndef、#if、#else、#elif、#endif)
- #other 处理其他宏指令(#error、#warning、#line、#pragma)
预处理器 其他功能如下:
- 处理预先定义的宏:例如__DATE__ __FILE__(其后均为两条下划线)
- 处理注释:用一个空格代替连续的注释
- 处理三元符
注意
- 编译器对预先处理过的代码进行词法分析、语法分析和语义分析,将符合规则的程序转化为等价的汇编代码
- 汇编器将编译器生成的汇编代码翻译成为计算机可以识别的机器指令,并生成目标文件。之所以不将源程序之间转化成为机器指令,而分成了多级转化,是为了在不同的阶段应用不同的优化技术,并且这些优化技术已经很强。可以保证各个阶段分别优化之后可以生成更为高效的机器指令
- 链接器 将所有的目标程序链接在一起,无论是静态还是动态链接,最终都可以生成一个可以在机器上运行的可执行的程序。运行可执行程序就会得到执行的结果
10.1.2 金典面试题解析
简述#include<>和#include“ ”之间的区别
- 主要目的都是为了 将指定文件中的内容引入到当前的文件,但是搜索被引入文件的时候,二者采用了不同的搜索策略
- #include<>:直接从编译器指定的路径处开始搜索
- #include“ ”:从程序所在的目录进行搜索,如果搜索失败,再从编译器指定的路径处开始搜索
- 用户自定义的文件只能使用#include“ ”,如果系统文件使用#include“ ”,会在用户目录进行无意义的搜索尝试,如果找不到,再按照编译器指定的路径处进行搜索
简述#和##在define里面的作用
#include <iostream>
using namespace std;
#define PRINTCUBE(x) cout<<"cube("<< #x<<") ="<< (x)*(x)*(x) << endl;int main(){int y = 5;PRINTCUBE(5);PRINTCUBE(y);return 0;
}
// cube(5) =125
// cube(y) =125
- define里面使用了#x,因此#x是会被字符串化的宏参数
#include <iostream>
using namespace std;
#define LINK3(x,y,z) cout << x##y##z << endl;
int main(){
// LINK3("C","+","+");LINK3(3,5,0);return 0;
}
// 350
- #运算符会将其前后参数转化成为字符串
- ##运算符会将前后的参数进行字符串的连接
assert 断言的概念
- assert是一个带参数的宏定义,不是一个函数,头文件在assert.h文件里面
- 使用assert检测条件表达式,如果表达式为假,表示检测失败,程序会向标准错误流stderr输出一条错误的信息,再次调用函数abort函数终止程序的执行
- 频繁使用assert会降低程序的性能,增加额外的开销,一般是调试中使用宏,调试完成之后,在#include语句之前 使用#define DEBUG禁用assert宏定义
- assert虽然可以检测多个条件,但是最好不要如此使用,因为一旦出错,不知道是哪个条件导致的错误,因此没有任何的意义
- 不要在assert里面修改变量的数值,因为assert只会在debug版本中使用,一旦使用了RELEASE版本,所有的assert都会失效。
- 断言的目的是为了捕获可控但是不应该发生的情况,不适合外部接口的函数检测,因为外部输入的参数是一个不可控的事件,应该使用if进行判定
- 断言用于 内部函数,其参数检测使用断言;因为函数的使用者是程序开发者,错误的参数是可控并且不该发生的情况
- assert用于程序的DEBUG版本检测条件的表达式,如果结果为假,则输出诊断信息并且终止程序的执行
10.2.1 知识点梳理
- 变量名使用小写字母;常量、宏定义使用大写字母
C++类型的转换操作符
static_cast
- static_cast 完全可以替代C风格的类型转换实现基本的类型转换。
- 此外,进行对象指针之间类型转化的时候,可以实现父类指针和子类指针之间的转换,但是无关的两个类之间,无法实现相互转化
- 如果父类指针指向一个一个父类对象,将其转换成子类指针,虽然可以通过static_cast实现,但是这种转换可能是不安全的
- 入股分类指针一开始就指向一个子类对象,将其转换成子类指针,则不存在安全性问题
#include <iostream>class father{};
class children : public father{};int main(){father *f1 = new father;father *f2 = new children;children *c1 = static_cast<children *>(f1);//转换成功,不安全children *c2 = static_cast<children *>(f2);//转换成功,很安全return 0;
}
dynamic_cast
- dynamic_cast只可以用于对象指针之间的类型转换,可以实现父类和子类指针之间的转换,还可以转换引用,但是dynamic_cast不等同于static_cast
- dynamic_cast在将父类指针转换为子类指针的过程中,会对其背后 的对象类型进行检查,以保证类型的完全匹配,而static_cast不会这么做
- 只有当父类指针指向一个子类对象,并且父类中包含虚函数的时候,使用dynamic_cast将父类指针转换成子类指针才会成功,否则返回空的指针,如果是引用则抛出异常
#include <iostream>class father{
public:virtual void HHH(){std::cout << "father" << std::endl;};
};
class children : public father{
public:void HHH() override{std::cout << "children" << std::endl;}void Test(){};
};int main(){father *f1 = new father;father *f2 = new children;//指针children *c1 = dynamic_cast<children *>(f1);//转换不成功,Process finished with exit code 139 (interrupted by signal 11: SIGSEGV)c1->HHH();children *c2 = dynamic_cast<children *>(f2);//转换成功,很安全c2->HHH();//引用children &c3 = dynamic_cast<children &>(*f1);//转换不成功,libc++abi.dylib: terminating with uncaught exception of type std::bad_cast: std::bad_castc3.HHH();children &c4 = dynamic_cast<children &>(*f2);//转换成功c4.HHH();return 0;
}
const_cast
- const_cast 在转换的过程中可以增加或者删除const属性,一般情况下,无法将常量指针直接赋值给普通指针,使用const_cast就可以移除常量指针的const属性,实现const属性到非const属性的转换
class Test{};int main(){const Test *t1 = new Test;Test *t2 = const_cast<Test *>(t1);return 0;
}
reinterpert_cast
- reinterpert_cast 可以将一种类型的指针 直接转换成为 另外一种类型的指针,不管二者之间是否存在继承关系;
- reinterpert_cast 实现指针和整数之间的转换
- reinterpert_cast 还用在不同函数指针之间的转换
class A{};
class B{};int main(){A *a = new A;B *a2b = reinterpret_cast<B *>(a);return 0;
}
静态全局变量的概念
- 全局变量之前加上const关键字
- 定义和声明放在源文件中,并且不可以使用extern将静态全局变量导出
- 静态全部变量的作用域仅仅限定于静态全局变量所在的文件内部
- 普通的全局静态变量的作用域是整个工程,在头文件中使用extern关键字声明普通全局变量,并且在源文件中定义
- 其他文件只需要引入全局变量定义的头文件,就可以在当前文件中使用普通全局变量
- 如果在头文件中声明静态全局变量,静态全局变量在声明的同时也会被初始化,如果没有显示的初始化,也会被初始化为默认数值,相当于在头文件中完成了声明和定义;而普通全局变量不可以直接定义在头文件中
- 如果多个文件都使用#include包含了定义某个静态全局变量的头文件,那么静态全局变量在各个源文件里面都有一份单独的拷贝,而且初始数值相同,拷贝之间相互独立;如果修改了某个静态变量的数值,不会影响静态全局变量的其他拷贝
参考链接
- C++ 全局变量 静态全局变量 傻傻分不清
10.4.1 知识点梳理
- 宏函数省略了函数调用的过程,节省了函数调用的开销;但是宏函数自身的缺陷,引出了内联函数
- 编译阶段,编译器在发现inline关键字的时候,会将函数体保存在函数名字所在的符号表内,在调用内联函数的地方,编译器直接在符号表中获取函数名字和函数体,并且使用内联函数的函数体替换函数的调用,从而节省了运行的时候函数调用的开销
- inline出现在函数定义的时候,声明使用inline是没有用的;此外,函数使用者不需要知道函数是否是内联函数,因此声明不需要使用inline进行函数的修饰
class Rectangle{
public:Rectangle(uint,uint);int get_square();int get_girth(){return 2 * (length + width);}
private:uint length;uint width;
};Rectangle::Rectangle(uint length, uint width) : length(length),width(width){}
inline int Rectangle::get_square() {return length * width;
}
-
如果在函数声明的同时给出函数的定义,编译器会自动将函数识别为内联函数;但是不推荐,最好实现函数的声明和函数的分离;将函数的定义暴露给用户是不恰当的
内联函数和宏定义的区别
- 二者都将函数调用替换成完整的函数体,节省了频繁的函数调用过程中产生的时间和空间的开销,提高了程序的执行的效率
- 区别:内联函数是个函数,可以像普通函数一样进行调试;而宏定义仅仅是字符串的替换
- 宏定义展开是在预处理阶段;内联函数展开是在编译阶段;因此很多编译阶段的工作只对内联函数有效,例如类型的安全检查和自动类型的转换
- 内联函数作为类的成员函数的时,可以访问类的所有成员,公有、私有、保护;this指针也可以隐式的正确使用,宏定义无法实现此功能
- 内联函数需要注意代码膨胀问题:代码展开之后,所有使用内联函数的地方,都会内嵌内联函数的代码,造成程序代码体积的极度增长,因此使用内联函数需要注意函数的体积
- 编译器经过优化,如果程序员定义的内联函数代码体很大,编译器也会拒绝将其展开,决绝将其作为内联函数使用
宏展开错误
- 宏定义的缺陷,主要是指宏展开错误;主要原因是由于运算符号的优先级等原因导致的宏定义展开之后,语义与预设产生偏差
- 宏定义当中,最好将参数加上括号,宏定义的整体也要加上括号,从而保证逻辑的正确性
- 内联函数可以被重载
- 构造函数可以定义成内联函数:但是构造函数会隐式调用基类的构造函数,并且初始化成员变量,所以代码展开会很大
sizeof使用
- sizeof运算符号 会获取其操作数所占的内存空间的字节数,sizeof是一个单目运算符号,不是一个函数
- sizeof运算符的操作数可以是类型的名字,也可以是表达式;如果是类型的名字,则直接获得该类型的字节数,这种操作一般用于给结构体分配空间时,计算大小;
- 如果是表达式,先分析表达式的结果类型,再确定所占的字节数,而不是对表达式实际进行计算
- 使用sizeof一般都是与内存分配或者计算数组长度等需求配合使用;使用sizeof(类型的名)作为分配空间的基数;
- uint *ptr = static_cast<uint *>(malloc(sizeof (uint) * 20));
- 计算数组的元素的个数的时候,使用sizeof运算符号加上数组元素的类型作为基数,例:int count = sizeof(array) / sizeof(double);
- 其中array是数组的名字,将数组的名字作为sizeof的操作数,可以获得整个数组所占的空间;如果将array作为实参传递给子函数,子函数中形参对应的参数已经变为了指针,对指针使用sizeof只会获取指针本身所占的字节数
void sub_func(double array[]){std::cout << sizeof (array) /sizeof (double) << std::endl; //输出4/8=0 错误
}int main(){double array[20];sub_func(array);std::cout << sizeof (array) /sizeof (double) << std::endl; //输出160/8=20return 0;
}
- 使用strlen计算字符串长度,sizeof是一个运算符,用于获取类型表达式所占的内存的大小
- strlen是一个函数,用于计算字符串中字符的个数,其中字符串结束符号 \0 不会被计算在内
数据对齐
- 数据对其是指处理结构体中的成员变量的时候,成员在内存中的起始地址编码必须是成员类型所占的字节数的整倍;
- 例如int类型变量占用四个字节,因此结构体中int类型成员的起始地址必须是4的倍数,同理,double类型成员的起始地址必须是8的整数倍;由于结构体数据成员在内存存放的时候需要数据对齐,就会造成结构体中逻辑上相邻的内存地址在物理上并不相邻,两个成员之间会出现多余的空间,书上称之为补齐;
- 结构体成员使用数据对其的主要目的是为了加快数据的读取速度,减少指令的周期,使得程序运行速度更快,这部分参考计算机组成原理
struct s1{char a;short b;int c;double d;
};
- 结构体的第一个成员a是char类型,起始地址为0,占用一个字节;笑一个地址为1,第二个成员的类型是short,占用两个字节,起始地址必须是2的倍数,因此地址1 需要空出来一个位置,b的起始地址是2,下一个地址是4,第三个数据c是int类型,占用4个字节,当前地址正好为4,下一个地址为8;同理第四个数据成员是double,起始地址是8的倍数,占用8个地址,因此笑一个地址是16
- 因此结构体s1需要16个字节。由于s1中最大的double,因此计算结果必须是8的整数倍;因此结构体s1的sizeof的运算结果是16
struct s2{int c;double d;short b;char a;
};
- 第一个数据成员是int,起始地址是0,占据4个字节,下一个地址为4,第二个数据成员类型是double,占用了8个字节,起始地址必须是8的整数倍,因此空出4个字节,double的起始地址是8,占据8字节,下一个地址是16;c为short类型,占用两个字节,当前地址是16,是2的倍数,short占据2个字节,下一个地址是18;第四个数据成员是char类型,起始地址是18,占据一个字节,下一个字节是19
- * 结构体的计算结果必须是结构体内部占用内存空间最大成员类型的整数倍
- 因此,s2结构体的类型必须是s2的整数倍,因此,需要数据对齐,补充5个字节,因此s2结构体的sizeof运算结果是24
- 结构中成员成员包含 数组、其他结构体,在数据对齐的时候,需要以结构体最深层的基本数据类型为准
- 所谓的结构体中最深层的基本数据类型是指:如果结构体中的成员为复杂的数据类型,不应该以复杂的数据类型所占的空间作为数据对齐的标准,而是要继续深入复杂的数据类型的内部,查看其所包含的基本数据类型所占的空间。如果结构体成员是一个数组,应该将数组的类型作为作为数据对齐的标准。如果结构体成员 是 其他结构体,应该将内层结构体中的基本数据类型成员作为外层结构体数据对齐的标准
内存分配
- C语言中,使用malloc函数动态申请内存空间,使用free函数将空间进行释放
int *p = nullptr;p = reinterpret_cast<int *>(sizeof (int) * 10);free(p);p = nullptr;
- 使用malloc函数申请空间的时候,首先会扫描可用空间的链表,知道发现第一个大于申请空间的可用空间,返回可用空间的首地址,并且利用剩余空间的首地址和大小更新可用空间的链表
- 动态分配的空间来自堆空间,指针指向一个堆空间的地址,指针本身作为局部变量存储在栈空间里面
- 使用malloc申请的空间必须使用free函数进行释放
- 使用free函数释放空间之后,最好将指针立即置空,这样可以防止后面的程序对指针的错误操作
- 反复使用malloc和free函数申请和释放空间之后,可用的空间链表会不断更新,导致内存空间碎片化;必要的时候,调用malloc之后会进行空间的整理,将相邻的较小的空间合并成为一个较大的可用的空间,从而满足申请大的空间的需要
- 为了支持面向对象的技术,满足操作类对象的需要;C++引入了new和delete两个操作符,用于申请和释放空间;
- new和delete不仅支持基本类型还支持自定义的类型,其申请和释放的空间同样在堆上
- 对于基本数据类型,new操作符号先分配空间,然后使用括内的数值初始化堆上的数据,并且返回空间的首个地址,delete操作符,直接释放堆上的内存空间
- 对于自定义的数据类型,new首先分配空间,再根据括号内的参数调用类的构造函数初始化对象,delete操作符号首先调用类的析构函数再释放对象在堆上的空间
- new完成了申请空间 和 初始化的工作
- delete完成了 释放空间 和 调用对象析构函数的工作
简述mallo/free 和 new/delete之间的区别
- malloc和free是C语言自带的库函数,通过函数调用访问,需要传递参数并且接收返回数值;new和delete是C++提供的运算符号,有自己的规则和运算方式
- malloc和free只可以用于基本的数据类型;new和delete不仅可以应用于基本数据类型,还可以应用于自定义的数据类型;
- malloc和free返回的是void * 类型,程序需要显示的转换成所需要的指针类型;new直接指明了数据的类型,不需要类型的转换
- malloc只负责申请内存空间,并且返回首地址;free只负责释放空间,标识这段内存空间已经被占用;new完成了申请空间 和 初始化的工作;delete完成了 释放空间 和 调用对象析构函数的工作
- new和delete已经涵盖了malloc和free的功能,之所以保留malloc和free是为了解决兼容性问题,防止调用中出现C函数时候导致错误
delete和delete[]的区别
int *i = new int[5];delete i;int *j = new int[5];delete[] j;
- 数组元素是基本数据类型的时候,使用delete和使用delete[]释放整个数组空间是等价的
- 对于基本数据空间,系统会根据数组长度和数据的类型计算出数组所占的空间,然后一次性释放整个空间,因此不需要区分delete和delete[]
class Test{
private:char *text;
public:Test(int length = 100){text = new char[length];}~Test(){delete text;std::cout << "destructor!" << std::endl;}
};
int main(){Test *a = new Test[5];delete a;return 0;
}
- 使用new[]为数组分配空间的时候,如果数组的类型是自定义的类型,必须通过delete[]释放整个数组空间,因为delete[]会逐个调用数组中每个对象的析构函数,只有这样才可以将数组元素申请的资源释放,从而将整个数组对象的空间完全释放,不会造成数据的泄露。
- 使用delete释放用户自定义的数据类型的时候,只会调用数组中首个元素的析构函数,而delete[]在释放空间的时候,会调用数组中所有元素的析构函数
- 申请和释放采用配对的形式;new和delete、new[]和delete[]配对使用
位运算
计算二进制数中1的个数
- 求二进制中1的个数,只需要逐个判断,具体办法就是指定位置上的元素和1进行&操作,其余位置上的元素和0进行&操作;这样只会判断指定位置上元素是0还是1,其余位置上的元素和0&后是0,排出干扰
- 也可以将数字每次右移一位,再和1进行按位与计算;也可以将1不断左移,再与原先位置上的元素进行按位与操作,判断是0还是1
二进制表示的整数,其中1的个数
int cnt = 0;while (num) {cnt += num&1;num >>= 1;}
- 参考链接
将二进制数倒数第M位的前N位取反
- 按位取反,一般是异或的问题。而且是和1进行异或操作。0和1异或的结果是1,1与1异或的结果是0,相当于取反。
步骤
- 将1左移N位(假设M=2,N=4:00000001 -> 00010000)
- 将上述结果-1 (00010000 - 1 = 00001111)
- 将上述结果左移N位 (N=2,00111100)
- 将上述结果和原数字进行异或
int getNum(uint num,uint m,uint n){int tmp = 1 << n;tmp -= 1;tmp = tmp << m;std::cout << tmp << std::endl;return tmp ^ num;
}int main(){int num = 255;std::cout << getNum(num,2,4) << std::endl;return 0;
}
验证工具
-
在线进制转换
一个数组中有两种数出现了奇数次,其他数都出现了偶数次,怎么找到这两个数 例子
- 相同的数字进行异或操作,其结果是0,异或操作非常适合去重
//C++
int get_single_dog(std::vector<int>num){int result = 0;for (auto i = num.cbegin();i != num.cend();i++) {result ^= *i;}return result;
}int main(){std::vector<int> num(5,4);int &m = num.at(1);m = 18;int &n = num.at(2);n = 18;int &a = num.at(3);a = 19;int &b = num.at(4);b = 19;for (auto i = num.cbegin();i != num.cend();i++) {std::cout << *i << std::endl;}
// std::cout << num.size() << std::endl;std::cout << get_single_dog(num) << std::endl;return 0;
}
参考链接
- 算法入门篇 一 时间复杂度
- vector容器用法详解
找出人群中 三个单身狗中的一个
- 将三个元素 分成两组,第一组里面有两个元素,第二组里面有一个元素;分组之后,剩余元素都要出现偶数次;对第二组元素进行异或操作得到三个元素中的一个元素
- 将元素转换为二进制,从元素的最末尾开始,先按照最末尾的1元素进行分组,如果不能实现“ 第一组里面有两个元素,第二组里面有一个元素 ”,那就按照倒数第二个1元素进行切分,如果实现不了,继续此过程
- 反证法:如果找不到 第一组里面有两个元素,第二组里面有一个元素,三个元素都会在一组里面,则表明这三个元素都相等,这个假设错误
例子
- 数组元素[2,5,7,6,4,5,7,2,8]
- 二进制转换:2=0010;5=0101;7=0111;6=0100;4=0100;8=1000
- 第一步:按照二进制末尾最后一位进行分组:
- 组1:2=0010;6=0100;4=0100;2=0010;8=1000;
- 组2:5=0101;7=0111;5=0101;7=0111;
- 第二组5和7成对出现,没有实现 三个元素 分成两组,第一组里面有两个元素,第二组里面有一个元素
- 第二步:按照二进制末尾倒数第二位进行分组:
- 组1:5=0101;5=0101;4=0100;8=1000;
- 组2:7=0111;6=0100;7=0111;2=0010;2=0010;
- 第一组异或不为0,意味着三个元素其中两个分到第一组,另外一个元素分到了第二组
- 第二组只有这个元素出现了一次,其余都出现了偶数次
- 二进制的位数是有限的,32位操作系统最多进行32次分组检测就可以得到最终的结果,因此时间复杂度是O(n)级
代码
补充
main函数
- main函数之前会进行一些初始化的操作,main函数之后也会进行一部分 扫尾工作
- main函数分成无参和有参两种类型
- int main()
- int main(int argc,char * argv[])
- 返回值都是int类型
- argc 是argument count的缩写,整数类型,表示通过命令行输入参数的个数
- argv 是argument value的缩写,其中,argv[0]是程序的名字,其余元素为通过命令行输入的参数
- mian函数之前会进行全局对象和静态对象的初始化(构造),也会初始化基本数据类型的全局变量和静态变量
class out_test{
public:out_test(){std::cout << "out_test begin!" << std::endl;}
};
class in_test{
private:static out_test inner_obj;
};out_test out_obj;
out_test in_test::inner_obj;int main(int argc,char * argv[]){std::cout << "main begin!" << std::endl;return 0;
}
- atexit函数是一个指向函数的指针,参数是函数的名字,可以使函数在atexit内部完成函数的注册,经过注册的函数会在main函数最后一条语句执行之前进行调用
- 函数的调用顺序和注册顺序相反,函数注册使用栈,注册的时候,将函数指针入栈,调用的时候函数出栈
- 输出:fun_3! fun_2! fun_1!
int main(int argc,char * argv[]){atexit(fun_1);atexit(fun_2);atexit(fun_3);std::cout << "main function!" << std::endl;return 0;
}