为了避免一个文件被多次include,有两种方式:
- 使用宏来防止同一个文件被多次包含
- 优点:可移植性好
- 缺点:无法防止宏名重复,难以排错
1 |
- 使用编译器来防止同一个文件被多次包含
- 优点:可以防止宏名重复,易排错
- 缺点:可移植性不好,windows支持,其他平台未必
1 |
为了避免一个文件被多次include,有两种方式:
1 | #ifndef _SOMEFILE_H_ |
1 | #pragma once |
下列程序是否正确:
1 | void GetMemory(char *p) |
错误,程序崩溃。因为GetMemory 并不能传递动态内存, Test 函数中的 str 一直都是 NULL。strcpy(str, "hello world");将使程序崩溃。
1 | char* GetMemory(void) |
可能返回乱码。因为GetMemory 返回的是指向“栈内存”的指针,该指针的地址不是 NULL,但其原现的内容已经被清除,新内容不可知。
1 | void GetMemory2(char **p, int num) |
能正常运行,但是内存泄漏。
1 | oid Test(void) |
篡改动态内存区的内容,后果难以预料,非常危险。因为 delete[ ]str;之后,str成为野指针(需要str = NULL;)if(str != NULL)语句不起作用。
1 | char *strcpy(char *strDest, const char *strSrc); |
如果说面向对象是一种通过间接层来调用函数以换取一种抽象(创建一个接口类),那么泛型编程则是更直接的抽象,它不会因为间接层而损失效率。不同于面向对象的动态期多态,泛型编程是一种静态期多态,通过编译器生成最直接的代码。泛型编程可以将算法与特定类型、结构剥离,尽可能复用代码。
1 | //通过函数的方法实现输出最大值 |
1 | //通过泛型编程的方法实现输出两数的最大值 |
1 | /* |
泛型编程是把算法和具体的数据结构分开了,我们不需要考虑类型本身是什么,直接用一套逻辑把所有的类型都涵盖了,如果需要针对某些特殊类型做处理,我们就进行单独的“特化”。这里比较复杂的操作是编译器的推理过程,程序员所做的工作无非是把该定义好的类型通知编译器,让编译器帮助我们做处理。
1 | /* |
模板编程的难点很大程度上在于对编译器的理解,我们需要直到怎么帮助编译器提供需要生成代码的信息。
一个模式描述了一个不断发生问题及这个问题的解决方案;模式是前人的设计经验上总结出来的对于一些普遍存在的问题提供的通用解决方案,比如单例模式、观察者模式等;
软件工程中有很多模式,面向对象常见的有23种设计模式,可以分为创建型(单例),结构型(适配器)和行为型(观察者)模型。设计模式不是万能的,它建立在系统变化点上,哪里有变化哪里就可以用。设计模式是为了解耦合,为了扩展,它通常是演变过来的,需要演变才能准确定位。设计模式是一种软件设计方法,不是标准,面目前大部分框架中都包含了大量的设计模式思想。
有些时候,我们需要整个程序中有且只有一个实例,比如系统日志、Windows资源管理器窗口,数据库分配主键等操作。单例的实现思路:
1 | //Singleton.h |
1 | //Singleton.cpp |
1 | int main() |
上面的单例模式实现,程序开始时全局实例Singleton* Singleton::This为空,只有我们主动调用Singleton::getInstance()->DoSomething()方法时才会产生这个对象的实例。我们也可以采用饿汉的方式,程序启动时就创建实例:
1 | //Singleton.cpp |
在观察者模式中,观察者需要直接订阅目标事件;在目标发出内容改变的事件后,直接接收事件并作出相应;对象通常是一对多关系。常见于各种MVC的框架中,Model的变化通知各种类型的View时几乎都存在这种模式。
观察者模式的实现思路:把问题的职责解耦合,将Observable和Observer抽象开,分清抽象和实体。
1 | //观察者类 |
用list列表来存储被观察清单。
1 | //被观察对象 |
1 | //main |
观察者模式帮助我们把职责关系梳理清晰了,我们如果要增加一个新的观察者,直接继承一个Observer类,实现一下消息更新的虚方法即可。
适配器模式可以理解为一个插口转换器,去不同国家旅游时可以适配不同的插座接口。适配器将类接口转换为客户端期望的另一个接口,让接口更兼容。适配器模式的动机是:如果可以更改接口,则可以重用现有的软件。
1 | //适配器模式 |
计算机程序的输入流起点和输出流的终点都可以是磁盘文件。C++把每个文件都看成是一个有序的字节序列,每个文件都以文件结束标志结束。
按照文件中数据的组织形式可以把文件分为:
文件操作步骤:
1 | //把输入的字符信息写入文件 |
文件打开方式 | 行为 |
---|---|
ios::in | ifstream的默认模式,打开文件进行读操作 |
ios::out | ofstream的默认方式,打开文件进行写操作 |
ios::ate | 打开一个已经有输入或输出文件并查找到文件尾 |
ios::app | 打开文件以便在文件的尾部添加数据 |
ios::nocreate | 如果文件不存在,则打开操作失败 |
ios::trunc | 如果文件存在,清除文件原有内容(默认) |
ios::binary | 以二进制方式打开 |
文件的默认打开方式是ASCII,如果需要以二进制方式打开,需要设置ios::binary:
1 | //二进制的方式拷贝一个文件: |
传统的C语言中I/O处理有printf,scanf,getch,gets等函数,他们的问题是不可编程,仅仅能识别内置的数据类型,无法识别自定义的数据类型;而且代码移植性差,有很多坑。
C++中有I/O流istream,ostream等处理方式,可编程,对于类库的设计者来说很有用;而且还能简化编程,使I/O的风格保持一致。
计算机发展过程中,需要实现外部的设备可以用统一的标准和计算机程序进行交互。最早的UNIX系统是以文件的方式进行交互。这个思想发展到现在就是IO缓存区。因为外部设备和内存的速度是不一样的,缓存区的存在可以让我们的信息读取更加高效。
标准的IO提供三种类型的缓存模式:
1 | int main() |
上面代码一连输入6个int,内部接收完5个数字后循环结束,但可以看到6还是读入了,并且以char的形式输出,后面的ch还没来得及输入程序就退出了。这是程序中面临的一个问题,输入的6被暂存到缓冲区中成为了脏数据,在我们不之情的情况下把后面的输入给覆盖掉了。
我们可以使用cin::ignore(int count,type metadelim)方法来清空缓冲区,第一个参数是要清空多少缓冲区信息,第二个参数表达以什么符号作为结尾
1 | //清空缓冲区 |
C++使用struct、class来定义一个类:struct的默认成员权限是public,class的默认成员权限是private;除此之外,二者基本无差别。
1 | class Student{ |
上面学生的类并不是真实世界中的学生,只是一个抽象的概念,并不包含真实世界中学生的所有属性,只是把一些属性抽象出来。
面向对象的误区:对象是对现实世界中具体物体的反映,继承是对物体分类的反映?这个观念是错误的。举个例子,现实生活中我们往往把正方形看作是长和宽都相等的特殊的长方形,如果把这个思想引入到C++中,可能会这么设计继承关系:
1 | class rectangle //长方形 |
上面设计了两个类,一个长方形类,一个正方形类,其中正方形类继承了长方形类以及内部的方法SetLength。当我们调用长方形类SetLength的方法时,我们只是修改了长方形的长,长方形的宽不受影响;但是如果我们调用了正方形对象的SetLength方法,不仅长会受到影响,宽也会受到影响。这个从面向对象的继承体系来说就有很大的问题了。所以我们不要把现实世界中的关系代入到面向对象编程中。
一个普通的int型变量,可以完成加、减、乘、除、比较、输出、自增等等一系列操作;如果现在有一个自定义的复数类型,我们自然也希望可以像使用int型变量一样使用它,同时它对我们是一个黑盒,一种抽象,我们不需要关心内部是如何实现的。
1 | //complex.h |
1 | //complex.cpp |
1 | int main() |
类中有个this指针,指向当前对象本身。
类创建后会系统默认创建一个构造函数,我们可以自己实现构造函数。但我们如果重写了构造函数,那么原始的默认构造函数就不存在了,如果想使用需要重新声明实现。
等号运算符也一样,系统会默认帮我们重载。不过我们最好不要过于相信系统默认的重载,在复杂情况下运算的结果可能不是我们想要的。
程序中的临时对象一定要注意优化,避免产生临时对象,否则会触发拷贝构造。
数学中有不同的图形,比如长方形、原型、三角形;多种图形计算周长、面积的方法不同,但都需要一个计算方法。我们可以抽象出一个图形类Shape,用Shape类进行公共层面的抽象操作。
1 | #include "stdafx.h" |
C++的对象模型中,子类对象中包含了父类。父类中有一个虚函数列表,是个类似数组的结构。对象模型中只保留成员变量信息和虚函数列表,其他的共有函数是通过this指针来访问的。
深拷贝的思想比较常见,比如C++的一个优化策略叫写时复制。有个信息存放在内存空间中,如果大家都去读取,那么内存中保留一份即可,但如果有地方需要写数据,那么会复制出一个新的地址空间存放相同数据,写操作作用在新地址空间上。
深拷贝和浅拷贝各有优劣,如果想兼有二者的优点,有两种可用方案:第一是使用引用计数,用shared_ptr的思路,每有一个指针指向对象,引用计数+1,直到引用计数清零时再清理内存;第二种是C++11的新标准移动语义move,把资源让渡,既可以避免重新创建空间,也防止空间释放导致新问题。
1 | //自定义字符串类String操作 |
面向对象是软件工程发展到一定阶段为了管理代码和数据提出的一种方法,它没有解决以前解决不了的问题,不是万能的,只是为我们便捷的开发出能适应快速变化的软件提供了可能。面向对象不是对现实世界的映射,但它的封装性可以把问题简化;它的继承性可以减少代码重复,避免重新发明轮子;它的多态可以实现灵活的功能扩充,提升开发效率;
ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)是基于拉丁字母的一套电脑编码系统,主要用于显示现代英语和其他西欧语言。它是最通用的信息交换标准,并等同于国际标准ISO/IEC 646。
ASCII码使用指定的7位或8位二进制数组合来表示128或256种可能的字符。美国一开始设计的是7位共128个,包含95个可见字符和33个控制字符,后期扩展到8位,称为扩展ASCII码。
中国也有自己的编码,用于表示中文字符。设计编码首先要确定字符集。国标使用分区管理,共计分为94个区,每个区含94位,共8836个码位。19区收录除汉字外的682个字符;1015区为空白区,没有使用;1655区收录3755个一级汉字,按照拼音排序;5687区收录3008个二级汉字,按照部首/笔画排序;剩余的88~94区为空白区,没有使用。这套字符集称为GB2312字符集。按照这个字符集来编码,比如3区的6行7列字符,编码0367。
ASCII是直接把码位按照二进制存储,但国标编码不是。比如5709编码的汉字(侃),把6709按照前两位和后两位分开,并分别转为16进制:0x39和0x09;然后两个数分别加上0xA0得到0xD9和0xA9,将两数合并,0xD90xA9就是汉字侃的GB2312码。
GB2312编码之所以要加0xA0,是为了要兼容ASCII码。GB2312是双字节编码,为了与ASCII编码区分,每个单字节的第八位必须是1,所以GB2312编码最少要从0x80(1000 0000)开始。但是根据规定,0x80~0x9F需要留给控制块,所以只能从0xA0开始。
计算机判断字符是ASCII码还是GB2312编码的方式就是通过字符大小,ASCII码是7个字节,如果小于127就是ASCII码,如果连续碰到两个大于127的8位就把两个组合起来当作GB2312编码。
GB2312编码只能表示6763个汉字,后期也不够用了,所以对GB2312做了扩展为GBK字符集。GB2312的高位和地位都要求必须大于127,GBK不再规定低位大于127,只保证高位大于127。把之前没用上的码位都用上,扩充了将近20000个汉字和符号。计算机只要碰到一个大于127的字节,就表示一个汉字的开始。
后期还扩展了GB18030字符集,新增了少数民族的字符。
ASCII可表示的字符数太少了,但如果每个国家都设计一套自己的编码就太乱了,为了把世界上的文字都映射到一套字符空间中,诞生了Unicode。Unicode是一个标准,规定了字符集和编码。
最开始的Unicode字符集称为UCS-2字符集,和ASCII码一样,把用到的字符按顺序罗列并标上对应的码位。存储方式和ASCII一样,直接把码位按照二进制方式存储。一共可以表示
互连网时代后,对Unicode做了优化,目前有3种Unicode的编码方式:
注意:如果使用的Windows系统,Windows文件可能有BOM(byte order mark)来表示字节序,如果要在其他平台使用,可以去掉BOM或者忽略掉。BOM在文本文件头部,FEFF(十进制为254 255)表示大端,FFFE(十进制为255 254)表示小端。
UTF-8编码原理:UTF-8将UCS-4字符集的码位划分为4个区间(0x000000000x0000007F;0x000000800x000007FF;0x000008000x0000FFFF;0x000100000x0010FFFF),第一个区间的编码样式为0XXXXXXX,第二个区间的编码样式为110XXXXX 10XXXXXX,第三个区间的编码样式为1110XXXX 10XXXXXX 10XXXXXX,第四个区间的编码样式为11110XXX 10XXXXXX 10XXXXXX 10XXXXXX。
汉字“王”在UCS-4中的编码为0x0000738B,转换为二进制就是“0000 0000 0000 0000 0111 0011 1000 1011”。0x0000738B属于UCS-4的第三区间,这个区间的编码样式是“1110XXXX 10XXXXXX 10XXXXXX”。此时我们得到了“王”这个字符的二进制形式和编码样式,把二进制形式从高到低依次插入到编码样式中,得到了“王”对应的UTF-8编码:“11100111 10001110 10001011”,十六进制为“0xe7 0x8e 0x8b”。
程序中编码错误的根本原因在于编码方式和解码方式的不统一。
先看C语言中常见的词法、语法问题:
1 | char c1 = 'yes'; |
从上面的例子可以看到,C语言是高级语言中的低级语言,优点是小巧、高效、接近底层,比如上面的例子就把字符和字符串区分的很细,但缺点就是细节和陷阱比较多。为了更好的解决这个问题,C++在兼容C语言的同时,推出了既高效又易于大规模开发的机制:string类的使用:
1 | #include <string> |
c预言数组在作为参数时的退化行为,退化为一个指针。
给定一个数组,计算数组中的数据的平均数,有以下代码:
1 | //计算平均数 |
可以看到输出的值并不是平均数,通过输出中间数据可以知道,main函数中的长度是10,而average1中的数组长度是1;
出现这个的原因就是C预言数组在作为函数参数传递时会退化为一个指针,average1中的入参实际上只是函数的首地址,sizeof(arr)输出的只是单个元素的长度。
可以进行如下优化,通过外部把数组长度先行计算出来然后传递给函数。需要注意,如果传递的是字符数组的话就不需要这么麻烦了,因为字符数组往往是通过'\0'结尾的,函数内部有办法知道数组的长度。
1 | //直接把数组长度传递进来 |
其实知道数组当作函数参数传递时会发生退化时,就可以不传递数组,而是只传递指针:
1 | double average2(int* arr, int len) |
C语言之所以要这么做,是和c语言发展分不开的。c语言早期是伴随着unix操作系统,是非常底层的,对空间要求非常高的语言。如果函数传参时传递了一个非常大的数据容器,空间转移的效率是非常低的。所以C语言设计者就通过传递指针和容器尺寸这样一种传递方式从而达到节省空间的目的。
C++的解决方案就是引入STL容器,实现底层包装,保证效率的同时也保证简单安全。
1 | #include <vector> |
使用stl容器后,哪怕是二维数组,处理起来也很方便了:
1 | double average2DV(vector<vector<int> >& vv) |
问题一:右移操作:无法区分是逻辑右移还是算术右移。
1 | #include<cstdio> |
上面可以看到,C语言在执行右移操作时表现不同,而不同的编译器输出的结果可能都不一样,C语言并没有做统一标准。C语言官方的做法是在做右移操作时,把操作数都变为无符号的数,这样可以保证执行的是逻辑右移操作(补0)。原因是无符号数首位都是0,可以保证补位的数也是0。
1 | int main() |
问题二:移位操作位数的限制。
1 | int main() |
由运行结果可以看到,char本身就只有8位,P_ADMIN的移位操作已经超过了8位,这时候所有的8位都被清零了。这是C语言编码常见错误,移位操作一定要注意操作位数上限,移位数大于0,小于位数;
出现上面两个问题的原因就是,C语言设计移位操作时需要考虑操作数表示的上下文环境。C++为了对这个问题做改进,引入了bitset:
1 | #include <bitset> |
C语言中强制类型转换隐藏了很多bug和陷阱:
1 | #include <iostream> |
上面的代码当数组长度大于0时,需要输出“positive number array”,否则输出“negative number array”。可以通过编译运行后,长度输出为3是正确的,但判断逻辑里却输出了“negative number array”。
发生这个问题的原因是sizeof的返回值是unsigned int,是无符号数,但threshold却是一个有符号数,在执行比较判断语句时,C语言的机制把threshold转换为了一个无符号数,然后才进行的比较。这里发生的是隐式类型转换。-1转换为unsigned int时会变为4294967295,是个很大的正整数(这里涉及到了补码转换)。
C语言在编写时,可以先用一个有符号的数把数据先取出来。今后编码时也需要注意,尽量避免用无符号的数据来进行数据比较:
1 | #include <iostream> |
类型转换还可能会发生在以下情况:假设要计算1+1/2+1/3+……+1/n,如果代码是这么写的:
1 | // 1+1/2+1/3+1/4+... +1/n |
可以看到,计算出的结果是1。这里的问题出在“result += 1/n”这句中,被除数是整形,除数也是整形,那么计算结果也是整型值。result虽然会转换为浮点数,但整形计算中已经丢失了精度。
c语言中的一个解决方法是把被除数先转换为浮点数:
1 | // 1+1/2+1/3+1/4+... +1/n |
上面两个例子可以看到,有时候我们会忽略C语言的隐式类型转换,导致出现程序bug;但有时候我们又需要这种隐式类型转换来得到我们想要的结果。c语言中滥用类型转换可能导致灾难性的后果,且很难排查。C语言之所以这么设计,是因为类型转换在底层语言中的运用非常广泛,且灵活方便。C++为了方便排查隐藏bug减少复杂性,提供了四种类型转换的方式:static_cast、const_cast、dynamic_cast、reinterpret_cast。
1 | // 1+1/2+1/3+1/4+... +1/n |
32位系统中,一个整数占用4个字节,共32位。其中第一位是符号位,所以一共有31位可以表示整数范围。如果计算的时候,如果我们算出的数值超出了数据表示范围,那么会数据溢出变为负数。要注意C语言中的整数不能和数学上的整数划等号。
1 | int main() |
出现这个问题的原因和系统的设计是有关的。数据存储空间是有限的,不能无限增长。C语言的一个解决方案是通过字符串的方式来表达大数的运算,字符串理论上是可以无限长的,C语言是有这个类库的,但并没有直接的解决方案。 C++本身也没有提供好的解决方案,但boost库中提供了cpp_int方法:boost官网
C标准字符和字符串的区别是:字符是单引号括起来的,字符串是双引号括起来的,由'\0'结尾。而'\0'作为结束符这个方式,表达能力有天生的缺陷:一旦字符串中间具有'\0'字符,那么c语言的字符串函数就会认为这个字符已经结束了。如果用c语言的方式存储一些图片或者其他二进制的内容,很容易出问题。
C语言的字符串操作还有另一个问题就是效率低下。C语言的字符处理函数都是通过遍历'\0'来寻找字符串结尾的,这个遍历操作会消耗性能。
C语言设计这种字符串处理方式主要是为了节省空间,有针对性的设计问题。C++语言为了解决这个问题有以下几种思路:
1 | int main() |
可以看到,string类的实现方案中,内部不仅记录了字符串的内容,还有几个变量记录了字符串内容的长度、容量等,在执行字符串操作时不需要遍历寻找'\0',提高了效率;但是依旧保留了c风格字符串以'\0'结尾的传统,还具有一些缺陷。
机器数:一个数在计算机中的二进制表示形式,叫做这个数的机器数。
机器数是带符号的,在计算机中用一个数的最高位存放符号,正数为0,负数为1;
比如:十进制数+3,就是00000000000000000000000000000011;十进制数-3,就是10000000000000000000000000000011;(int值占4字节);这个例子只是整型数,浮点数有其他表达方式。
真值:真正的数学意义上的数值。因为机器数第一位是符号位,所以机器数的形式就不等于真正的数值。
按照上面的机器数的表示方法有一个问题,第一位用作符号位的话,这个数的表示范围会变小。所以计算机中存储用的并不是机器数,而是补码。
用一个函数
eg:
用一个函数
eg:
数 | 8位字长 | 16位字长 | 32位字长 | 64位字长 |
---|---|---|---|---|
UMax | 0xFF 255 |
0xFFFF 65535 |
0xFFFFFFFF 4294967295 |
0xFFFFFFFFFFFFFFFF 18446744073709551615 |
TMin | 0x80 -128 |
0x8000 -32768 |
0x80000000 -2147483648 |
0x8000000000000000 -9223372036854775808 |
TMax | 0x7F 127 |
0x7FFF 32767 |
0x7FFFFFFF 2147483647 |
0x7FFFFFFFFFFFFFFF 9223372036854775807 |
-1 0 |
0xFF 0x00 |
0xFFFF 0x0000 |
0xFFFFFFFF 0X00000000 |
0xFFFFFFFFFFFFFFFF 0X0000000000000000 |
需要注意,无符号数中0xFFFFFFFF是最大值,但有符号数中这个值代表-1;还要注意有符号数中的最小值是0x80000000;
以32位机器为例,一个字有32bits字长,占用4bytes,在内存中有以下两个存放方式:
个人机器基本上都是小端表示法。
我们在设计软件系统时总是希望软件系统尽可能的简单通用。于是人们希望在只有加法运算器的情况下设计一种方法能实现减法运算。
以时间为例:表盘一圈12个小时,现在是8点,那么3小时前是5点,9小时以后还是5点(8+9-12),这里进行的是模12的操作。所以8-3和8+9的结果是一样的;我们就可以用9来表示-3,如果想计算8-3,那么就用加法器计算8+9;
当然单纯的这么想是有问题的,以时间为例我们可以得到一个对照表,遇见12就清零:
0 | -1 | -2 | -3 | -4 | -5 | -6 | -7 | -8 | -9 | -10 | -11 |
---|---|---|---|---|---|---|---|---|---|---|---|
0 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 |
我们计算3-5的时候,得到的值是10,我们可以在对照表中得到10对应的值是-2,但我们计算5-3的时候,得到的值是2,我们却不需要找对应的值。计算机如何区分什么时候要找对应的值,什么时候不需要呢?
可以对表进行一些修改:
0 | -1 | -2 | -3 | -4 | -5 | -6 | 5 | 4 | 3 | 2 | 1 |
---|---|---|---|---|---|---|---|---|---|---|---|
0 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 |
表格修改后,每次计算完都在表格中进行对照,这样操作统一,得到的值也是正确的了。其实在计算机内部,补码的用处就是构造这张映射表的。