预处理器、作用域、static、const和内存管理

预处理器

C预处理器处理程序的源代码,在编译器运行之前运行,通常以符号"#"开头。

C预言的预处理主要有三个方面的内容:

  1. 宏定义与宏替换
  2. 文件包含
  3. 条件编译

  

宏定义与宏替换

  “宏”是借用汇编预言中的概念,为的是在C语言程序中方便的做一些定义和扩展。这些语句以“#define”开头,分为两种:符号常量的宏定义和带参数的宏定义

  • 符号常量的宏定义和宏替换
1
#define 标识符 字符串

  其中标识符就是宏名称,注意宏定义末尾不加分号.

  由于预处理是在编译之前的处理,而编译工作的任务之一就是语法检查,所以预处理是不做语法检查的。且宏定义不分配内存,变量定义才分配内存。

  • 带有参数的宏定义及其替换

  对带有参数的宏定义进行宏替换时,不仅对宏标识符作字符串替换,还必须作参数的替换。有时为了避免发生错误,需要在宏参数上加括号。

1
2
3
4
5
6
#define 标识符(参数列表) 字符串


#define FUN(x) (x*x) //FUN(a+b)将被替换为a+B*a+B

#define FUN(x) ((x)*(x)) //FUN(a+b)将被替换为(a+B)*(a+B)

  宏替换的本质就是文本替换,需要注意:

  1. 宏名一般用大写,宏名和参数的括号间不能有空格,宏定义末尾不加分号;
  2. 宏替换只坐替换,不做语法检查、不做计算、不做表达式求解;
  3. 宏替换在编译前进行,不分配内存;函数调用在编译后的程序运行时进行,且分配内存;
  4. 函数只有一个返回值,利用宏则可以设法得到多个值;
  5. 宏替换会使源程序变长,函数调用不会;
  6. 宏替换不占用运行时间,只占用编译时间;函数调用占用运行时间(内存分配、保留现场、值传递、返回值)

  实际工程中应尽量少用宏替换。C++中宏替换实现的符号常量功能由const、enum代替,带参数的宏替换可由模板内联函数代替。

文件包含

1
2
#include <func.h>
#include "func.h"

  如果头文件名在尖括号<>中,那么认为该头文件是标准头文件。编译器将会在预定义的位置集合中查找该头文件,这些预定义的位置可以通过设置查找路径和环境变量或者修改命令行选项来修改;

  如果头文件在一对引号中,则认为它是非系统头文件,非系统头文件的查找通常开始于源文件所在的路径中;

条件编译

  提供条件编译措施使得同一源程序可以根据不同编译条件(参数)选择不同的目标代码,其作用在于便于调试和移植。

1
2
3
4
#if/ifdef/ifndef
#elif
#else
#endif

全局变量和局部变量

  全局变量也称为外部变量,在函数的外部定义。它属于一个源程序文件,作用域是整个源程序。

  在不同的文件中引用一个已经定义过的全局变量,可以用头文件引用的方式,也可以用extern关键字。假设变量写错了,如果使用头文件包含的方式,那么编译期间会报错;如果使用extern关键字,编译期间不会报错,链接期间报错。

1
2
3
4
5
6
//file_1.cpp
int counter;

//file_2.cpp
extern int counter; //使用file_1中的counter
++counter;

  局部变量指在程序中,只在特定过程或函数中可以访问的变量,是相对全局变量而言的。在面向过程的语言中,局部变量可以和全局变量重名,但局部变量会屏蔽全局变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int count = 3;  //语句1

int main()
{
int i, sum, count = 2; //语句2
for(i = 0;i < count; i+=2, count++) //判断条件count是语句2的count
{
static int, count = 4; //语句3
count++; //用的是语句3的count
if(i % 2 == 0)
{
extern int count;
count++; //因为用了extern,这里的count是语句1的count
sum += count; //因为用了extern,这里的count是语句1的count
}
sum += count; //语句3的count
}

printf("%d %d\n", count, sum); //语句2的count
return 0;
}

static

普通的static

普通的static作用有三个:

  1. 隐藏:当我们同时编译多个文件时,所有未加static的全局变量和函数都具有全局可见性,其他的源文件也可以访问。如果加了static前缀,则全局变量和函数对其他源文件隐藏,只在当前文件生效。
  2. 默认初始化为0,包括未初始化的全局静态变量和局部静态变量。全局未初始化的变量本身也具备这个属性,未初始化的全局变量和未初始化的静态变量都在BSS段内,所有的字节默认为0x00;
  3. 保持局部变量内容的持久。函数内的普通的局部变量退出作用域就消失了,但静态的局部变量退出函数后仍然存在,生存周期为整个源程序的周期。static的局部变量特点是只进行一次初始化且具有“记忆性”。不过虽然生存周期是整个程序,但作用域仍然和普通局部变量相同,出了函数虽然还存在,但不能使用。

类中的static

  C++重用了static关键字,并赋予了不同的含义:类中的static表示属于一个类但不属于此类的任何特定对象的变量和函数。static的成员变量和成员函数都独立于类对象存在。

  • 静态数据成员

  普通数据成员存在于该类的每个对象中,但static数据成员独立于该类的任意对象存在:static数据成员是与类关联的对象,二不与该类的对象相关联。当某个类的实例修改了该静态成员变量,修改后的值被该类的所有实例所见。

  静态数据成员和普通数据成员一样,也遵从public、protected、private的访问规则。

  静态数据成员也存储在全局(静态)存储区。静态数据成员定义时要分配空间,所以不能在类声明中定义,必须在类定义体的外部定义。

1
2
3
4
5
6
7
8
9
10
11
class Account
{
public:
void apply();
static double rate() {return interestRate;}
private:
std::string owner;
static double interestRate;
};

double Account::interestRate = 10; //类外定义

使用static成员变量而不是全局变量有三个优点:

  1. static成员的名字还在类的作用域中,可以避免全局冲突;
  2. static成员可以声明为私有的,实施封装;
  3. 阅读方便,可以明确看出static成员与类是关联的

  • 静态成员函数

  和静态成员变量一样,静态成员函数是类的内部实现,属于类定义的一部分,为类服务而不是为类的具体对象服务。

  普通成员函数服务于具体的对象,所以普通的成员函数都隐含了一个this指针指向类的对象本身;静态成员函数不与对象关联,所以不具有this指针。所以静态成员函数无法访问属于类对象的非静态数据成员,也无法访问非静态成员函数,只能调用类其余的静态成员函数与访问静态数据成员。不过非静态的成员函数可以任意访问静态的成员函数和变量。由于没有this的开销,静态成员函数相比普通的成员函数速度上有少许增长。

  static成员变量可以被声明为const,但static成员函数不可以。const成员函数的意思是承诺不修改该函数所属的对象,但static成员函数不属于任何对象。

  static成员函数也不能被声明为虚函数、volatile。

const

常量

  C++中const限定符把一个对象转换为常量,常量定义后不允许修改,所以定义时必须初始化。

1
const int bufSize = 512; //必须初始化

  全局变量在整个程序中都可以访问,但全局的const变量是定义该变量的文件的局部变量。如果想被其他文件访问,需要声明extern。

  C和C++中的const有区别。常量引进是在早期的C++版本中,当时标准C规范正在制定。C中的const意思是“一个不能被改变的普通变量”,在C中总是占用存储,C编译器不能把const视为一个编译器的常量:

1
2
3
//以下写法在C中是错误的,C++中是允许的
const bufSize = 100;
int buf[bufSize];

  C默认const是外部连接的,C++默认const是内部连接的,如果想在C+中完成于C同样的事情,需要加extern变成外部连接:

1
2
3
4
5
//C语言可以这么写,C编译器只把他当作声明,C++编译器必须初始化,不能这么写
const int size;

//C++必须加extern
extern const bufSize; //只声明

  const最初的动机是取代#define的值替换功能,使用const取代#define的优点如下:

  1. const常量有数据类型,宏常量没有数据类型,所以编译器可以对const常量进行类型安全检查;
  2. 预处理会盲目的将宏常量进行代码替换,可能导致代码中多出很多个备份,所以使用const常量可能比使用#define产生更小的目标代码;
  3. const可以执行常量折叠:常量折叠是编译期间简化常量表达式的过程,简单说就是将常量表达式计算求值,并用求得的值替换表达式放入常量表。

指针和const

  需要区分指向const的指针和const指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//指向const的指针:
const double *cptr;
// cptr是个指针,它指向一个const double
// cptr可以指向任何东西,所以不需要初始化,但是它所指的东西不能改变


//const指针
//使指针成为const,要把const标明的部分放在*右边
double d = 1.0;
double* const cptr = &d;
// cptr是个指针,这个指针是指向double的const指针
// 指针本身是const,所以必须初始化
// 在指针寿命期间内,指向的地址不可以改变,但地址里的内容可以变更

const修饰函数参数和返回值

  在一个函数声明式内,const可以和函数返回值、各参数、类成员函数函数自身产生关联:

  • const修饰返回值

  若返回值是值类型,则对于内部数据类型来说,返回值是否是常量并没有关系,const修饰返回值通常用于处理用户定义的类型;除了值类型,还可以返回指针。正常的函数不能返回指向局部变量的指针,因为函数返回后指针就无效了,栈也被情理了,但如果返回的指针是指向对中分配的存储空间的指针或者是指向静态存储区的指针,在返回后仍然有效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
//错误!!
// p是数组在栈上,返回的是指向栈内存的指针,
//返回的地址不是NULL,但内容已经被清除了
char* GetMemory()
{
char p[] = "hello world";
return p;
}


//正确
//数组在静态存储区,可以通过函数返回
char* GetMemory()
{
static char p[] = "hello world";
return p;
}



//正确
//p指向全局静态存储区,可以通过函数返回
//因为p指向常量,这里最好加上const修饰
//如果不加const,函数外面如果对返回的字符串做修改就崩溃了
const char* GetMemory()
{
char p* = "hello world";
return p;
}


//不太好的写法
//p指向堆内存的地址,可以通过函数返回
//但是需要外部来delete[]释放内存
char* GetMemory()
{
char* p = (char*)malloc(12);
if(p == NULL){
return NULL;
}
else{
p = "hello world";
}
return p;
}

  • const修饰函数参数

  参数加上const可以明确告知编译器参数在函数体内部不会也无法改变。如果是值传递,加const意义不大,但如果是传递地址,那么都应该尽可能的用const修饰。

const与类

  • const成员函数
1
2
3
4
class base{
void func1();
void func2() const;
}

  被const修饰的类成员函数改变了隐含的this形参的类型,使得this形参指向的对象为const类型。this本身的类型是base* const,被const修饰后变成了const base* const。const成员函数不能修改调用该函数的对象。

  const成员函数的目的是为了确保该成员函数可以作用于const对象身上。const对象、指向const对象的指针或引用只能调用其const成员函数;非const对象可以调用所有成员函数。显然,若不存在const成员函数,那么const对象的操作就变的极为困难,无法调用任何成员函数了。

  要注意,如果类成员函数只是常量性质不同,其余都一样,是可以被重载的:

1
2
3
4
class base{
void func1();
void func1() const;
}

  • const数据成员

  如果一个类的数据成员声明为const,那么必须在构造函数的初始化列表中进行初始化,且必须具有构造函数。不过如果数据成员同时具有static和const属性,那么也可以使用外部初始化。const数据成员也不可以在类定义处初始化,因为const数据成员只是在某个对象的生存周期内是常量,对于整个类而言是可变的,不同的对象可以有不同的值,如果在类声明中初始化const数据成员,因为类对象还没有创建,编译器不知道const数据成员的值是什么。

1
2
3
4
5
6
class Thing{
public:
Thing():valueB(1){}
private:
const int valueB;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Test
{
public:
Test():a(0){}
enum {
size1 = 100,
size2 = 200
};

private:
const int a; //只能在构造函数初始化列表中初始化
static int b; //在类外定义并初始化
const static int c;
//也可以写作static const int c;
//c这里是整型,所以也可以在这里初始化,但仍需要在类外进行定义
//如果c不是整形(char、short、int、long),就不允许在这里初始化
};

int Test::b = 0; //static成员变量不允许在类内初始化,不属于某个对象
const int Test::c = 0; //给const static成员变量赋值时可以不加static,但必须加const

内存管理与释放

  C/C++程序,用户主要使用的内存主要分为:栈区、堆区、全局(静态)存储区、文字常量区、代码区。

  堆区和栈区的区别:

  • 栈区:由编译器自动分配和释放,存放函数的参数值、局部变量的值等。操作方式类似于数据结构中的栈,速度较快;
  • 堆区:一般由程序员分配释放,若程序员不释放,程序结束时由操作系统回收。要注意和数据结构中的堆是两回事,它的分配方式类似链表。一般速度比较慢,而且容易产生内存碎片,不过用起来方便。
1
2
3
4
5
6
7
//C语言
char* p1 = (char*)malloc(10);

//C++
char* p2 = new char[10];

//要注意p1和p2本身是栈上的变量,只是指向堆上分配的内存

  每一个程序执行时都占用一块可用的内存空间,用于存放动态分配的对象,这个内存空间就是堆。C语言使用标准库函数malloc和free在堆区分配存储空间,C++语言使用new和delete表达式实现。

动态创建对象的初始化

  通常,动态创建的对象如果不提供显式的初始化,那么对于类对象,用该类的默认构造函数初始化,当然也可以使用显式初始化;而内置类型的对象则无初始化;

  对于提供了默认构造函数的类的类型,没有必要对其对象进行显式初始化,操作结果一样;但如果是内置类型或者是没有默认构造函数的类型,不同的初始化方式有显著的差别:

1
2
3
4
5
6
7
8
9
//std::string类有默认构造函数
//无论是隐式的还是显式的,结果都是调用默认构造
string* ps = new string; //调用默认构造函数初始化
string* ps = new string(); //调用默认构造函数初始化


//int是内置类型的,隐式的和显式的有区别
int* pi = new int; //隐式的,无初始化
int* pi = new int(); //显式的,pi指向一个初始化为0的int值

  动态创建的对象用完后,程序员必须显式的将该对象占用的内存释放,否则就会内存泄漏,C语言提供了free函数,C++使用delete表达式。回收用new分配的单个对象的内存空间用delete,回收用new[]分配的一组对象的内存空间时用delete[];

1
2
3
char* p = new char[64];
delete[] p;
p = nullptr;

const对象的动态分配和回收

1
const int* pci = new const int(1024);

  C++允许动态创建const对象。与其他常量一样,动态创建的const对象必须在创建时初始化,且一经初始化其值不可再修改。

  对于一个类的const动态对象,如果该类提供了默认的构造函数,则此对象可以隐式初始化;内置类型对象或者未提供默认构造函数的类对象必须显式初始化:

1
2
3
4
//std::string有默认的构造函数
//new表达式没有显式的初始化pcs所指的对象
//而是隐式的将pcs所指的对象初始化为空string
const string *pcs = new const string;

  尽管程序员不能改变const对象的值,但是可以撤销对象本身。如同其他对象一样,const动态对象也可以使用指针释放:

1
2
const string *pcs = new const string;
delete pci;

new/delete和malloc/free比较

相同点:都可以用于申请动态内存和释放内存

不同点:

  1. malloc和free是C/C++语言的标准库函数,new/delete是C++的运算符;
  2. new自动计算需要分配的空间,而malloc需要手工计算字节数;
  3. new是类型安全的,malloc不是;
  4. new和delete可以调用相关对象的构造和析构,malloc和free不行;
  5. malloc和free需要库文件支持,new和delete不需要
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
malloc和free由于是库函数,不在编译器控制权限之内,所以无法执行类的构造函数和析构函数;



new的执行过程:
1.operator new的标准库函数(operator new对应malloc),分配足够大的原始的未类型化的内存,以保存指定类型的一个对象;
2.运行该类的一个构造函数,用指定初始化式构造对象
3.返回指向新分配并构造的对象的指针;

delete的执行过程:
1.首先对指针指向的对象运行适当的析构函数;
2.调用operator delete的标准库函数释放该对象用的内存(operator delete对应free);



malloc的函数原型如下:
void* malloc(size_t size);
用malloc申请一块长度为length的整数类型的内容程序如下:
int* p = (int*)malloc(sizeof(int) * length);
可见malloc返回值的类型是void*,所以在调用malloc时一定要显式类型转换;
且malloc函数本身并不识别要申请的内存类型,只关心总字节数

free函数的原型如下:
void free(void* memblock);
free(p)来释放内存,如果p是NULL,那么free(p)操作多少次也不会出问题;
但如果p不是NULL,那么连续free(p)两次就会导致程序运行错误



new内置了sizeof、类型转换和类型安全检查功能,所以使用简单
int* p2 = new int[length];

内存池

  通常我们直接使用new、malloc等申请内存,这样做有个缺点:由于所申请的内存块大小不定,当频繁使用时会造成大量的内存碎片降低性能。

  内存池是一种内存分配方式,在真正使用内存之前,先申请分配一定数量的、大小相等(一般情况下)的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块,若内存块不够再继续申请新的内存。