I - 概述
本文章主要分享 C++ 的一些基础和易错点,人一天的精力和时间有限,需要节省找 bug 改 bug 和避免一些不必要的时间浪费,通过比较好的编程方式和借助编译器,将节省的精力和时间用在重要的事情上。
好钢要用在刀刃上 —— 鲁迅
:)
II - C++ 基础
2.1 - 计算机语言分类
首先可以思考一个问题
Python 与 C++ 有什么区别?
答案可以是:
- python 语句结束不用加分号,C++ 语句结束需要加分号
- python 中 if else 悬挂问题,else 关联最近的相同缩进的 if ,
而 C++ else 永远关联最近的 if
见下面 python 和 C++ 代码:
python 示例
if test >= 0
print("test is greater than or equal to zero")
if test == 0
print("test is equal to zero")
else
print("test is less than zero")
此代码中, else 关联 test >= 0
的 if
分支。
C/C++ 示例
if (test >= 0)
printf("test is greater than or equal to zero");
if (0 == test)
printf("test is equal to zero");
else
printf("test differs from zero");
此代码中 else 关联 0 == test
的 if
分支,C/C++中 else 为就近原则。
- ... 等
但这些答案都不触及到实质。实质问题是 Python 与 C++ 属于两种不同类型的编程语言。
计算机语言分为三大类
2.1.1 - 编程语言
简而言之,用于写程序的语言,比如手机 app 应用,网站,操作系统等,比如 Python, Java, JavaScript, C#, PHP, C/C++, R, Objective-C, Swift, Go 等。
2.1.2 - 描述语言
根据规定,制约来描述和结构化数据集合的语言,例如 XML, HTML 和 JSON 等。
2.1.3 - 查询语言
用于查询存储数据的结构,常见例如,关系型数据库的查询语言 SQL, RDF 图表的 SPARQL, XML 文档的 XQuery 等。
2.2 - 编程语言分类
其中,编程语言又可以细分为三大类:解释型语言,编译型语言,伪编译型语言。
2.2.1 - 解释型语言
这类语言的源代码需要被翻译为汇编,然后一行一行地被一个程序执行,这个程序被称为解释器。例如,Python 和 PHP 就是两种解释型语言。
2.2.2 - 编译语言
此类语言所写出的源代码会被直接转变为可执行文件,在 Windows 下,它们的扩展名为 .exe 。C 和 C++ 就属于编译型语言。
2.2.3 - 伪编译语言
伪编译型语言需要借助一个伪编译器 (pseudo-compiler) 来生成一些可以在任何平台下都支持的中间文件。例如,借助 JVM 的 Java 和可以在 Microsoft .NET 平台下可用的 VB.NET,C# 等。
在执行效率方面,因为编译型语言是直接由系统执行,伪编译型语言借助平台框架或虚拟机来执行,解释型语言借助解释器翻译后来执行。所以效率上,编译语言 > 伪编译型语言 > 解释型语言。
有个朋友之前用 C++ 和 Python 写过两个相同的程序,即打开服务器上的目录并列出其包含的所有文件,如果包含子目录则递归列出其包含的所有文件。其中 C++ 程序跑了十分钟,而 Python 程序跑了几天,由此可见 Python 和 C++ 执行效率的大致情况。
2.3 - 编译过程
编译过程一共包含四个阶段:
- 1 - 预处理
将所有的 #include 和 #define 展开,移除注释。(.i)
所以,不必担心注释会影响最终生成的程序体积,可以放心写注释。 - 2 - 编译
将经过预处理的源代码转换成汇编代码。(.s) - 3 - 汇编
将汇编代码进一步转换成二进制格式的机器码,生成目标文件 (.o) - 4 - 链接
将多个目标文件以及需要的库文件链接成最终的可执行文件。
所以我们在代码中的空白行也会被编译器忽略,可以大胆换行,提高代码的可读性。
2.3.1 - 前置声明
避免不必要的依赖导致编译时间过长,顶层头文件改变,所有包含此头文件的文件都需要重新生成,造成编译时间浪费,可以使用前置声明。比如:
// in header Remote.h
class DataStruct;
class Remote
{
//...
};
// in source file Remote.cpp
#include "../common/DataStruct.h"
2.3.2 - 默认生成
在编译过程中,C++ 的编译器会默认生成一些代码:
- main 函数的
return 0;
在此语句缺失时,编译器会自动补充。 - 类的默认构造函数和析构函数,同样在未声明和实现时。
- 默认的拷贝构造函数和赋值操作符重载。
第 3 点有可能造成浅拷贝 (shallow copy),和双重释放 (double free) 的崩溃问题 。
浅拷贝:
- 拷贝发生时,指针类型的类成员变量只拷贝了另一个类此成员变量的指针地址,而未重新分配一片新内存,会使得两个指针指向同一片内存,从而操作同一片内存
- 所以对象析构的时候会释放两次同一片内存,就会出现运行时 double free 的问题
解决方法:
- 自己实现这两个函数,拷贝构造和赋值操作符重载
- 设置为私有,写一个空的或者使用默认的。
class Example { public: Example(); ~Example(); private: // make an empty one Example(const Example & ex) { } Example & operator=(const Example & ex) { } // or use compiler default Example(const Example & ex) = default; Example & operator=(const Example & ex) = default; };
- 禁用这两个函数
class Example { public: Example(); ~Example(); // disable copy constructor and assignment operator Example(const Example & ex) = delete; Example & operator = (const Example & ex) = delete; };
III - 易错点
3.1 - 宏定义
3.1.1 - 存在问题
宏定义存在的问题和替代方法。宏的缺点:
- 缺乏类型检查,没有函数调用检查严格
- 调试困难 (不能打断点),难定位到问题的具体位置
- 宏只是简单的文本替换,宏展开可能产生意想不到的副作用
如:
#define SQUARE(a) (a)*(a)
int main(int argc, char * argv[])
{
int i(10), r(0);
r = SQUARE(++i); // ++i 执行了两次
}
再如优先级问题:
#define N 10
#define M 100 + 25
此时计算 MN 的结果是什么?
100 + 25 10 = 350
而不是 1250
另外,多重定义可能出现预期错误,导致下标越界,也不容易查找出错位置如:
#define PATH_MAX_SIZE 256
#define PATH_MAX_SIZE 1024
由于头文件的包含先后顺序或者包含层级等,出现不正确的宏定义。
思考? 出现两个宏定义时,代码执行时是使用的哪个宏定义?
答:后定义的宏会覆盖掉先定义的宏。
3.1.2 - 解决方法
定义常量
多数编程规范建议使用 const 表达式来替代宏,为了强制编译器类型检查,但可能还不够。
举例:
const int remoteClient = 0;
const int remoteServer = 1;
const int statusOn = 2;
const int statusOff = 1;
const int statusError = 0;
bool SetRemoteConfig(Remote * rmt, int type, int status);
SetRemoteConfig(rmt1, statusOn, remoteClient);// wrong parameters
定义相同的变量类型,可能会由于开发人员疏忽设置错误。
定义枚举
enum RemoteType {
remoteClient, remoteServer };
enum Status {
statusOn, statusOff, statusError };
bool SetRemoteConfig(Remote * rmt, RemoteType type, Status status);
SetRemoteConfig(rmt1, statusOn, remoteClient); // wrong parameters but compilation error
// 编译报错,statusOn 和 remoteClient 位置错误
3.1.3 - 小结
注意:这里并不是说宏不好,而是说不要滥用。
宏的优点:
- 可以使代码简洁,方便修改
- 节省一定量重复性的代码编写工作
- 实现多环境兼容 (多操作系统/调试发布/多版本等宏开关)
- 函数的整体替换
- 提高性能
关于提高性能:
一般函数调用会先进入被调用函数中,然后再回到调用函数中。小函数的频繁跳转会引起性能上的损失。有一种方法可以既避免性能损失又进行类型检查。
使用内联函数替代宏函数,由于内联函数将调用表达式用内联函数体来替换,可以避免这种性能损失,也可在编译期间进行类型检查。
例:C 标准库的 \ 中的 max 函数。
#define __max(a,b) (((a) > (b)) ? (a) : (b))
template <class T>
inline T & max(T& x, T& y)
{
return (x > y) ? x : y;
}
3.2 - NULL 与 nullptr
NULL 的实质,在 C++ 中为整数 0
。
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *) 0)
#endif
通常在函数重载时容易出现问题,例
DataStruct(DataStruct & dt); // 1
DataStruct(DataStruct * dt); // 2
DataStruct(int , std::string param = ""); // 3
DataStruct(NULL);
此处的函数调用会调用第 3 个, nullptr
可以转换成任意类型的指针和布尔值,但是不能转换为 int
。
3.3 - 条件判断易错
可能由于疏忽或其他不可控因素导致相等判断的双等号少打一个,变成了恒成立的赋值语句,编译时不报错。
相等判断时,将常值置于双等号左侧,由于常值不可以修改,则编译时会报错。
int type = 9;
enum Type {
Connection };
if (Connection == type) // 1, compilation error, when =
if (type == Connection) // 2, no compilation error, when =
第 1 种条件判断语句少写等号符时会编译报错,而第 2 种则不会,因此建议将常值置于相等条件判断的左侧。
3.4 - 继承与多态
3.4.1 - 知识点 struct 与 class
struct
与 class
在 C++ 中除以下三种区别外,使用方式相同。
- 默认成员变量的访问权限,struct 为 public ,class 为 private
- 默认继承方式不同,struct 为 public 继承,class 为 private 继承
class 和 struct 可以相互继承,默认继承方式取决于派生类。 - 关键字 struct 不能用于定义模板
template <class T> // OK //... template <struct T> // error //...
3.4.2 - 多态易错
见如下代码
class Vehicle
{
public:
void run() {
std::cout << "Vehicle" << std::endl; }
};
class Tank : public Vehicle
{
public:
void run() {
std::cout << "Tank" << std::endl; }
};
int main(int argc, char * argv[])
{
Vehicle * v = new Tank();
v->run();
}
此处 v-> run()
执行的是哪个类的 run 函数?
答:调用的是 Vehicle 类中的 run 函数,由于 run 函数不为虚函数,要调用子类中的 run 函数需要在父类 run 函数前加上 virtual
关键字。
3.4.3 - 其他易错
在使用结构体时,初始化常常使用 memset
以简化操作,但是如类中或其父类中包含虚函数时需要特别注意。
class A
{
public:
A()
{
//...
memset(this, 0, sizeof(*this));
}
};
上述代码中的 memset
在有虚函数时,会将类的虚表指针置空,从而导致空指针,以致运行时程序异常退出。
3.5 - 赋值操作符
赋值操作符重载需要特别注意要 忽略自身拷贝 。
有以下代码
class String
{
public:
String(const char * value)
~String();
String& operator=(const String & that);
private:
char * data = nullptr;
};
//...
String a;
a = a;
//...
String& String::operator=(const String & that)
{
if (data) delete [] data;
data = new char [strlen(that.data) + 1];
strcpy(data, that.data); // 自拷贝时, that.data 已经删除,变成野指针
return *this;
}
需要进行如下修改
String& String::operator=(const String & that)
{
if (this != &that)
{
if (data) delete [] data;
data = new char [strlen(that.data) + 1];
strcpy(data, that.data);
}
return *this;
}
参考链接:https://www.cnblogs.com/sujz/archive/2011/05/12/2044365.html
参考文档:
《华为C语言编程规范.pdf》
《华为C++语言编程规范.pdf》