类的进化史
类无疑是C++最重要的概念之一,是从C的面向过程到C++面向对象的重要转变的基础,下面我们就来谈谈C++中的类是怎样演变的。
先来看看C中的结构体(struct),结构体是一组属性的集合体,举一个简单的栗子,struct point{float i; float j;};这样就把平面内一个点的两个属性捆绑到了一起,这样的好处是显而易见的,试想一下,如果没有结构体,每次定义一个点都需要定义两个变量,无论是管理还是使用起来都是非常的不方便。通过struct把一组相关联的属性捆绑在一起,无疑是十分明智的选择。
下面我们再来看看C库的栗子,C库便是把一系列相关联的数据和操作放在了一起,为了方便之后调用,增加代码复用性,提高开发效率而存在的。这里有一个简单的C库栗子:
1 // CLib.h 2 // Header file fr a C-like library 3 // An array-like entity created at runtime 4 typedef struct CStashTag{ 5 int size; 6 int quantity; 7 int next; 8 unsigned char* storage; 9 }CStash; 10 11 void initialize(CStash* s, int size); 12 void cleanup(CStash* s); 13 int add(CStash* s, const void* element); 14 void* fetch(CStash* s, int index); 15 int count(CStash* s); 16 void inflate(CStash* s, int increase);
1 // Clib.cpp {0} 2 // Implementation of example C-like library 3 // Declare structure and functions: 4 #include "Clib.h" 5 #include <iostream> 6 #include <cassert> 7 using namespace std; 8 9 const int increment = 100; 10 11 void initialize(CStash* s, int sz){ 12 s->size = sz; 13 s->quantity = 0; 14 s->storage = 0; 15 s->next = 0; 16 } 17 18 int add(CStash* s, const void* element){ 19 if(s->next >= s->quantity) 20 inflate(s, increment); 21 int startBytes = s->next * s->size; 22 unsigned char* e = (unsigned char*)element; 23 for(int i = 0; i < s->size; i++) 24 s->storage[startBytes + i] = e[i]; 25 s->next++; 26 return(s->next -1); 27 } 28 29 void* fetch(CStash* s, int index){ 30 assert(0 <= index); 31 if(index >= s->next) 32 return 0; 33 return &(s->storage[index * s->size]); 34 } 35 36 int count(CStash* s){ 37 return s->next ; 38 } 39 40 void inflate(CStash* s, int increase){ 41 assert(increase > 0); 42 int newQuantity = s->quantity + increase; 43 int newBytes = newQuantity * s->size; 44 int oldBytes = s->quantity * s->size; 45 unsigned char* b = new unsigned char[newBytes]; 46 for(int i = 0; i < oldBytes; i++) 47 b[i] = s->storage[i]; 48 delete [](s->storage); 49 s->storage = b; 50 s->quantity = newQuantity; 51 } 52 53 void cleanup(CStash* s){ 54 if(s->storage != 0){ 55 cout << "freeing storage" << endl; 56 delete []s->storage; 57 } 58 }
这只是为了方便演示所创建的简单代码,暂时先不考虑其他要素(如:使用#ifndef来防止重复包含)。
这个库中提供了一个结构体以及CStash,以及围绕这个结构体的一系列函数,通过使用库,可以比较方便的复用这段代码,而不必在每次开发时重写一遍。下面时使用这个库的一个栗子:
1 // CLibTest.cpp 2 // CLib 3 // Test the C-like library 4 #include "CLib.h" 5 #include <fstream> 6 #include <iostream> 7 #include <string> 8 #include <cassert> 9 using namespace std; 10 11 int main(){ 12 CStash intStash, stringStash; 13 int i; 14 char* cp; 15 ifstream in; 16 string line; 17 const int bufsize = 80; 18 initialize(&intStash, sizeof(int)); 19 for(i = 0; i < 100; i++) 20 add(&intStash, &i); 21 for(i = 0; i < count(&intStash); i++) 22 cout << "fetch(&intStash, " << i << ") = " 23 << *(int*)fetch(&intStash, i) 24 << endl; 25 initialize(&stringStash, sizeof(char)* bufsize); 26 in.open("CLib.cpp"); 27 assert(in); 28 while(getline(in, line)) 29 add(&stringStash, line.c_str()); 30 i = 0; 31 while((cp = (char*)fetch(&stringStash, i++)) != 0) 32 cout << "fetch(&stringStash, " << i << ") = " 33 << cp << endl; 34 cleanup(&intStash); 35 cleanup(&stringStash); 36 }
在测试文档中,只需要引入这个库,然后便能像其它类型那样定义CStash类,并用库中的函数对它进行操作。看起来似乎很棒,但仔细想想便会发现诸多不方便的地方。
首先,每个函数都需要向其中传入一个结构体的地址,这看起来有些多余。其次,如果引入两个库,它们都包含相同的函数名称,则会出现错误,因为它们共用同一个的命名空间,链接器会在主表查询,当发现两个定义不同的同名函数,必然会报错。其实这也是C库的一个最麻烦的地方,那就是命名冲突。因为像initialize和cleanup这样通俗的名字也许很多库的开发者都会觉得是个不错的选择而使用它们,因此,为了避免这样的冲突,库中的函数往往都需要带上特殊的前缀。这时,我们这个库的函数可能会变成:CStash_initialize()、CStash_cleanup()。这样便能一定程度上减少这种冲突,但副作用便是使函数名看起来十分笨拙,而且越是复杂的函数,这样做越会使客户程序员混淆其真正意图。
怎样更好的避免这样的冲突?想想看,结构体中的变量标志与外部全局变量的标志是不会发生冲突的,即使它们看起来完全一样,那为何不把这种优点也发挥到函数上呢?嗯,把函数也一起放进结构体,这或许是个不错的选择。下面是把上面C库代码改写成使用这种结构后的栗子:
1 // CppLib.h 2 // C-like library converted to C++ 3 4 struct Stash{ 5 int size; 6 int quantity; 7 int next; 8 unsigned char* storage; 9 //Functions 10 void initialize(int size); 11 void cleanup(); 12 int add(const void* element); 13 void* fetch(int index); 14 int count(); 15 void inflate(int increase); 16 }
1 // C04:Cpplib.cpp 2 // C library converted to C++ 3 // Declare structure and functions: 4 #include "Cpplib.h" 5 #include <iostream> 6 #include <cassert> 7 using namespace std; 8 9 const int increment = 100; 10 11 void Stash::initialize(int sz){ 12 size = sz; 13 quantity = 0; 14 storage = 0; 15 next = 0; 16 } 17 18 int Stash::add(const void* element){ 19 if(next >= quantity) 20 inflate(increment); 21 int startBytes = next * size; 22 unsigned char* e = (unsigned char*)element; 23 for(int i = 0; i < size; i++) 24 storage[startBytes + i] = e[i]; 25 next++; 26 return(next -1); 27 } 28 29 void* Stash::fetch(int index){ 30 assert(0 <= index); 31 if(index >= next) 32 return 0; 33 return &(storage[index * size]); 34 } 35 36 int Stash::count(){ 37 return next ; 38 } 39 40 void Stash::inflate(int increase){ 41 assert(increase > 0); 42 int newQuantity = quantity + increase; 43 int newBytes = newQuantity * size; 44 int oldBytes = quantity * size; 45 unsigned char* b = new unsigned char[newBytes]; 46 for(int i = 0; i < oldBytes; i++) 47 b[i] = storage[i]; 48 delete [](storage); 49 storage = b; 50 quantity = newQuantity; 51 } 52 53 void Stash::cleanup(){ 54 if(storage != 0){ 55 cout << "freeing storage" << endl; 56 delete []storage; 57 } 58 }
这已经开始有一点点类的味道了,因为结构体中不仅包含了数据,还包含了行为,这样就已经具备了对象的两大特征,已经从C向C++迈出了第一步。在定义成员函数时,与C语言中有许多不同之处,比如使用作用域解析符::来表明是定义在哪个结构下的成员函数,在函数内部不再需要使用结构指针而是直接通过使用成员名来对成员进行操作。
下面是使用这个库的测试代码:
1 // CppLibTest.cpp 2 // CppLib 3 // Test the C++ library 4 #include "CppLib.h" 5 #include <fstream> 6 #include <iostream> 7 #include <string> 8 #include <cassert> 9 using namespace std; 10 11 int main(){ 12 Stash intStash, stringStash; 13 int i; 14 char* cp; 15 ifstream in; 16 string line; 17 const int bufsize = 80; 18 intStash.initialize(sizeof(int)); 19 for(i = 0; i < 100; i++) 20 intStash.add(&i); 21 for(i = 0; i < intStash.count(); i++) 22 cout << "intStash.fetch(&intStash, " << i << ") = " 23 << *(int*)intStash.fetch(i) 24 << endl; 25 stringStash.initialize(sizeof(char)* bufsize); 26 in.open("CLib.cpp"); 27 assert(in); 28 while(getline(in, line)) 29 stringStash.add(line.c_str()); 30 i = 0; 31 while((cp = (char*)stringStash.fetch(i++)) != 0) 32 cout << "stringStash.fetch(&stringStash, " << i << ") = " 33 << cp << endl; 34 intStash.cleanup(); 35 stringStash.cleanup(); 36 }
从这里我们可以发现几个很明显的改变。第一,不需要再向每个函数传入一个结构体地址,事实上,编译器悄悄为我们做了这事。第二,函数都是通过类型名后加取成员符再加函数名来调用,可以用Stash来定义多个变量,而它们内部的函数实际上是一样的,只是一份代码的多个拷贝。
现在我们似乎已经很好的解决了命名空间这个问题,事实上,C++使用多个命名空间,这也是其具备很好扩展性的基础,想必大家对与std::不会感到陌生,这是标准C++库的命名空间,在不同命名空间使用相同的标识是不会发生冲突的。使用命名空间常用使用指令,如using namespace std,这样就可以直接使用cout 而不需要在前面再加上std::,因为使用指令已经告诉链接器在哪个命名空间查找。
那如果我们在结构体中定义的成员标志跟全局变量一样,而同时需要对它们操作该怎么办呢,下面时一个小栗子:
1 // Scoperes.cpp 2 // Global scope resolution 3 int a; 4 void f(){} 5 6 struct S { 7 int a; 8 void f(); 9 }; 10 11 void S::f(){ 12 ::f();//use the global func 13 ::a++;//select the global a 14 a--; //the a at the struct 15 }
下面再说说头文件。关于头文件,在C语言学习的时候也许并不是十分明显和重要,因为它是可选的,而在C++中,头文件几乎是强制的。这样有很多的好处,第一是把所有函数信息,结构体信息放到头文件后方便链接器查找,第二是方便了客户程序员阅读和使用,在一个稍大一点的项目中,也许会多次使用到同一个头文件,这样就会带来多次声明的问题。例如,如果每个结构文件都包含了iostream这个库,那么就会面临多次重申输入输出流的危险。编译器认为重申明结构是一个错误,因为它还允许对不同的结构使用相同的名字,为了防止重复声明,我们需要对头文件做一点点处理。
这时需要用到预处理指令,#define,#undef,#ifdef,#ifndef,#endif。其含义通过名字也能大致猜测得到,下面是一段改进后的头文件代码:
1 #ifndef HEADER_FLAG 2 #define HEADER_FLAG 3 // type declaration 4 #endif
这样便能有效的防止多次声明的问题出现,因为只会在第一次声明时定义标志HEADER_FLAG,之后再引用这个头文件时,发现已经定义了这个标志,后面的代码将被忽略。
需要注意的一点是,不要在头文件中放使用指令,using namespace XXX,这样会破坏对指定命名空间的保护,因为头文件的引用通常在文件的最开始处,这样在整个文件中使用指令都会有效,这就意味着命名空间保护在使用该头文件的任何文件中消失。
再回到我们的结构体,现在结构体中已经有了数据和函数,但这还不够,因为有时候,我们并不希望其它客户程序员能够自由操作所有内部变量,因为这样可能更容易导致一些错误。这时候就需要用到C++中的访问控制符,public,private,protected。
使用public声明就意味着在其后的声明是可以被所有人访问的,public成员就如同一般的struct成员一样(可以理解为struct默认为public访问方式),而private则意味着,除了该类型的创建者和内部的成员函数之外,任何人都不能访问。这样在设计者和客户程序员中间建立了一道墙,只给客户程序员开放必要的接口。protected则是与private基本相似,只有一点不同,那就是继承的结构体可以访问protected成员。
这样做的好处一是能更好的确保数据安全,另一方面,也更好的将实现和接口分离开来,只要接口没有改变,内部实现不管怎样变,都不需要客户程序员再次改写代码,只需要重新编译一次就行了。有时还有这样的需求,想要其它不属于当前结构的一个成员函数访问当前结构中的数据,那该怎么办呢?这时候需要用到“友元”的概念。什么是“友元”呢,就是使用friend标志来声明一个外部的函数,以告知编译器“这是我这个类的朋友,他可以访问我所有成员的权限。”,这样这个友元便能访问其所有的成员和成员函数。下面是一个小栗子:
1 // Friend.cpp 2 // Friend allows special access 3 struct X; 4 5 struct Y { 6 void f(X*); 7 }; 8 9 struct X { 10 private: 11 int i; 12 public: 13 void initialize(); 14 friend void g(X*, int); 15 friend void Y::f(X*); 16 friend struct Z; 17 friend void h(); 18 }; 19 20 void X::initialize(){ 21 i = 0; 22 } 23 24 void g(X* x, int i){ 25 x->i = i; 26 } 27 28 void Y::f(X* x){ 29 x->i = 47; 30 } 31 32 struct Z { 33 private: 34 int j; 35 public: 36 void initialize(); 37 void g(X* x); 38 }; 39 void Z::initialize(){ 40 j = 99; 41 } 42 43 void Z::g(X* x){ 44 x->i += j; 45 } 46 47 void h(){ 48 X x; 49 x.i = 100; 50 } 51 52 int main(){ 53 X x; 54 Z z; 55 z.g(&x); 56 }
struct加上函数加上访问控制加上友元,便已经演变出了类的模型。在最初的面向对象语言中,关键字class被用来描述一个新的数据类型,显然也启发了Stroustrup在C++中用同样的关键字,来强调这是整个语言的关键所在,新的数据类型并不是只在C的struct中加上函数,还包括很多其他的特性,这当然需要一个新的关键字。
有着相似定义的class和struct的唯一区别在于,class默认访问方式为private,而struct的默认访问方式为public。
最后,还有一点点补充说明的情况。当我们想隐藏更多关于private成员的信息时(比如我们在开发一个加密库,为了防止有人不择手段使用各种指针来访问私有成员),我们可以更好的封装,将内部结构体也封装起来,仅提供一个使用的指针,也可以叫住句柄。下面是一个小栗子:
1 // Handle.h 2 // Handle classes 3 #ifndef HANDLE_H 4 #define HANDLE_H 5 6 class Handle { 7 struct Cheshire; 8 Cheshire* smile; 9 public: 10 void initialize(); 11 void cleanup(); 12 int read(); 13 void change(int); 14 }; 15 16 #endif // HANDLE_H
1 // Handle.cpp 2 // Handle implementation 3 #include "Handle.h" 4 5 // Define Handle's implementation 6 struct Handle::Cheshire{ 7 int i; 8 }; 9 10 void Handle::initialize(){ 11 smile = new Cheshire; 12 smile->i = 0; 13 } 14 15 void Handle::cleanup(){ 16 delete smile; 17 } 18 19 int Handle::read(){ 20 return smile->i; 21 } 22 23 void Handle::change(int x){ 24 smile->i = x; 25 }
在头文件中仅包含公共接口和一个单指针,该指针指向一个没有完全定义的类,而将类的定义放到实现文件中,这样就能将结构更好的隐藏起来。这样做还有另一个好处,那便是减少重复编译,试想一下,如果开发一个大型项目,难免会有内部实现的改动,而这样就会导致所有引用该头文件的文件都需要重新编译,十分不方便。使用了句柄这种方式后,只要定义没有改变,结构体无论怎样改变,只需要重新编译定义文件即可,大大的节省了时间。
至此,一个类的成长历程已经说了一大块了,之后的继承多态和重载,以后的文章再做说明。