第5章 数据的共享与保护
5.1 标识符的作用域与可见性
5.1.1 作用域
作用域:一个标识符在程序正文中有效的区域。
在函数原型声明时形参的作用范围就是函数原型作用域。比如下面的函数原型声明中,标识符radius的作用范围(有效范围)只在函数area形参列表的左右括号之间,在程序其他地方无法引用该标识符。
double area(double radius);
注意:由于在函数原型声明的形参列表中起作用的只是形参类型,而标识符并不起作用,因此标识符是可以省去的。但为了可读性强,标识符一般不省去。
函数形参列表中形参的作用域,从形参列表中的声明处开始,到整个函数体结束之处为止。
函数体内声明的变量,其作用域从声明处开始,一直到声明所在的块结束的大括号为止。
局部变量:具有局部作用域的变量。
类可以被看成是一组有名成员的集合,类X的成员m具有类作用域。对m的访问有三种方式:
(1)如果在X的成员函数中没有声明同名的局部作用域限定符,则在该函数内可以直接访问成员m。
(2)表达式X.m
或者X::m
(用于访问类的静态成员)是程序中访问对象成员的最基本方法。
(3)通过ptr->m
这样的表达式,其中ptr
为指向X类的一个对象的指针。
C++中,类及其对象还有其他特殊的访问和作用域规则。
命名空间的作用:不同模块中的类和函数之间可能发生重名,命名空间添加限定,以避免引发错误。
命名空间的语法形式:
namespace 命名空间名{ 声明命名空间
命名空间内的各种声明(函数声明、类声明等等)
}
比如:
namespace SomeNs{
class SomeClass{...};
};
命名空间允许嵌套。
在命名空间内部可以直接引用当前命名空间中声明的标识符。
如果需要引用其他命名空间的标识符,需要使用下面的语法:
命名空间名::标识符名 //引用其他命名空间的标识符,用其命名空间名限定
比如:
SomeNs::SomeClass obj1; //声明一个SomeNs::SomeClass型的对象obj1
在标识符前总使用这样的命名空间限定会显得过于冗长,为解决这一问题,C++提供了using
语句。
using
语句的两种形式:
(1)将指定的标识符暴露于当前作用域内,使得在当前作用域中可以直接引用该标识符。
using 命名空间名::标识符名;
(2)将指定的命名空间内所有标识符暴露于当前作用域内,使得在当前作用域中可以直接引用该命名空间内的所有标识符。
using namespace 命名空间名;
C++标准程序库的所有标识符都被声明在std
命名空间内,如cin
、cout
、endl
等标识符。
using namespace std;
注意:using namespace
语句不宜放在头文件中。
全局变量:具有命名空间作用域的变量。
两类特殊的命名空间:全局命名空间、匿名命名空间。
全局命名空间:是默认的命名空间,在显式声明的命名空间之外声明的标识符都在一个全局命名空间内。
匿名命名空间:一种需要显式声明的没有名字的命名空间,匿名命名空间的声明方式如下:
namespace{ //声明匿名命名空间
匿名命名空间内的各种声明(函数声明、类声明等等)
}
匿名命名空间的作用:在包含多个源文件的工程中,匿名命名空间常被用来屏蔽不希望暴露给其他源文件的标识符,这是因为每个源文件的匿名命名空间是彼此不同的,在一个源文件中没有办法访问其他源文件的匿名命名空间。
5.1.2 可见性
程序运行到某一点,能够引用到的标识符,就是该处可见的标识符。
如果在两个或多个具有包含关系的作用域中声明了同名标识符,则外层标识符在内层不可见。
5.2 对象的生存周期
对象从诞生到结束的这段时间就是它的生存期。
在生存期内,对象将保持它的状态(即数据成员的值),变量也将保持它的值不变,直到它们被更新为止。
5.2.1 静态生存周期
如果对象的生存期与程序的运行期相同,则称它具有静态生存期。
在命名空间作用域中声明的对象都具有静态生存期。
若要在函数内部的局部作用域中声明具有静态生存期的对象(静态变量),则要使用关键词static。
static int i=5;
当一个函数返回后,下一次再调用时,局部作用域中的静态变量还会保持上一次的值。
细节:定义时未指定初值的基本类型静态生存期变量,会被赋予0值初始化。而对于动态生存期变量,不指定初值意味着初值不确定。
5.2.2 动态生存期
除了上述情况外,其余的对象都具有动态生存期。
在局部作用域中声明的具有动态生存期的对象,习惯上也被称为局部生存期对象。
局部生存期对象诞生于声明点,结束于声明所在的块执行完毕之时。
5.3 类的静态成员
静态成员能解决同一类的不同对象之间数据和函数共享问题。
5.3.1 静态数据成员
若某个属性为整个类所共有,不属于任何一个具体对象,则用static关键字来声明为静态数据成员。
静态数据成员在每个类只有一个副本,由该类的所有对象共同维护和使用,从而实现了同一类的不同对象之间的数据共享。
类属性是描述类的所有对象共同特征的一个数据项,对于任何对象实例,它的属性值是相同的。
静态数据成员具有静态生存期(对象的生存期与程序的运行期相同)。
访问静态数据成员:静态数据成员不属于任何一个对象,因此可以通过类名加作用于限定符对它进行访问,一般的用法是:
类名::标识符 //访问静态数据成员
在类的定义中仅仅对静态数据成员进行引用性声明,必须在命名空间作用域的某个地方使用类名限定定义性声明,这时也可以进行初始化。
静态数据成员必须类外初始化。
class Point{
private:
static int count; //类内声明静态数据成员
};
int Point::count=0; //类外定义和初始化静态数据成员,使用类名限定态
上例中的静态数据成员count
虽是私有类型(private),却可以直接在类外初始化。但除此之外的其他地方,不能直接访问(private)。
5.3.2 静态函数成员
用static关键字来声明为静态函数成员。
与静态数据成员一样,静态函数成员也属于整个类,由同一个类的所有对象所共有和共享。
(静态)函数成员的类外实现通过类名加作用于限定符。
静态函数成员的调用可以通过类名或对象名,即**类名::
或对象名.
**。一般习惯于通过类名调用。
非静态函数成员的调用只能通过对象名,即**对象名.
**。
静态函数成员中,可以直接访问(调用)该类的静态数据成员和静态函数成员,不依赖于任何对象,而若要在静态函数成员中访问(调用)非静态成员,必须通过对象名。
class A{
public:
static void f(A a); //声明静态函数成员f
private:
static int x; //声明静态数据成员x
int y; //声明非静态数据成员y
};
void A::f(A a){ //实现成员函数要指明类的名称
cout<<x; //静态函数成员可以直接访问该类的静态数据成员
cout<<a.y; //静态函数成员若要访问非静态成员,必须通过对象名
}
提示:之所以在静态函数成员中访问类的非静态成员需要指明对象,是因为对静态函数成员的调用是没有目的对象的,因此不能像非静态函数成员那样,隐含地通过目的对象访问类的非静态成员。
由于通过静态函数成员访问非静态成员比较麻烦,一般情况下,静态函数成员主要用来访问同一个类中的静态数据成员,维护对象之间的共享信息。
5.4 类的友元
友元关系提供了不同类或对象的成员函数之间、类的成员函数与一般对象之间进行数据共享的机制。通过友元关系,一个普通函数或者类的成员函数可以访问封装于另外一个类中的数据。
在一个类中,可以利用关键字friend将其他函数或类声明为其友元。若友元是一般函数或类的成员函数,则称为友元函数;若友元是一个类,则称为友元类,友元类的所有成员函数都自动成为友元函数。
友元关系是单向的,不能传递,不能继承。
5.4.1 友元函数
友元函数:在类中用关键字friend修饰的非成员函数。
友元函数可以是一个普通的函数,也可以是其他类的成员函数。虽然友元函数不是本类的成员函数,但是在友元函数的函数体中可以通过对象名访问类的私有和保护成员。
#include<iostream>
#include<cmath>
using namespace std;
class Point{
public:
Point(int x=0,int y=0):x(x),y(y){}
int getX(){return x;}
int getY(){return y;}
friend float dist(Point &p1, Point &p2); //普通的友元函数的原型声明
private:
int x, y;
};
float dist(Point &p1, Point &p2){
double x=p1.x-p2.x; //在友元函数中可以直接访问Point类中的私有数据成员x和y
double y=p1.y-p2.y;
return static_cast<float>(sqrt(x*x+y*y)); //强制类型转换为float类型
}
int main(){
Point myp1(1, 1), myp2(4, 5); //定义Point类的对象
cout<<"The distance is: ";
cout<<dist(myp1, myp2)<<endl; //计算两点间的距离
return 0;
}
友元函数不仅可以是一个普通的函数,也可以是另外一个类的成员函数。友元成员函数的使用和普通的友元函数的使用基本相同,只是要通过相应的类或对象来访问。
5.4.2 友元类
一个类的友元类中的所有成员都是这个类的友元。
若A类为B类的友元类,则A类的所有成员函数都是B类的友元函数,都可以访问B类的私有和保护成员。
但友元关系是单向的,B类不是A类的友元类。
class A{
public:
void display(){cout<<x<<endl;}
int getX(){return x;}
friend class B; //B类是A类的友元类
//其他成员略
private:
int x;
};
class B{
public:
void set(int i);
void display();
private:
A a; //A类的对象a
};
void B::set(int i){
a.x=i; //由于B类是A类的友元类,所以B类的成员函数set中可以访问A类对象a的私有成员x
}
注意:
(1)友元关系是单向的。如果B类是A类的友元,那么B类的成员函数就可以访问A类的私有、保护数据,但A类的成员函数却不能访问B类的私有、保护数据。
(2)友元关系不能传递。**B类是A类的友元,C类是B类的友元,但除非声明,否则C类和A类间没有友元关系。
(3)友元关系不能继承。B类是A类的友元,但B类的派生类不会自动成为A类的友元。
5.5 共享数据的保护
5.5.1 常对象
常对象:用const关键字修饰的对象。
常对象的数据成员值在对象的整个生存期内不能被改变。也就是说,常对象必须进行初始化,而且不能被更新。
初始化:在定义一个变量或常量时为它指定初值叫做初始化。
赋值:在定义一个变量或常量以后使用赋值运算符修改它的值叫做赋值。
声明常对象的语法形式:
const 类型说明符 对象名; //声明常对象
细节:在声明常对象时,把const关键字放在类型名后面也是允许的。比如const A a(3, 4);
和A const a(3, 4);
是等价的。
5.5.2 用const修饰的类成员
- 常成员函数
常成员函数:用const关键字修饰的成员函数。
常成员函数的声明格式:
类型说明符 函数名(参数表) const; //声明常成员函数
常成员函数的定义部分也要带const关键字,因为const是函数类型的一个组成部分。
如果将一个对象声明为常对象,则通过该常对象只能调用它的常成员函数,而不能调用其他成员函数。这是C++从语法机制上对常对象的保护,也是常对象唯一的对外接口方式。
无论是否通过常对象调用常成员函数,在常成员函数调用期间,目的对象都被视为常对象,因此常成员函数不能更新目的对象的数据成员,也不能针对目的对象调用该类中没有用const修饰的成员函数,这样保证了在常成员函数中不会更改目的对象的数据成员的值。
const关键字可以用于对重载函数的区分。比如在类中的如下声明是有效重载:
void print();
void print() const; //对print函数的有效重载,const关键字可以用于对重载函数的区分
注意:对于无需改变对象状态的成员函数,都应当使用const。
- 常数据成员
如果在一个类中声明了常成员函数,那么任何函数中都不能对该成员赋值。构造函数对该常数据成员进行初始化,只能通过初始化列表。
#include<iostream>
using namespace std;
class A{
public:
A(int i); //构造函数
void print();
private:
const int a; //声明常数据成员a
static const int b; //声明静态常数据成员b
};
A::A(int i):a(i){} //常数据成员a的初始化,只能通过构造函数的初始化列表a(i)来获得初值
const int A::b=10; //(具有整数类型的)静态常数据成员b的类外初始化方式
void A::print(){
cout<<a<<":"<<b<<endl;
}
int main(){
A a1(100), a2(0); //建立对象a1和a2,并以100和0作为初值,分别调用构造函数,通过构造函数的初始化列表给对象的常数据成员a赋初值
a1.print(); //运行结果为100:10
a1.print(); //运行结果为0:10
return 0;
}
类成员中的静态变量和常量都应当在类定义之外加以定义。但如果类的静态常量具有整数类型或枚举类型,那么可以直接在类定义中为它指定常量值。比如上例中可以直接在类定义中写:
static const int b=10; //类的静态常量b具有整数类型,则可以直接在类定义中为它指定常量值
这时,不必在类定义之外再定义A::b
,因为编译器会将程序中对A::b
的所有引用都替换成数值10,一般无需再为A::b
分配空间。但也有例外,比如如果程序中出现了对b
取地址的情况,则必须通过专门的定义为A::b
分配空间。由于已经在类定义中为它指定了初值,不能再在类定义外为它指定初值,即使两处给出的初值相同也不行。
5.5.3 常引用
常引用:在声明时用const关键字修饰的引用。
const 类型说明符 &引用名; //声明常引用
常引用所引用的对象不能被更新。
非const的引用只能绑定到普通的对象,而不能绑定到常对象,但常引用不仅可以绑定到普通的对象,也可以绑定到常对象。
**一个常引用,无论是绑定到一个普通的对象,还是常对象,通过该常引用访问该对象时,都只能把该对象当作常对象。**这意味着,对于基本数据类型的引用,则不能为数据赋值;对于类类型的引用,则不能修改它的数据成员,也不能调用它的非const的成员函数。
对于在函数中无须改变其值的参数,不宜使用普通引用的方式传递,因为那会使得对象无法被传入,采用传值的方式或传递常引用的方式可以避免这一问题。对于大对象来说,传值耗时较多,因此传递常引用更合适。
复制构造函数的参数一般也宜采用常引用传递。
5.6 多文件结构和编译预处理命令
5.6.1 C++程序的一般组织结构
到目前为止的很多C++源程序实例的结构基本都是三部分:类的定义、类的成员的实现、主函数。在规模较大的项目中,往往需要多个源程序文件,每个源程序文件称为一个编译单元。这时,C++语法要求一个类的定义必须出现在所有使用该类的编译单元中。比较好的,也是惯用的做法是将类的定义写在头文件中,使用该类的编译单元则包含这个头文件。
通常一个项目至少划分为三个文件:类定义文件(.h文件)、类实现文件(.cpp文件)、类使用文件(.cpp主函数文件)。两个.cpp文件被分别编译生成各自的目标文件.obj,然后再与系统的运行库共同连接生成可执行文件.exe。对于更为复杂的程序,每一个类都有单独的定义和实现文件。这样的组织结构,有利于封装、调试和修改程序。
决定一个声明放在源文件中还是头文件中的一般原则:
(1)将需要分配空间的定义放在源文件中,例如函数的定义(需要为函数代码分配空间)、命名空间作用域中变量的定义(需要为变量分配空间)等.
(2)将不需要分配空间的声明放在头文件中,例如类的声明、外部函数的原型声明、外部变量的声明、基本数据类型常量的声明。内联函数比较特殊,由于它需要嵌入到每个调用它的函数之中,所以对于那些需要被多个编译单元调用的内联函数,它们的代码应该被各个编译单元可见,这些内联函数的定义应当出现在头文件中。
如果误将分配了空间的定义写入头文件中,在多个源文件包含该头文件时,会导致空间在不同的编译单元中被分配了多次,从而在连接时引发错误。
include指令的两种书写方式:
#include <文件名>
表示按照标准方式搜索要嵌入的文件,该文件位于编译环境的include子目录下,一般要嵌入系统提供的标准文件时采用这样的方式。
#include "文件名"
表示首先在当前目录下搜索要嵌入的文件,如果没有,再按照标准方式搜索,对用户自己编写的文件一般采用这种方式。
5.6.2 外部变量与外部函数
5.6.2.1 外部变量
外部变量:如果一个变量除了在定义它的源文件中可以使用外,还能被其他文件使用,那么就称这个变量是外部变量。
外部变量是可以为多个源文件所共享的全局变量。
命名空间作用域中定义的变量,默认情况下都是外部变量,但在其他文件中如果需要使用这一变量,需要用extern关键字加以声明。
//源文件1
int i=3; //在源文件1中定义变量i(定义性声明)
void next(); //函数原型声明
int main(){
i++;
next();
return 0;
}
void next(){ //函数实现
i++;
other();
}
//源文件2
extern int i; //利用extern关键字,(引用性)声明一个在其他文件中定义的外部变量i
void other(){
i++;
}
对外部变量的声明,可以是定义性声明,即在声明的同时定义(分配内存、初始化),也可以是引用性声明(引用在别处定义的变量)。
在命名空间作用域中,不用extern关键字声明的变量,都是定义性声明;用extern关键字声明的变量,如果同时指定了初值,则是定义性声明,如果没有同时指定初值,则是引用性声明。
外部变量可以有多处声明,但是对变量的定义性声明只能是唯一的。
5.6.2.2 外部函数
在所有类之外声明的函数(即非成员函数),都具有命名空间作用域,如果没有特殊说明,这样的函数都可以在不同的编译单元中被调用,只要在调用之前进行引用性声明(即声明函数原型)即可,可以加extern关键字修饰,但其效果与不加extern关键字修饰的效果是一样的。
5.6.2.3 将变量和函数限制在编译单元内
命名空间作用域中声明的变量和函数,在默认情况下都可以被其他编译单元访问。但为了安全性考虑,或是为了避免重名冲突,有时不希望一个源文件中定义的命名空间作用域的变量和函数被其他源文件引用。
可以将不希望被其他编译单元引用的变量和函数放在匿名的命名空间中:
namespace{ //将不希望被其他编译单元引用的变量和函数放在匿名命名空间中
int n;
void f(){
n++;
}
}
5.6.3 标准C++库
在C语言中,系统函数、系统的外部变量和一些宏定义都放置在运行库(run-time library)中。
C++库中,除了继续保留大部分C语言系统函数外,还加入了预定义的模板和类。
标准C++类与组件在逻辑上分为以下六种类型:
(1)输入输出类;
(2)容器类与ADT(抽象数据类型);
(3)存储管理类;
(4)算法;
(5)错误处理;
(6)运行环境支持。
使用标准C++库时,需要用以下语句来将指定命名空间中的名称引入到当前作用域中:
using namespace std;
如果不使用上述方法,就需要在使用std命名空间中的标识符是冠以命名空间名std::。
using namespace语句不宜放在头文件中,因为这会使一个命名空间不被察觉地对一个源文件开放。
5.6.4 编译预处理
在编译器对源程序进行编译之前,首先需要由预处理器对程序文本进行预处理。
预处理器提供了一组编译预处理指令和预处理操作符。
预处理指令不是C++语言的一部分,它只是用来扩充C++程序设计的环境。
所有的预处理指令在程序中都以"#"引导,每一条预处理指令单独占用一行,结尾不加分号。
5.6.4.1 #include指令
#include指令,也称文件包含指令,作用是将另一个源文件嵌入到当前源文件中的指定位置处。
通常用#include指令来嵌入头文件。
#include指令(文件包含指令)的两种格式:
(1)#include <文件名>
按照标准方式搜索,文件位于系统目录的include子目录下。
(2)#include "文件名"
首先在当前目录中搜索,若没有,再按标准方式搜索。
#include指令可以嵌套使用。
5.6.4.2 #define和#undef指令
预处理器最初为C语言而设计,但#define能完成的一些功能,能够被C++引入的一些语言特性很好地替代。
在C语言中,可以用#define来定义符号常量,如#define PI 3.14
;而在C++中,更好的办法是在类型说明语句中用const关键字进行修饰。
在C语言中,可以用#define来定义带参数宏,以实现简单的函数计算,提高程序运行效率;而在C++中,这一功能被内联函数取代。
在C语言中,可以用#define来定义空符号,如#define MYHEAD_H
,定义它的目的,仅仅表示“MYHEAD_H已经定义过“这样一种状态。将该空符号配合条件编译指令一起使用,可以起到一些特殊效果,这时C++程序中#define的最常用之处。
#undef的作用:删除由#define定义的宏,使其不起作用。
5.6.3.3 条件编译指令
**条件编译指令的作用:限定程序中的某些内容要在满足一定条件的情况下才参与编译。**因此,使用条件编译指令可以使同一个源程序在不同的编译条件下产生不同的目标代码。例如,可以在调试程序时增加一些调试语句,以达到跟踪的目的,并利用条件编译指令,限定当程序调好后,重新编译时,使调试语句不参与编译。
常用的条件编译语句有五种形式:
(1)形式一:
#if 常量表达式
程序段 //当"常量表达式"非零时,编译本程序段
#endif
(2)形式二:
#if 常量表达式
程序段1 //当"常量表达式"非零时,编译本程序段
#else
程序段2 //当"常量表达式"为零时,编译本程序段
#endif
(3)形式三:
#if 常量表达式1
程序段1 //当"常量表达式1"非零时,编译本程序段
#elif 常量表达式2
程序段2 //当"常量表达式1"为零,"常量表达式2"非零时,编译本程序段
...
#endif 常量表达式n
程序段n //当"常量表达式1至n-1"均为零,"常量表达式n"非零时,编译本段
#elif
程序段n+1 //其他情况下,编译本程序段
#endif
(4)形式四:
#ifdef 标识符
程序段1 //若“标识符”经#define定义过,且未经#undef删除,则编译本段
#else
程序段2 //否则编译本程序段
#endif
如果没有程序段2
,则#else
部分可以省略,如下:
#ifdef 标识符
程序段1 //若“标识符”经#define定义过,且未经#undef删除,则编译本段
#endif
(5)形式五:
#ifndef 标识符
程序段1 //若“标识符”未被#define定义过,则编译本程序段
#else
程序段2 //否则编译本程序段
#endif
如果没有程序段2
,则#else
部分可以省略,如下:
#ifndef 标识符
程序段1 //若“标识符”未被#define定义过,则编译本程序段
#endif
5.6.3.4 defined操作符
defined是一个预处理操作符,不是指令,因此不要以#开头。
defined操作符的使用形式:
defined(标识符) //defined操作符的使用形式
若“标识符
”在此前经#define
定义过,并且未被#undef
删除,则上述表达式为非0。否则上述表达式为0。
下面两种写法等价:
#ifndef MYHEAD_H
#define MYHEAD_H
...
#endif
//等价于
#if!defined(MYHEAD_H)
#define MYHEAD_H
...
#endif
**文件包含指令(include指令)可以嵌套使用,而在设计程序时要避免多次重复包含同一个头文件,否则会引起变量及类的重复定义。**例如:
//file1.h
#include "head.h"
...
//file2.h
#include "head.h"
...
//head.h
...
class Point{
...
};
...
//main.cpp
#include "file1.h"
#include "file2.h"
int main(){
...
}
在上例的main函数中,由于#include指令(在file1.h
和file2.h
中)的嵌套使用,使得头文件head.h
被包含了两次,于是编译系统会指出错误:类Point被重复定义。如何避免这种情况?可以在可能被重复包含的头文件中使用条件编译指令,具体做法是用一个唯一的标识符来标记某文件是否已参与过编译,如果已参加过编译,则说明该程序段是被重复包含的,编译时忽略重复部分。将上例中的文件head.h
改写为:
//head.h
#ifndef HEAD_H
#define HEAD_H
...
class Point{
...
};
...
#endif
在改写后的head.h
头文件中,首先判断标识符HEAD_H
是否被定义过。若未定义过,说明此头文件尚未参与过编译,于是编译接下来的程序段,并对标识符HEAD_H
进行宏定义,标记此文件已参与过编译。若标识符HEAD_H
被定义过,说明此头文件参与过编译,于是编译器忽略接下来的程序段。这样便不会造成对Point类的重复定义。