风雨飘摇C++

诞生之初,C++语言不仅通盘继承了C的语法,而且在源代码层级保留了对C的兼容性。当然,老的C程序员们比较开心,对于他们来说,至少看上去C++不是那么地难上手,更何况还能原封不动地重用现有的C库。所以,C++的推广过程似乎蛮顺利,很快,声称转向C++的程序员开始有意地把类加到代码里,然而,C++从来没有实质性地深入人心,至少从C++设计者的角度讲是这样的。良好的初衷有时可导致难以弥补的后果:即C++始终无法作为一种全新的语言被认可,结果某种程度上沦为C的扩展。不信瞧瞧代码里的那些类,好多连个拷贝构造函数都没有,他们不过把它看成是种更好的C语言而已,这种先入为主的羁绊严重地阻碍着C++的特性得到充分发挥,以至于令其逐渐式微于面向对象语言的丛林之中,艰难地挣扎。

一段代码:

#define MAX_NUMBER_KEYS 100
class KeyManager
{
public:
    KeyManager();
    virtual ~KeyManager();
 
    unsigned GetNumberOfKeys();
    int GetKeys(struct Key* keys, int number);
 
private:
    struct Key* keys[MAX_NUMBER_KEYS];
    unsigned    nKeys;     
};

如果遵循C++的设计理念,代码看起来应该是这样的:

#include <vector>
 
class Key
{
// ...
};
 
class KeyManager
{
public:
    class Exception
    {
    // ...
    };
 
    KeyManager();
    virtual ~KeyManager();
 
    Key& GetKey(unsigned index) throw(Exception);
    const Key& GetKey(unsigned index) const throw(Exception);
 
    unsigned NumberOfKeys() const;
 
private:
    std::vector<Key> keys;    
};

但是,以上修改极易遭到非难:许多公司的编码规范中明确禁止使用异常,至今国内许多程序员(尤其是老程序员)对STL的使用还心存疑虑,刚转过来的新手则会质疑代码的性能…… 总之,历史的包袱致使许多C++的特性没有得到广泛的认可,反过来又导致这些特性的底层支持(如编译器)得不到完善,从而将这一语言推入恶性循环,不仅难以实现自我进化,还要面临衰退的危险。 有时在想,当初若是将之命名为D,其演进历程或有大不同也难说。

言归正传。

神说:「要有光!」光就有了,从对象的构建说起

窃以为,理解C++,当从理解对象的构建开始。

首先需要明白的是:C++对象的构建受到类定义的严格约束,也就是说,以任何方式新建一个对象都必须明确指定对象所属的类,从而强制对象的建构过程依循一个既定的原型,且构建成功之后对象的结构基本不能够发生改变。虽然这一准则看上去理所当然,但并非所有支持对象的语言都会这样做,如Python和JavaScript均支持向已经存在的对象添加新的属性和方法,JavaScript甚至没有类的概念。对于C++来说,这一方式牺牲了一定的灵活性,但可以将潜在的错误限制在编译阶段,同时鼓励程序员使用其他机制——如工厂和聚合——来实现对象创建方式的扩展。

C++原生操作符new创建的对象类型是确定的:

TAny * o = new TAny;

而工厂方法Create创建的对象类型是不确定的:

TAny* CreateAny()
{
    return new TAny
}
 
TAny* o = CreateAny();

其次,在C++中,继承和组合要求对象的构建过程以层次化地形式展开:即从向父类上溯开始,逐层初始化,然后依次构建各成员对象,最后才落入到自身的构造函数中。而正是这个构造函数决定着一个类的特定的初始化行为,从而确保新建对象的状态是可知的。 通常,为了适应各种场合中的对象构建,一个类需要定义不同形态的构造函数。如果没有定义任何构造函数,对象的构建也可以隐式地完成,因为编译器至少会生成一个缺省的构造函数和一个缺省的拷贝构造函数,只是此时新建对象的状态变得不可知,增加了程序的风险。

最典型的对象构建方式是使用new操作符,如:

class Object
{
public:
    Object()
    {
        std::cout << "Now initializing Object" << std::endl;
    }    
};
 
int main()
{
    Object *o = new Object;
    return 0;
}
Now initializing Object

参数化构造函数则为初始化过程提供了更多的可能性:

class Object
{
public:
    Object()
    {
        std::cout << "Now initializing Object" << std::endl;
    }   
 
    Object(int id)
    {
        std::cout << "Now initializing Object using id " << id << std::endl;
    }
 
    Object(int id, std::string& name)
    {
        std::cout << "Now initializing Object using id " << id << " and name " << name << std::endl;
    }  
 
};
 
int main()
{
    std::string name("Dalek");
    int id = 0;
 
    Object *o = new Object;
    Object *o1 = new Object(id);
    Object *dalek = new Object(id, name);
 
    return 0;
}
Now initializing Object
Now initializing Object using id 0
Now initializing Object using id 0 and name Dalek

这些可能性取决于严格的类型匹配,譬如以下代码就会触发编译错误,因为无法在Object类的定义中找到只接受一个std::string或std::string&参数的构造函数。

Object *oo = new Object(name)

当然,参数化构造函数的引入也带来了一些的问题,例如某场合下的对象构建不允许传参。

如果删除Object定义中的无参构造函数,以下代码是无法编译的,因为只要定义了一个显式构造函数,不管它的形式怎样,编译器不再会生成缺省构造函数。

Object *os = new Object[100];

因此,为了使对象能够适应各种构建场合,必须显式定义一个缺省的构造函数,它可以是一个无参的构造函数,也可以是一个限定了缺省参数值的构造函数,如:

Object(int id = 0) {}

除了使用new,局部对象和静态对象是另外两种常见的显式创建对象的方式,它们均会触发相应的构造函数,例如以上代码中对局部变量name的定义就会触发构造函数1)

std::basic_string<char>::basic_string(const charT* s, const allocator_type& alloc = allocator_type())

值得注意的是,静态对象的创建发生在main函数执行之前,因此不可以为构造函数的执行设定任何先决条件。

以下代码在构造函数中调用了Log类方法,由于Log类的初始化在程序在main函数一开始就执行过,所以看上去没有什么问题。但如果Object被定义为静态对象,其构造函数的调用会先于main发生,Append方法则会因为Log没有初始化而失败

class Object
{
public:
    Object() { Log::Append("Now initializing Object"); }
};
 
int main()
{
    Log::Init();
 
    return 0;
}

此外,在某些情形中,对象是被隐式创建的,如函数的值传递和函数的值返回,它们都会导致临时的局部对象被创建,因此也会触发构造函数的调用,不同之处在于此类新对象的创建是一次基于现有对象的拷贝,所调用的则是一个以现有对象引用为参数的特定的构造函数,亦即拷贝构造函数:

Object(Object& o) { std::cout << "Initializing with a copy" << std::cout; }

定义一个clone函数并在main中调用:

Object clone(Object other)
{
    return other;
}
 
int main()
{
    std::string name("Dalek");
    int id = 0;
    Object o(id, name);
 
    clone(o);
 
    return 0;
}
Now initializing Object using id 0 and name Dalek
Initializing with a copy
Initializing with a copy

可以观察到,拷贝构造函数被调用了两次,第一次是由main中的局部对象o创建函数的参数对象other,第二次则是由other创建返回到main的临时对象。

还有一种值得注意的情形是使用赋值符创建对象,注意此时被激活的是构造函数而非赋值操作,因为赋值操作要求对象已经存在,比较以下两段代码:

int main()
{
    Object o1;
    object o2 = o1;
 
    return 0
}
Now initializing Object
Initializing with a copy
int main()
{
    Object o1;
    object o2;
    o2 = o1;
 
    return 0
}
Now initializing Object
Now initializing Object

如果没有显式定义拷贝构造函数,程序会使用编译器生成的缺省拷贝构造函数,但最好避免这样的情形,这样或许不会让程序发生异常,但让对象处于已知状态的目标并没有实现。同样,拷贝构造函数也遵循向父类上溯的初始化过程,如果希望父类得到拷贝,必须显式调用父类的拷贝构造函数,否则,父类的初始化会由缺省的构造函数实现,这往往会有悖初衷

class Object
{
public:
    Object() :
    _id(0), _name("anonymous")
    {}
 
    Object(int id, const std::string& name):
    _id(id), _name(name)
    {}
 
    Object(const Object& other)
    _id(other.id), _name(other.name)
    {}
private:
    std::string _name;
    int _id
};
 
class Key : public Object
{
public:
    Key(int id, const std::string& name) 
    : Object(id, name) /*< construct base class with given parameters */
    {}
    Key(const Key& other) 
    : Object(other) /*< construct base class with copy */
    {}
};

一个好的构造函数要尽可能地使用初始化列表。

以下代码构建一个Key对象,其成员_data和属于父类的成员_name和_id均会被初始化,按照顺序,首先调用的是Object的构造函数,然后是std::vector<char>的构造函数,最后是Key自己的构造函数——这里什么都没有做。需要稍微注意的是,成员的初始化顺序遵循定义时的顺序,而非初始化列表的顺序,如果二者不一致编译器会发出警告。

class Object
{
public:
    Object() :
    _id(0), _name("anonymous")
    {}
 
    Object(int id, const std::string& name):
    _id(id), _name(name)
    {}
private:
    std::string _name;
    int _id
};
 
class Key : public Object
{
public:
    Key():
    _data(100, 0)
    {}
 
    Key(int id, const std::string& name):
    Object(id, name),
    _data(100, 0)
    {}
 
private:
    std::vector<char> _data;
};

至于成员初始化之外的任何其他行为,最好将之封装到专门的方法中,由构建好的对象显式调用。遵循如是原则便不需要再去浪费时间讨论虚函数在构造函数中的行为了。

以下代码的初衷是希望通过调用_Initialize方法实现对象构建过程的多态,常见的批评都会指出Action对象的构建之处还未实现虚化,因此_Initialize无法达到期望的效果。而实际上代码的初衷本身就是基于一种对C++的误解,正如前文所提到的,C++对象的创建的前提就是已经指定了一个明确的类;而且,任何同初始化自身成员无关的工作理论上都不应该出现在构造函数之中。

class Action
{
public:
    Action() { _Initialize(); }
protected:    
    virtual void _Initialize() { } 
};

总之,C++对象的构造行为比较复杂,疏忽和不恰当的实现往往会诱发意想不到的结果,有不少热心的程序员亲身实验各种极端情况并分享结果,其实意义不是很大,关键还是要正确理解对象构建的过程和构造函数,从而避免极端情况的发生。

理解封装

谁动了我的成员变量?

标准模板库

食之无味,弃之可惜。

Boost

万金油还是大杂烩?

运行期类型识别

如何来拯救我的内存?

继承?还是组合

实现多态

异常的代价

第一阶段:更好的C语言

声明(declaration)

任何变量和函数在使用前必须实现声明。(定义也是一种声明方式。对于变量,使用extern关键字强调是声明不是定义;对于函数,可以不使用extern 关键字,不带函数体的函数名连同参数等被认为是函数的声明。)

命名空间(namespace)的概念

命名空间是为了解决C++中变量、函数命名的冲突而服务的,命名空间也是一种表示逻辑分组的机制。

引用类型(reference)及其初始化

引用是某个对象的别名,它类似于一个指针常量。当创建一个引用时,引用必须被初始化指向一个存在的对象。如果引用作为函数的参数,则在函数调用是进行初始化。引用类型还可以作为函数的返回值。

内联函数(inline function)

用inline关键字定义的函数叫做内联函数;内联函数可以降低函数调用带来的开销,但增加了函数的代码。需要注意的是,内联函数必须以定义的形式声明,函数体内不能有循环结构和switch语句。

函数名过载(overload)

不同的函数(参数类型、个数不同)允许采用相同的函数名,编译过程中需要根据一定的匹配准则从一系列过载函数中寻找最匹配的函数(这些函数的内部命名其实是不相同的)。注意,由于仅根据返回值不能区别两个函数,所以只有返回值不同的两个函数采用相同的函数名是不合法的。

函数的缺省参数值

函数声明中允许为一个或多个参数指定缺省的参数值,但要求所有没有缺省值的参数都放在函数参数列表的前面部分。下面一段代码中,由于无法对过载函数正确匹配而发生编译错误。

  void fun(int a, double b = 1);
  void fun(int a);
 
  int main(int argc, char* argv[])
  {
  	int x = 4;
 
  	fun(4);
 
  	return 0;
  }
 
  void fun(int a, double b)
  {
  	return;
  }
 
  void fun(int a)
  {
  	return;
  }

const关键字

const是一个类型修饰符,const修饰一个类型得到该类型的派生类型,使用该派生类型定义的变量必须被初始化,而且其值不能再改变。

  const char *p; //pointer to constant char
  char* const p; //constant pointer
  const double&r = R; //reference to constant

const关键字是左结合的,它修饰在它左边出现的类型描述符:T const,但是,当T是一个简单类型的时候,也可以写作const T,如const int x;如果函数参数为const,则函数内部不能改变其值。如果成员函数被const修饰,则该成员函数不能修改成员变量或调用另一个非const函数。

堆对象,new/delete操作符

堆对象在程序运行过程中根据需要被创建或删除。

创建堆对象:T* ptr = new T(参数列表);
创建堆对象数组:T* ptr = new T[NBR];
删除堆对象:delete ptr;
删除堆对象数组:delete [] ptr;
创建堆对象数组时,T类型必须有缺省构造函数。

异常(exception)和异常处理

库的编写者可以预料到运行期间的错误,但不知道如何处理错误;库的使用者知道如何处理错误,但无法检测错误,于是引入了异常机制。

异常的抛出:throw表达式
异常的捕获:try语句体
异常的处理:catch语句体

变量的定义

在C++中,变量的定义不再被限制到语句首。

数组的初始化

Y类型数组的初始化可能有如下形式:Y y[] = {Y(1), Y(2), Y(3)};

值返回和引用返回

值返回使用语句return expression或return variable,前者对expression求值并将该值存入一个临时变量中,后者直接将variable拷贝到临时变量,函数调用者可以访问这个临时变量。引用返回使用语句return variable,返回后不产生临时变量,函数调用者以引用的形式直接操作variable,因此,引用返回的对象不可以为局部的栈变量。

第二阶段:数据抽象——将数据结构和行为捆绑在一起

对象

一个对象具有状态(state)、行为(behavior)和标识(identity)。对象的状态包括它的属性以及这些属性的当前值;对象的行为包括可以进行的操作以及所伴随的状态变化;对象的标识用于区别其它的对象。

类用于实现用户自定义类型。类在结构上分为接口部分和内部实现部分。前者包括类的共有成员声明及规范说明,后者包括成员函数的实现代码和需要的数据结构。

类成员的访问控制描述符

public		共有成员描述符,共有成员可以在任何程序单元中被引用
private		私有成员描述符,私有成员只能在类的实现代码中被引用
protected	保护成员描述符,保护成员除了可以在类的实现代码中被引用外还可以在派生类的实现代码中被引用。
friend		友元描述符,友元不是类的成员,而是允许访问类私有成员、保护成员的其它类或函数。

(使用class关键字,其成员缺省为私有;使用struct关键字,其成员缺省为公有)

隐藏实现的不完整性及解决方案

C++中的存取控制允许将实现与接口部分分离,但实现的隐藏是不完全的:私有成员对用户是可见的。这导致两个问题:用户虽然不能访问私有成员,但可以看见它;如果实现部分的改变要求私有成员发生变化,用户部分也因之要发生变化。

解决方案:在类的定义中只提供一个指向私有成员结构的指针。

构造函数和析构函数

构造函数的作用是在对象被创建时采用特定的值构造对象,使其处于一个初始状态;析构函数完成对象被删除前的一些清理工作。构造函数和析构函数由编译器调用,不需要标识返回类型。一个对象建立时,他的所有成员对象也一同建立。因此,构造函数的执行顺序为:首先执行其父类的构造函数——如果有的话、其次执行其成员对象的构造函数、然后才执行自身构造函数;对应的析构函数的执行顺序则为:自身析构函数、成员对象的析构函数、父类的析构函数。 一个类可以有多个不同的构造函数,其中没有任何参数的构造函数成为缺省构造函数。一个类也可以不定义构造函数,这时调用系统设计的缺省构造函数。

拷贝构造函数(copy constructor):X::X(X& x)

拷贝构造函数使用一个已有对象初始化一个正在建立的同类型对象。在函数调用过程中,拷贝构造函数是实现值方式传递参数和返回用户定义对象的根本所在。如果没有定义拷贝构造函数,编译器采用缺省的原始行为:位拷贝(bitcopy)。

静态成员

  • 静态数据成员(类数据成员):静态数据成员具有静态生存周期;静态数据成员必须在程序的某处被初始化(定义);静态数据成员不是对象的成员,虽然也可以通过对象来引用;静态数据成员也有public、private、protected三种。
  • 静态成员函数(类成员函数):静态成员函数不是对象的成员,虽然也可以通过对象来调用;静态成员函数的调用也可以完全不依赖于对象(不需要this指针);静态成员函数的实现部分不能引用类的非静态数据成员。

操作符过载(overload)

可以过载的预定义操作符包括:+, -, *, /, %, ^, &, |, ~, !, =, <, >, +=, -=, *=, /=, %=, ^=, &=, |=, «, », ==, !=, ⇐, >=, &&, ||, ++, –, [], →, new, new [], delete, delete []。

两种形式:

  • 作为全局函数(类的友元),一元操作符有一个参数;二元操作符有两个参数。
  • 作为成员函数,一元操作符有一个参数(this指针),但不出现在参数列表中;二元操作符有两个参数,但参数表中之出现一个(另一个是 this指针)。

定义方法:

  • 返回类型 operator 操作符(参数列表)
  • 返回类型 类名::operator 操作符(参数列表)

赋值操作符过载:“=”只能作为成员函数被过载。如果没有定义operator =,编译器会自动创建一个,如同创建缺省拷贝构造函数一样。

自动类型转换

方法一:利用构造函数实现 class U {public : U{T t};}(目的类执行转换:T→U)

方法二:类型转换成员函数 class U {public : operator T();} (源类执行转换:U→T)

第三阶段:继承、组合、抽象基类、虚函数

继承(inheritance)

C++中的继承方式

  • 公有继承:class B : public A {…}; 基类的公有成员和保护成员作为派生类的成员时,依然保持原有的访问控制特性。
  • 私有继承:class B : private A {…}; 基类的公有成员和保护成员都作为派生类的私有成员。
  • 多继承:class C : public A, private B {…};

继承的原则:若逻辑上B是A的“一种”,并且A的所有功能和属性对于B而言都有意义。

公有继承的作用:第一是实现以代码复用为目的的类继承;第二是实现以接口复用为目的的接口继承。

私有继承的作用:私有继承所得的派生类不具备父类特性,但可以重用父类代码,从而实现代码复用。(实现与组合相同的功能)

==== 某些观点认为应该避免单纯以代码复用为目的而设计的继承。 构造函数的调用次序 ====

首先调用基类构造函数,初始化基类子对象;然后调用各成员对象构造函数,初始化各成员对象;最后调用类构造函数,初始化当前对象。格式如下所示:

B:B(int l, int n):A(l):b(n) {...}

其中B由A派生,b是B的成员。

重载(override)

派生类中若重定义基类的同名函数,则基类中的同名函数在派生类中被覆盖,不再可用。

向上映射原则

如果一个函数的参数为类类型、类指针或类引用类型,则这个函数也可以接受该类的派生类作为其参数,但不能接受该类的基类作为其参数。

渐增式开发

继承的优点之一在于它允许开发者在已经存在的代码中引入新代码,而不会给原代码带来错误,如果发现错误,这个错误也只与新代码有关。

虚函数(virtual function)

虚函数的出现:基类和派生类中有一个同名的操作,但其实现并不相同。对于一段与基类接口通信的代码,编译器无法确定对象属于基类还是派生类,若该对象引用了一个在基类和派生类中有不同定义的同名操作,编译器必须将该操作束定到某个具体函数上。

  • 静态束定:在编译过程中将其束定到基类操作上了。
  • 动态束定:若基类中该操作为虚函数,则在运行过程中动态束定。
Q: 析构函数为什么要定义为虚函数?
A: 由于在编译时期无法判断delete操作符所操作的指针是否指向一个派生类对象,唯有采用动态绑定的方式才能调用正确的析构函数。 否则,如果一个类指针指向的是其派生类的对象,delete操作直接调用该类的析构函数,子类对象就无法被正确析构了。
Q: 构造函数可否定义为虚函数?
A: 不可以。首先没有必要,当使用new创建某个特定对象的时候,对象的类是确定的,其构造函数也是确定的,因此无须进行动态绑定;其次是不可能,因为在构造函数尚未被执行的时候,动态绑定的机制还没有建立。
Q: 构造函数中调用虚函数会怎样?
A: 构造函数的实现部分不采用动态绑定机制,构造函数中所调用的该类的所有成员函数——包括虚函数——都将静态绑定到该类对应的实现上。也可以这样理解,在构造函数退出之前,对象尚未完全生成,则动态绑定的机制尚未建立(虽然此时虚函数表指针可能已经挂接到正确的虚函数表上)。

抽象基类和纯虚函数

纯虚函数的声明:virtual void f() = 0;(纯虚函数不需要定义)

包含有纯虚函数的类称为抽象基类,抽象基类仅仅提供一个公共接口,但并不给出其实现,试图创建一个抽象类的对象会引发编译错误。当希望通过公共接口操作一组类时,就创建一个抽象基类。

模板(template)

函数模板:

template <typename 类型标志符> 函数定义
template <class 类型标志符> 函数定义

类模板

  • 类定义:
      template <typename 类型标志符> 类定义
      template <class 类型标志符> 类定义
  • 类成员函数定义:
      template <typename 类型标志符> 类名::函数名 { 函数体 }
      template <class 类型标志符> 类名::函数名 { 函数体 }

补充说明:一个模板是允许多个类型参数的。

组合(composition)

组合的概念允许用户利用已定义的类实现更复杂的类,这种复杂的类的对象由多个成员对象构成。

组合的原则:若在逻辑上A是B的一部分,则不允许B从A派生,而是要用A和其他东西组合出B。

多态

多态体现为同一个程序段可以处理多种类型的数据,目的是通过不同的方式执行相同的操作,以达到相同的目的。

  • 过载多态:通过函数名的过载和操作符的过载实现。
  • 强制多态:通过类型强制转换实现。
  • 包含多态:通过子类型化实现。(一个程序段既能处理类型T的对象,也能处理T的子类型的对象,同名的行为因子类型的不同而有不同的实现)
  • 类型参数化多态:通过类模板和函数模板实现。

RTTI

RTTI叫做“运行期间类型识别”,C++语言通过在对象的vtable中保存一个type_info指针指向描述该对象类型的type_info结构来实现RTTI。和RTTI相关的操作符主要有两个,其一是typeid,其二是dynamic_cast。

typeid返回一个描述对象类型的type_info引用,使用前需要包含<typeinfo>头。

typedef unsigned int UINT;
void foo()
{
    cout << typeid(UINT).name() << endl;    //"unsigned int"
    cout << typeid(string).name() << endl;    //"string"
}

dynamic_cast可以将一个父类的指针转化为某个派生类的指针,而如果父类指针指向的对象的类型并不是指定的派生类,则返回空。这一机制也是由RTTI来实现的。

使用g++编译器的话要打开-frtti来支持RTTI。必须关注RTTI代码和非RTTI代码混编的情况,假如A类编译的时候没有启动RTTI而继承自A类的B类在编译时启动了RTTI,则会出现找不到A类的type_info定义的错误。

1) string实际上是basic_string<char>的重定义
 
tech/programming/cplusplus.txt · Last modified: 2016/08/27 00:24 by admin
 
Except where otherwise noted, content on this wiki is licensed under the following license:CC Attribution-Noncommercial-Share Alike 3.0 Unported
Recent changes RSS feed Donate Powered by PHP Valid XHTML 1.0 Valid CSS Driven by DokuWiki