C++读书笔记1:C语言基础知识

来源:互联网 发布:软件开发 需求文档 编辑:程序博客网 时间:2024/06/05 20:39

一、C/C++基本知识

1、C++通过const关键字定义常量,常量可以实现了和#define相同的目的,但是和#define相比,常量具备了类型属性,更利于编译器检查代码中可能存在的数据类型不匹配的错误。const常量在定义的时候必须初始化,且在编译器编译的时候需要能确定常量的数值。【《Effective C++ 第二版》条款1:宏定义被预处理器处理,编译器看不到宏名,所以宏名不会像变量名那样被放在符号表中,不利于问题定位】。

全局const常量放在只读数据段(.rodata,其中除了全局const常量外,还包括字符串常量,例如,函数printf中的字符串),局部const常量就放在函数堆栈上(这一点和局部静态变量是不一样的),所以局部const常量通过取地址并强制转换的方式可以被修改,而全局const数据如果这样操作的话,代码在运行过程中会出现段访问异常。

宏定义一般定义在头文件中,C++为了让const能够替代普通的宏定义,也允许在头文件中定义const常量。我们知道,如果是普通变量定义在头文件中,编译的时候一定会出现“变量重复定义”这样的错误信息。所以,C++在语法上对全局const常量做了一些强化处理:凡是const修饰的全局常量,等价于前面再加一个static修饰,所以全局const常量的有效范围就是在一个文件内部。(如果其它文件要访问这个const常量,必须自己使用extern对这个常量再声明一次,见《C++ Prime 中文版第四版 P50》)。

另外,有必要把全局const和全局静态变量对比一下,正常情况下全局const的作用域只是单个文件,通过extern,可以将全局const共享给多个文件,但是在内存里只有一份const对象。全局静态变量的作用域也是单个文件,且无法通过extern共享给其它文件,如果全局静态变量定义在头文件中,则每个包含此头文件的源文件都会产生一个作用域在本文件范围内的同名静态全局变量,即编译器将这些同名的静态变量放在不同的内存地址上。

2、带参数的宏定义与宏展开:宏,应该是宏大的意思。所以,宏定义,正确的理解应该是一个简单的定义能展开成一大坨代码。这就和带参数的宏定义有关,带参数的宏定义要求宏的名字括号之间必须没有空格,形式如下:

#define <宏名>(<参数表>)  <宏体>

#define MAX(x,y)  ((x)*(y)) // 自己的宏定义就必须这样层层加括号

带参数的宏定义其实和函数非常像,只是缺少函数的返回类型和参数类型。在宏展开时,就是用代码中的实参来替换<宏体>中的形参,再用展开后的<宏体>替换代码中的宏。

// 得到一个结构体类型中field成员的偏移量#define OFFSETOF(type, field) ((size_t) &((type *)0)->field)// 得到一个结构体类型中field成员所占用的字节数#define FSIZ(type, field) sizeof(((type *)0)->field)

为了更灵活(其实就是让更多的人看不懂),宏定义的语法还规定了三个特殊符号用于宏体:#,##,#@

#define Conn(x,y)   x##y   // “##”表示将参数x和y连接起来#define ToChar(x)   #@x    // “#@” 表示给参数x加上单引号#define ToString(x)  #x    // “#” 表示给参数x加上双引号int n = Conn(123,456);           // 结果就是n=123456char* str = Conn("asdf", "adf"); // 结果就是 str = "asdfadf"char a = ToChar(1);              // 结果就是a='1';char* str = ToString(123132);    // 结果就是str="123132";

另外,需要注意,“##”应该被当做一种分隔连接符,即先分隔,再连接,例如:

#define A1(name, type)  name_##type##_type#define A2(name, type)  name##_##type##_type

在第一个宏定义中,预处理器把name_##type##_type解释成3段, “name_”、“type”、以及“_type”,这时只有“type”被当作宏定义的参数。

在第二个宏定义中,预处理器把name##_##type##_type解释成4段, ‘name’、’_’、’type’、以及’_type’,其中, “name”、“type”被当作宏定义的参数。

《Effective C++ 第二版 P16》条款1,举例使用内联的模板函数来替换带参数的宏定义(感觉真是不作不死啊,有兴趣的时候再研究吧)。

3、位运算符和逻辑运算符

>>:右移运算符,如果是无符号类型数据,左边空出的位置用0来填补;如果是有符号类型数据,则根据操作系统的规定来补全。有些操作系统是用符号位来填补,有些操作系统默认是按照0来填补。(在Win7 VC2010测试,右移用符号位填补)

<<:左移运算符,右边空出的位置用0来填补,高位左移溢出应该舍弃该高位。而且是全部位参与左移,包括符号位。

~:按位取反

|:或运算符

&:与运算符

^:按位异或运算符

!:逻辑非运算符

||:逻辑或运算符

&&:逻辑与运算符

4、不允许对浮点数做取模操作(%),否则会引起编译错误。

5、负数表示法与数值溢出:计算机中正数采用二进制原码表示,负数采用二进制补码表示。这样做的好处是为了简化硬件设计。即计算机用一套加法电路搞定全部加减运算(正数+正数、正数+负数)。为了简化,这里我们以16位宽的short int数据类型进行分析。

例如,short int i = 8;short int j = -8;

此时,变量 i 在计算机中的二进制数据表示为:0000 0000 0000 1000。变量 j 在计算机中的表示应该是:2168。因为对于一个位宽为16的数据类型,216表示的就是零。这样,计算机在计算 88 的时候才能使用普通的加法逻辑电路来统一处理(即:8 - 8 = 8 +(-8)=8+ 2n8=2n=0)。【对于16位数据类型,n>=16】

如果要验证这一点,可以通过下面的代码:

short int i = 8;short int j = 65536-8; // j=2^16-8short int k = 65536;   // k=2^16printf("i=%d; j=%d; k=%d \n",i, j, k);

这里写图片描述

最终,正数和负数的机器码应该是

正数的机器码 = 正数的二进制原码

负数的机器码 = 2n – 负数的绝对值(负数的机器码也被称为补码;n大于等于数据类型实际的位宽)

举例:16位数据类型,数值(-8)的机器码 = 216-8=65536-8=65528 。

所以,假如C代码中定义int变量如下:

short int i = 65528;  // 65528  = 2^16 -8short int j = 131064; // 131064 = 2^17 -8

此时,在计算机看来,变量i和j的实际数值都是-8,这就是我们接下来要讲的 已知负数的机器码,求对应的负数

负数的绝对值 = 2n –负数的机器码(n大于等于数据类型实际的位宽)

接下来分析什么是溢出,看一个例子:

short int i = 32767;short int j = 2;short int k = i+j;printf("i+j=%d \n",k); // 输出的结果是“i+j=-32767”

因为short int表示有符号的整数,它的范围是:32767~-32768,而32767+2=32769,这个结果超出了short int数据类型的表示范围,这种现象称为溢出。这种情况下,计算机是如何理解这个数呢?

首先,计算机将32769转化为二进制数据:1000 0000 0000 0001,因为变量的定义是short int(有符号的整数)类型,而二进制数据的最高位是1,所以,计算机认为这是一个负数。那么最终打印(计算)出来的负数是:

-(216 - 32769)=-32767

最后的一个例子:

short int k = -32769;printf("k=%d \n",k);

计算机是如何理解这里的变量k呢?首先,-32769对应的二进制补码是:216 - 32769=32767,所以变量k在内存中存放的数据就是32767的二进制数据0111,1111,1111,111。对于这个数值,函数printf按照格式化字符串中指定的类型进行解析,打印的结果是:k=32967。

6、算术类型转换:两个数据运算,转换总是朝着表达数据能力更强的方向转换。例如:

short int i = 234;int j = 567;printf("i*j=%d \n", i*j); // 机器会现将i和j转换为int类型数据后再运算,最后的结果是“i*j=132678

7、 数据类型的长度计算、自然对齐、与强制对齐

使用sizeof运算符计算指针变量的长度,在32位系统上,得到的结果是4,在64位系统上,得到的结果是8。所以,通过计算指针的长度可以判断当前是32位系统还是64位系统。其它数据类型的长度如下:

sizeof(char)的长度为:1
sizeof(short)的长度为:2
sizeof(int)的长度为:4
sizeof(long)的长度为:4(Win X86和X64都为4,Linux X86为4,X64为8)
sizeof(float)的长度为:4
sizeof(double)的长度为:8
sizeof(bool)的长度为:1(C++里)
sizeof(BOOL)的长度为:4(windows平台)

什么是自然对齐,数据为什么需要对齐

数据对齐指的是数据在内存中存放的位置。

自然对齐指的是变量在内存中的起始地址正好是变量长度的整数倍。

为了让CPU读取数据的效率最高,编译器在默认情况下为每个变量都按照自然对齐的方式分配内存地址。

对于标准数据类型:char、short、int、long它们的长度都是确定的,所以对齐的结果也比较明确。

对于非标准数据类型的对齐结果,对齐规则如下:

1)数组 :按照单个数组元素的数据类型对齐,第一个对齐了,后面的数据自然也就对齐了。(简单)

2)联合体 :按其中长度最大的数据成员的数据类型对齐。(简单)

3)结构体: 每个数据成员要分别对齐。第一、各成员变量的偏移地址是sizeof(类型)的整数倍。第二、结构的总大小是所有成员类型中最大sizeof(类型)的整数倍。

struct stMember{    char m_byte1;    // 相对偏移地址是0    double m_double; // 相对偏移地址是8(因为double数据类型的长度是8,这里满足第一条规则)    char m_byte2;    // 相对偏移地址是16};// sizeof(stMember)的结果是24。(这里满足上面的第二条规则)

强制对齐与修改对齐方式

自然对齐得到的最终结果依赖于编译器和系统平台。但是在某些特殊情况下,例如,一个用于网络通信结构体,或者是一个结构体的各个成员与硬件的寄存器对应。为了避免不同平台、不同系统和不同编译器带来的差异,此时就需要了解如何实现强制对齐

强制对齐方案1

#pragma pack(4)    // 修改当前的对齐参数为4                   // 在这里定义的结构体,编译器将参照新的对齐参数n为结构体成员分配内存位置struct stMember{    char m_byte1;    // 相对偏移地址是0    double m_double; // 相对偏移地址是4(对齐的规则是:按照成员类型的对齐参数(8)和指定对齐参数(4)中较小的一个值对齐)    char m_byte2;    // 相对偏移地址是12};// sizeof(stMember)的结果是16。#pagman pack()       // 取消指定的对齐参数n,恢复缺省对齐参数

强制对齐方案2

__attribute((aligned (n)))  // 如果修饰一个结构体,表示结构体的大小是n的整数倍。__attribute((aligned))      // aligned没有参数,表示“让编译器根据目标机制采用最大最有益的方式对齐",应该就是自然对齐。__attribute__ ((packed))    // 表示以最小方式对齐,即以一字节为单位对齐。

8、全局变量定义在函数之外,一般在源文件的最前面,让所有函数可见,如果定义在两个函数之间,则定义在全局变量之前的函数看不到这个全局变量。

9 、静态变量:使用static关键字修饰的变量被称为静态变量,静态局部变量定义在函数内部,但是编译器将它存放在全局数据区,只有第一次进入函数的时候才被初始化。静态全局变量只在定义该变量的源文件内有效,不能通过extern声明后被其它文件访问。总结:把局部变量改变为静态变量后是改变了它的存储方式,即改变了它的生存期。把全局变量改变为静态变量后是改变了它的作用域,限制了它的使用范围。

静态全局对象初始化的时间在main函数执行之前,且不同文件中的静态全局对象初始化顺序无法保证;如果ObjA和ObjB是两个分属于不同源文件的静态全局对象,且ObjB的初始化依赖于ObjA,如何能保证ObjA先完成初始化呢?答案是保证不了。可行的方案是参考设计模式中的单件(singleton)模式,将两个静态对象放入两个函数中(且函数的返回值是静态对象的地址或引用),因为函数内部的静态对象在函数第一次调用的时候完成初始化,这样通过函数调用的顺序,保证对象初始化的顺序。【参考《Effective C++第二版》第47条】

10、重载函数(overload):C++中,如果有多个函数,它们宏观的功能差不多,只是处理的参数类型或参数的数量不同,可以将这些函数定义成相同的文件名,这种情况被称为函数重载。编译器对重载函数的处理规则:

1. 如果实参类型与形参类型严格匹配,则调用

2. 参数类型通过向上类型转换(char、short、int、long int、float、double、long double的顺序)自动转化后再去匹配

3. 用户在代码中直接强制转化数据类型。

4. 如果同名函数,只是返回值类型不一样,此时编译器不会当作重载,而是上报编译错误,因为C语言支持用户调用函数的时候,不关注函数的返回值,这种情况下,编译器就无法根据返回值确定应该调用哪个函数,所以函数的返回值类型不是属于函数重载的条件。

后面在学习C++类继承关系的时候,还有一个概念叫做函数重写(override),重载和重写的区别简单讲就是:重载只要求函数名不变,参数类型可以不一样,重写要求函数名和参数列表都不变。所以说,重载是一个类中多个同名函数处理不同类型的参数,重写是子类对基类函数的重新实现(即多个同名函数处理相同类型的参数,但函数内部算法不一样)。

11、函数的默认参数:C++允许函数在声明的时候,为函数的形参设置一个初始值,即默认参数。在函数调用的时候,如果不提供实参,编译器就直接取用默认参数作为函数的输入参数。默认参数一般只能在函数声明中设置,除非对于那种只有定义没有声明的函数,才可以在函数定义中设置默认参数。(默认参数是静态绑定的,即编译时指定的,这点在后面详细描述)

为了方便编译器识别默认参数,默认参数只能从右至左依次定义。(换句话说:如果某个参数是默认参数,那么它后面的参数必须都是默认参数)

默认参数可以是常量或全局变量,要求在编译的时候是确定的。

一个函数不应该又定义重载函数,又定义默认参数,这会导致编译器无法判断。【《Effective C++ 第二版》第24条 建议:如果两个处理过程需要处理的数据数量不一样,但是数据类型和算法一致,应该用默认参数的方式统一成一个函数实现。如果数据类型或算法不一致就考虑通过函数重载分成多个函数定义。】

12、静态全局函数:和静态全局变量非常相似,在函数声前加明之一个static,则函数只能被当前文件生效,其它文件无法使用该函数。

13、数组:定义数组的时候,下标不能是变量,全局变量也不行,但可以是常量。全局数组和静态数组默认初始化为零,局部数组初始值不确定。

可以使用sizeof运算符计算数组的大小。

把数组作为参数传递给函数,实际上就是把数组的地址传递给函数(即函数内部看到的就是一个地址)

14、指针:(理解了这里的指针常量,后面的引用就好理解了)

const int a = 78;      // a是常量const int* ptr1 = &a;  // ptr1是一个指向常量的指针,也叫作常量指针(即常量的指针),但ptr本身是变量。int const *ptr1 = &a;  // 常量指针的另一种写法。int b = 78;int* const ptr2 = &b;  // ptr2是一个常量,即指针常量,指向b的内存地址。const int* const ptr2 = &a; // 指向字符常量的指针常量

15、字符串常量:代码中放在“”里的字符串,但又不是给数组初始化的字符串被称为字符串常量,保存在只读数据区。例如,函数printf中的格式化字符串。

char Array[]="Hellow";  // 这里的字符串就是在初始化一个数组,所以它不属于字符串常量sizeof(Array);          // 因为系统在字符串"Hellow"的尾部自动添加结束符\0,所以字符串占用数组的实际空间是7字节strlen(Array);          // strlen函数计算的字符串长度不计算结束符null

16、引用

int a = 100;int& rNumber = a; // 引用的定义

引用就是一个变量的别名,引用与指针差别很大,指针是个变量,通过赋值,指针可以指向不同的地址。引用建立的时候就必须初始化,此后引用就不能再通过赋值关联其它的对象了(这也可以看出引用基本上就是一个不允许改变的指针常量)。

另外,不允许声明针对数组的引用,但是可以为常量字符串声明一个引用。引用是对象的别名,所以对引用取地址就可以得到对象的地址(这一点在定义=运算符重载函数时经常使用:if (this==&rRhs),用于防止自己给自己赋值)。

C++创造引用的目的主要是用于函数的参数传递,而且主要是用于结构体和类这样的数据,因为传递引用可以和传递指针一样高效(不需要在被调函数栈内创建参数副本),并且,引用一旦初始化,就不能指向其它对象,也就防止了因为指针改写而出现的内存丢失问题。另外,如果被定义为引用的参数在函数内部只是被读取,则应该将参数定义为const引用。这样做的优势有三点:

1. 函数定义const形参可以避免传入的参数被无意中修改。

2. 函数定义const形参可以使函数既能够处理const也能够处理非const类型的实参,否则只能处理非const类型的实参(是的,将const对象传给一个函数,如果函数相应的形参不是const类型,则编译失败,此乃常识。)

3. 使用const引用使函数能够正确生成并使用临时变量。

一个类的全部成员变量中如果包含引用,它的赋值运算符重载函数(oprator=)必须由程序员自己定义,编译器拒绝自动生成按位拷贝的默认函数,在用户自定义的赋值运算符函数里不能对引用赋值;同理,如果有const成员变量,也是一样的。

17、函数指针:函数指针就是一个指向函数的指针。声明一个函数指针变量其实和声明一个函数非常像,唯一的区别就是把函数名换成带有*号的指针变量名,且必须加上()。

// 计算两个整数相加,返回值是int类型int function1(int a, int b){    return a+b;}// 声明函数指针,函数的返回类型是int,*表示后面的func是一个指针变量,*和func的前后必须加上()int (*func)(int a, int b);int main(){    int c = 0;    func = function1;  // 初始化函数指针,也可以写成:func = &function1;    c = (*func)(1, 2); // 调用函数指针指向的函数,也可以写成:c = func(1,2);    return 0;}

关于上面两种函数调用方式,《C++ primer plus 第六版》243页有描述:“观点1认为,func是函数指针,*func才是函数(借鉴了数据指针和数据的概念)。观点2认为:因为func = 函数名,既然能直接用函数名调用函数,就应该直接用func调用函数。最终,C++编译器决定同时支持这两种表示法。”其实,个人认为从语法一致性的角度看,第二种观点更合理,但是从代码可阅读性的角度看,第二种观点容易让人忘记func是一个指针。所以,建议使用观点1。

附录:typedef详解

从上面的例子可以看出,因为函数带的参数比较多,所以,每次声明一个函数指针变量的时候都要写一大堆,显得很麻烦,此时可以用typedef定义一个函数指针的类型的别名,后面使用这个类型别名定义函数指针变量,可以让代码更简洁。

typedef int (* FUN)(int a, int b); // 定义一个函数指针类型的别名FUN func1; // 使用简化的类型别名定义函数指针变量

通过typedef为复杂的类型声明定义简单的别名。但是它和宏定义不同,宏定义纯粹是替换关系,如果上下文环境不合适,替换可能会有意想不到的问题。typedef是一个存储类的关键字,编译器可以完整的理解它的意思,举个例子:

char* pa, pb;  // 此时只有pa是指针类型,pb是char类型typedef char* POINTER_CHAR; // POINTER_CHAR pa, pb // 此时pa和pb都是指针类型

在编程中使用typedef目的只是简化复杂的类型定义,它并不能创造新的类型。

// 定义与平台无关的数据类型别名typedef unsigned char U8;        // 无符号8位变量类型别名typedef unsigned short int U16;  // 无符号16位变量类型别名typedef unsigned int U32;        // 无符号32位变量类型别名

typedef的陷阱:

typedef char* POINTER_CHAR; // const POINTER_CHAR相当于 const char*吗?否,它相当于char* const

0 0
原创粉丝点击