第二部分 面向对象基础

从这篇文章开始,就是C++面向对象的内容了。

封装

C++的类将成员变量和成员函数进行了封装。在C++中通常把类分为头文件(.h)和源文件(.cpp)来写。而且对于类来说,类名本身也能当做一个命名空间来使用。所以可以在头文件中声明类地成员变量和成员函数的原型,然后在类的源文件中通过类名::函数名的方法进行函数的具体定义。也可以把一些小型简单的函数放在头文件中,这样默认会尝试做成inline函数(常见的如get和set方法)。

构造函数

类在实例化的时候通常需要通过构造函数进行初始化。默认构造函数指的是一个无参的构造函数。假如没有给类定义任何的构造函数,编译器就会自动补充一个默认无参的构造函数,这个函数通常来说什么都不做(这里不做深入讨论,深入探讨可以参考C++默认构造函数——深入理解)。如果想要在构造的时候初始化一些变量,则需要自定义构造函数。

和Java不同,C++的对象声明时就可以在栈中创建完整的对象。对象的构造书写方法:

  • 使用默认构造函数构造:A a=A()或者A a
  • 使用有参构造函数:A a=A(1)或者A a(1)。如果只有一个参数,也可以写成A a=1
  • 使用默认改造函数声明数组:A a[4],会调用默认构造函数。
  • 声明数组并自定义初始化:A a[]={A(),A(1)}

私有构造函数经常用来实现单件模式。

构造函数除了明显声明对象的时候可以调用之外,还可以在函数返回的时候隐式调用,如下例子:

class Foo{
    int i;
public:
    Foo(int i) : i(i) {
        cout<<"constructor"<<endl;
    }

    Foo(const Foo& f): i(f.i){
        cout<<"copy"<<endl;
    }

    ~Foo(){
        cout<<"destroy"<<endl;
    }
};

// 隐式调用构造函数
Foo getFoo(int i){return i;}

int main(){
    getFoo(1);
    cout<<"over"<<endl;
    
    const Foo& foo=getFoo(1);//正确
    Foo& f=getFoo(1);//错误,不能用非常量引用指向临时变量
    cout<<"over"<<endl;
}

//输出为:
//constructor
//destroy
//over
//constructor
//over
//destroy

成员初始化表

构造函数在定义的时候有一种特殊的操作,即成员初始化表。由于C++的类中成员变量不能在声明的时候赋值,因此提供了一种高效的初始化成员变量的方法。书写时需要在构造函数的大括号前面插入成员初始化表,用:开头,用,隔开。每个初始化的操作写成变量名(值)的形式。例如:

class Foo{
    int num;
    double n;
public:
    Foo(int a,double b):n(b),num(a){}
};

这里的构造函数就包含了一个成员初始化表。需要注意的是,成员初始化表中成员变量的初始化顺序是由声明的顺序决定的,和表的书写顺序无关。但是总之,成员初始化表是先于构造函数函数体执行的。

另外,成员初始化表中的操作是初始化,会调用构造函数或者拷贝构造函数,但不会受到赋值操作符重载影响;而如果在构造函数体中给成员变量赋值,就是赋值操作,赋值操作符可以重载。

注意一点,如果类中声明有成员对象,那么成员对象初始化会先于构造函数函数体执行,而且默认的初始化是编译器调用成员对象的无参构造函数完成的。如果想调用成员对象的有参构造函数进行初始化,就必须把初始化放在成员初始化表中。请看下面的例子:

#include <iostream>

class Part {
    int a;
public:
    Part() {
        std::cout << "no param construct" << std::endl;
    }

    Part(int a) : a(a) {
        std::cout << "all param construct" << std::endl;
    }

    Part &operator=(const Part &part) {
        std::cout << "part get value" << std::endl;
        this->a = part.a;
        return *this;
    }
};

class Whole {
    Part part;
public:
    // 如果想调用有参构造初始化成员变量,必须放在成员初始化表中进行初始化
    // 否则在执行构造函数函数体之前,编译器就会使用无参构造函数把成员对象初始化好
    // 在函数体中,只能给成员对象进行赋值,而不能初始化。
    Whole() {
        part = Part(1);
        std::cout << "Whole construct" << std::endl;
    }

    Whole(int a) : part(a) {
        std::cout << "Whole param construct" << std::endl;
    }
};

int main() {
    Whole whole;
    std::cout << std::endl;
    Whole whole1 = 1;
}

// 输出:
// no param construct
// all param construct
// part get value
// Whole construct
//
// all param construct
// Whole param construct

析构函数

析构函数是对象在被销毁时所调用的函数吧,必须是无参的。不自定义析构函数的时候,编译器会生成一个默认的版本,这个版本的析构函数通常什么都不做(和构造函数一样,这里不深入,事实上可能是有用和无用析构函数)。

栈中对象:如果对象被声明在栈中,会随着函数返回而被销毁。这时系统会自动调用对象的析构函数,归还占用的空间。但是不排除对象中有些变量是指针,指向堆中的空间。这个时候编译器的默认白板析构函数就不够用了,需要自己定义析构函数,将栈中指针指向的占用的资源也释放掉。另外,如果对象还占用了其他资源,例如网络资源、文件资源,也应该自定义析构函数来释放。

堆中对象:栈中只有一个指针或引用,指向堆中的对象空间。这个时候在退栈的时候系统只会删除指针,却不会自动调用指针指向对象的析构函数。这时需要显式地使用delete 指针,调用指针指向对象的析构函数。delete操作的两个步骤大致就是:调用析构函数,然后释放对象本身占用的空间。

举例:

#include <iostream>
using namespace std;

class Foo{
public:
    ~Foo(){
        cout<<"destructor"<<endl;
    }
};

int main(){
    Foo fooo; //随着main函数结束自动调用析构
    Foo& foo=*(new Foo);
    Foo *fo=new Foo;
    
    delete &foo; //显式delete调用析构函数
    delete fo;//显式delete调用析构函数
    return 0;
}

析构函数也可以声明为私有。一旦声明了私有的析构函数,则不能在栈中创建对象,因为系统无法调用析构函数。在堆中创建对象时,需要通过另一个共有成员函数比如void destory(){delete this}来调用自己的私有析构函数,并释放空间。总之,声明私有析构函数等于强制了对象必须在堆中创建。

拷贝构造函数

关于对象的拷贝构造,就不得不说一下对象的初始化和赋值之间的区别。对象的初始化发生在声明的对象被创建的时候(除了明显的声明对象,在函数传参函数返回值的时候也可能有对象创建!),而赋值则必须是两个已经存在的对象,将一个赋值给另一个,赋值的时候必须已经有两块类型一致的空间。C++中的对象之间进行赋值,默认会将每个成员变量分别赋值,也是浅层次的赋值,赋值操作不会创建新的对象,因此不调用任何构造函数和拷贝构造函数。

在对象初始化的时候,可以用一个已有的对象为母版复制出一个新的对象,这个过程创建新对象,因此调用一种特殊的构造函数——拷贝构造函数。同样的,如果不自定义拷贝构造函数,编译器会提供一个默认版本,这个版本只是把成员变量进行拷贝,是浅拷贝。如果需要进行深拷贝,或者拷贝的时候有其他操作,就需要自定义拷贝构造函数。

拷贝构造函数的格式为Foo(const Foo& foo){}。从这里可以看出,拷贝构造函数其实也可以理解为构造函数的一个重载版本,只有一个唯一的参数,因此在书写上也和构造函数基本相同。注意这里一定是引用类型的参数,否则传参的过程本身就是拷贝的过程,将产生递归调用,这是不允许的。

例如:

#include <iostream>
using namespace std;

class Foo{
public:
    Foo() {
        cout<<"constructor"<<endl;
    }

    Foo(const Foo& foo){
        cout<<"copy constructor"<<endl;
    }

    ~Foo(){
        cout<<"destructor"<<endl;
    }
};

int main(){
    Foo f; //构造函数
    Foo fo=f; //拷贝构造,写法1,和单一参数的构造函数一种写法很像
    Foo foo(f); //拷贝构造,写法2
    Foo fooo=Foo(f); //拷贝构造,写法3
    foo=fo; //赋值
    Foo *foooo=new Foo(f); //拷贝构造
    (*foooo)=fo; //赋值
    delete foooo; //析构堆中的对象
    return 0;
} //析构栈中的对象

另外,需要注意的是拷贝有很多发生的场合,除了明显写出,在函数传参、成员初始化列表、函数返回值时都有可能发生拷贝,并且很多时候是没有必要的额外开销。C++编译器也在这方面做了很多优化,例如返回值优化和具名返回值优化。这个问题可能会在最后C++11新特性时讨论。

C++的哲学:

  • 如果没有自定义拷贝构造函数,则拷贝时默认调用成员(和基类)的拷贝构造函数
  • 如果自定义了拷贝构造函数,则拷贝时默认调用成员(和基类)的默认(无参)构造函数

例如:

#include <iostream>

class Father{
public:
    Father():age(10){
        std::cout<<"Father Constructor"<<std::endl;
    }
    Father(const Father& father):age(father.age){
        std::cout<<"Father Copy"<<std::endl;
    }
};

class Son: public Father{
public:
    Son(){
        std::cout<<"Son Constructor"<<std::endl;
    }
    Son(const Son& son)  : Father(son) { //注意这里!如果不写Father(son)成员初始化表,则拷贝时调用的是Father的构造函数
        std::cout<<"Son Copy"<<std::endl;
    }
};

int main(){
    Son *son=new Son();
    Son son1=*son;
}

如果程序员要自己来,那就全自己来吧。。。

另外,对于函数返回值,如果是值传递的返回值(对比与引用返回),则理论上需要对函数体中要返回的局部变量进行拷贝,如果是对象就会拷贝构造。但是C++编译器已经进行了一些优化,有时候我们并看不到拷贝的过程,这是因为编译器已经自动识别并避免了不必要的拷贝。如果是出现返回时发生对象切片的情况,还是能够明显看到拷贝构造过程的。请看下例:

#include <iostream>

class Integer {
    int v;
public:
    Integer(int v) : v(v) {}
    Integer(const Integer &integer) : v(integer.v) {
        std::cout << "Copy!" << std::endl;
    }

    Integer & operator=(const Integer &integer) {
        v = integer.v;
        return *this;
    }
};

Integer getInteger() {
    Integer integer(1);
    std::cout << &integer << std::endl;
    return integer;
}

int main() {

    Integer integer1=getInteger();
    std::cout << &integer1 << std::endl;
//    0x61fe14
//    0x61fe14
    // 看到两个地址是相同的。意味着编译器直接拿getInteger函数栈中的那个Integer对象来当做新创建的对象了。
    
    Integer integer(2);
    integer=getInteger();
    std::cout << &integer << std::endl;
//    0x61fe1c
//    0x61fe18
    // 赋值也不拷贝
    
    return 0;
    // 整个过程没有调用拷贝构造函数
}

参数传递的时候如果是值传递的方式也会发生拷贝:

#include <iostream>

class Foo{
public:
    Foo() {
        std::cout<<"construct"<<std::endl;
    }
    Foo(const Foo& foo){
        std::cout<<"copy"<<std::endl;
    }
};

void lookFoo(Foo foo){
    std::cout<<"here is a foo"<<std::endl;
}

int main(){
    Foo foo;
    lookFoo(foo);
}

// construct
// copy
// here is a foo

如果是以引用的形式传递参数,则通常不会发生对象拷贝。如果参数是基类,但实际传入派生类,就会形成一个指向派生类对象的基类引用,调用虚函数时会使用对象的实际类型版本的函数。请看下面例子:

#include <iostream>

class Father{
public:
    Father() {
        std::cout<<"Father Construct"<<std::endl;
    }

    Father(const Father &father){
        std::cout<<"Father Copy"<<std::endl;
    }
    
    virtual void sayHi(){
        std::cout<<"Father: Hi"<<std::endl;
    }
};

class Son: public Father{
public:
    Son() {
        std::cout<<"Son Construct"<<std::endl;
    }

    Son(const Son &Son){
        std::cout<<"Son Copy"<<std::endl;
    }
    
    void sayHi(){
        std::cout<<"Son: Hi"<<std::endl;
    }
};

Father lookFather(Father &father){
    std::cout<<"Look Father"<<std::endl;
    father.sayHi();
    return father;
}

int main(){
    Son son;
    lookFather(son);
}

// 输出:
//Father Construct
//Son Construct
//Look Father
//Son: Hi
//Father Copy

移动构造函数

C++11之后,对于对象不必要的拷贝又做了优化。移动构造函数接受一个右值,绑定到右值引用上,然后在函数体中应该让新的对象“侵占”这个右值的内存空间,然后将右值引用对象中的指针置为nullptr。由于右值作为临时值,一般来说总会被销毁,不能进一步修改,所以这样简单地把内存空间的控制权进行转交的构造方法,在接受右值参数的时候避免了拷贝,节约了资源。

拷贝构造函数中对对象的指针进行浅拷贝即可。

主动调用右值拷贝要使用std::move()函数,具体用法如下例:

#include <iostream>
class Foo{
public:
    int *num;

    Foo(int n){
        num=new int(n);
    }

    Foo(Foo&& that) :num(that.num){
        that.num= nullptr;
        std::cout<<"move"<<std::endl;
    }
};

int main(){
    Foo f1(2);
    Foo f2=std::move(f1);
    std::cout<<*(f2.num);
    return 0;
}

另外,还有移动赋值操作符的重载,可以在赋值的时候采用移动的方式。典型的应用是交换两个对象值的swap函数。

动态对象

指的是内存分配在堆中的对象。

上面大多是在栈中创建对象,事实上,在堆中通过new来申请资源创建对象也很常用,而且很接近Java的习惯。如果说在栈中创建对象的时候,对象标识符就是对象名,那么堆中创建的对象就是无名对象,唯一能够帮助访问对象的是指向对象的指针。new操作符(new可以被操作符重载)可以在堆中申请资源、调用构造函数,然后返回一个指针供我们接受下来。

在堆中创建的对象不会由系统自动调用析构函数,因此用完之后必须用delete释放内存并调用析构函数。

对象数组:可以通过new A[100]形式的语句直接声明一个对象的数组,返回指向第一个对象的指针。这样做的前提是类必须有默认构造函数(无参构造函数)。通过delete []ptr的方式可以一次性释放数组的所有内存,逐一调用析构函数(关于数组的整体情况系统有记录)。

二维对象数组:声明二维对象数组和初始化的时候一般要分两步进行:先声明2级指针,然后分配一块类型为一维数组的内存给它;第二步循环给每个一维数组的每个位置进行初始化。例如:

const int ROWS = 3; 
const int COLUMNS = 4;
 
char **chArray2; 

// allocate the rows 
chArray2 = new char* [ ROWS ]; 

// allocate the (pointer) elements for each row 
for (int row = 0; row < ROWS; row++ ) 
    chArray2[ row ] = new char[ COLUMNS ]; 

new/delete、new[]/delete[]、malloc/free

先说结论,这三个必须配套使用。

malloc只负责申请一定大小的堆上空间。传入一个int类型的参数表示申请空间的字节数。事实上申请的空间总量要比需要的大一点,因为还需要记录这块空间的大小等信息,方便free的时候进行释放。malloc可以返回一个申请空间的void*类型指针。free则是归还空间,只需要传入空间位置指针即可,然后释放的时候会找到这块空间的大小等信息进行归还。总的来说,malloc和free只是完成简单的动态申请空间工作。

new(关键字)的操作总的来说分为三步:调用new操作符进行空间申请(内部实现还是借助malloc的),如果是非基本类型则调用构造函数对空间进行初始化,最后返回一个指向空间开头的指针。new的申请空间大小会自动根据类型进行判断。而delete的工作则总体分为两步:先调用对象的析构函数(如果是非基本类型的对象),然后调用delete操作符归还空间。无论是delete还是free,都只能对已经申请的空间进行一次归还,即使尝试归还两次,也会因为找不到对应空间块的大小等信息而出错。

new[]和delete[]应该理解为与new/delete没关系的两套关键字(尽管名字很像)。new[]负责动态申请数组空间,特点是根据数组元素的类型和数量自动计算所需空间大小,并且一次性完成申请,而不是为每一个元素单独申请一次空间。另外,new[]会在指针头部前面额外使用一点空间(4字节)来记录数组元素的个数,为delete[]提供便利。空间申请好,会逐个调用默认构造函数(如果是非基本类型)进行初始化,然后返回一个指针。delete[]用来归还数组空间,会查找指针前面的一块信息得到数组元素个数并逐一调用每个数组元素的析构函数(如果是非基本类型),最后一次性归还所用空间,不留痕迹。

接下来看一下如果不配套使用会发生什么:

  • 使用free归还new申请的空间:如果是基本类型还好,如果是非基本类型则会漏掉析构,可能导致资源释放不彻底。不会报错。
  • 使用free归还new[]申请的空间:一定会引发内存泄漏,指示数组元素个数的信息占用空间不能归还。不会报错。
  • 使用delete归还malloc申请的空间:基本类型还好。没见过用malloc创建动态对象的。如果使用malloc创建对象,对象的非虚函数还是可以静态绑定,但是由于空间没有初始化,所以不能保证可以使用。虚函数不能使用,因为申请的空间没有经过处理,不存在对象的虚函数表。delete这样的“对象”没有任何意义。
  • 使用delete归还new[]申请的空间:不会报错,一次归还了整个数组的空间,但是只调用第一个对象的析构,而且对于头部的数组元素个数信息占用空间不会归还,造成内存泄漏。
  • 使用delete[]归还new和malloc申请的空间:运行时错。

Const成员

const成员变量表示,变量一旦初始化就不能更改值。只能在构造时候的成员初始化表中进行初始化。const成员的声明形如const int num;,即直接在前面加const关键字即可。

const成员函数表示,函数不能修改本对象的任何非静态成员变量,但是可以修改类的静态成员变量。这个const事实上是改变了参数的类型,其实是将隐藏参数中的T* const this 改成了const T* const this,所以也可以作为区分重载函数的依据。除非确定要修改本对象,否则则尽量加上const。如果函数声明没有const,则不能对const声明的对象使用这个函数。如果声明了const,则无论对象是不是const都可以调用。这一点也不难理解:const成员函数要求传入一个隐藏的const T* const this,非const成员变量要求传入非const的this。如果对象本身是非const,那么调用const成员函数的时候可以隐式地转化为const传入;但是如果对象本身是const,企图调用非const成员函数时不能将const的this转为非const的this。const对对象的约束本来就是使得对象的成员变量不能遭到修改,如果能够调用非const成员函数,作用就丢失了。

例如:

#include <iostream>
class Foo{
    static int n;
    int num;
    const char c;
public:
    Foo(int num, const char c) : num(num), c(c) {} //正确,const成员变量必须放在成员初始化表中。
    void f() const {
        num++; // 错误!const成员方法不能修改对象的非静态成员变量。
        n++; // 正确,可以修改静态成员变量。
    }
    void g() {
        num++;
    }
};

int Foo::n=0;

int main(){
    Foo foo(100,'a');
    foo.f();
    
    const Foo fo(200,'b');
    fo.f();//正确。可以调用f()因为是const成员函数
    fo.g();//错误!没有参数匹配const Foo的g()
    
    return 0;
}

静态成员

使用static关键字声明静态成员变量和方法。静态成员变量和方法是归类所有的,这个类的所有对象都可以访问并且共享同一个变量(方法)。

静态成员变量必须放在类定义的外部进行初始化,类似于重新定义一下在初始化一个值,形如int Foo::num=0;

静态成员函数中,只能存取静态的成员变量,调用静态成员函数。这是因为调用静态函数不会实例化对象。通过在函数前面加static实现,酷似Java。

静态成员不必通过实例化的对象访问,也可以直接通过类名访问,只需将类型作为命名空间写出即可。

静态常量:同时声明为静态成员和Const常量。需要在类外初始化,且不能更改值。

例如:

#include <iostream>
class Foo{
    const static int n;
    static int num;
public:
    static void f() { num++;}
};

const int Foo::n=0; // 初始化静态常量
int Foo::num=0; // 初始化静态成员变量

int main(){
    Foo::f();
    return 0;
}

友元

友元分为友元类、友元函数、友元类成员函数三种表现形式。

在类定义中,通过friend关键字加上函数的签名或者其他的类定义,可以在本类中声明友元。友元就像本类的联系人,声明为友元的类、函数或者类成员函数可以直接访问本类的私有变量和函数,并且静态非静态都可。换句话说:友元函数(或者友元类的函数、友元成员函数)在暴露的类中,具有和被暴露类的成员函数一样的访问权限。理解之后,在后面的继承中友元函数的权限就很好判断了。

友元类和友元函数的声明都没有什么大问题,直接将上述的friend定义写入需要暴露的类中即可,都不需要事先有签名。(原理:如果是友元类,编译器会在本命名空间中找,只要存在即可,不一定要放在友元声明之前)

友元成员函数的定义则略微复杂。首先,需要写上需要暴露的类的签名,然后是要作为友元成员函数所在的类和方法的签名,然后是暴露类的完整定义(内含友元声明),最后是友元成员函数的具体实现。这样一来,友元函数知道需要访问的类是已经定义好的,并且暴露的类知道友元函数的定义确实存在,友元函数的具体实现中也清楚暴露类的成员变量。

例如:

#include <iostream>

//---友元成员函数-------
//step1 类签名
class Foo;
//step2 类定义和函数签名
class Goo{
public:
    void printFoo(const Foo& foo);
};
//step3 类完整定义
class Foo{
    static int num;
    int x;

    // 友元声明,写在private或者public都无所谓,只要存在即可。
    friend void printn(); //友元函数
    friend class Bar; //友元类
    friend void Goo::printFoo(const Foo& foo); //友元成员函数
public:
    Foo(int x):x(x){};
    static void f() {
        num++;
    }
};
int Foo::num=10;
//step4 友元成员函数具体实现
void Goo::printFoo(const Foo &foo) {
    std::cout<<foo.x<<std::endl;
}
//---友元成员函数End------

class Bar{
    Foo foo;
public:
    Bar(const Foo &foo) : foo(foo) {}

    void printFoo() const{
        std::cout<<foo.x<<std::endl;
    }
};

void printn(){
    std::cout<<Foo::num<<std::endl;
}


int main(){
    printn();
    return 0;
}

友元的性质:

  • 单向。A是B的友元,B并不是A的友元。
  • 非传递。A是B的友元,B是C的友元,A并不是C的友元。
  • 不能继承。A是B的友元,A的派生类C并不是B的友元。

另外,友元函数的实现位置可以比较灵活,但是推荐在类内声明,类外实现。不过,把实现写在类内也是可以的,如下:

#include <iostream>
using namespace std;

class Integer{
    int num;
public:
    Integer(int num) : num(num) {}

    // 这里可以把友元函数的实现写在类内部
    // 但是应当类外有签名(重载操作符可以没有)
    friend void print(const Integer &integer){
        cout<< integer.num <<endl;
    }
};

// 这里只有签名
void print(const Integer &integer);

int main(){
    print(Integer(1)); // 正确
}

临时变量的对象

临时变量的特征:栈中、无名。

临时变量的行为:充当中介,使用时候在当前语句结束后马上销毁(没有被const引用时)。

临时变量出没地点:

  • 值传递形式的函数返回值
  • 栈中调用构造函数创建的对象,但是没有被赋值给一个新声明的变量上。
  • 类型转换的中间产物。如A+(A)B。这里会产生一个临时的A对象。
#include<iostream>

class Dooble {
    double value;
public:
    Dooble(double value) : value(value) {}

    double getValue() const {
        return value;
    }

    virtual ~Dooble();

    friend Dooble operator+(const Dooble &dooble1, const Dooble &dooble2);
};

Dooble operator+(const Dooble &dooble1, const Dooble &dooble2) {
    return Dooble(dooble1.getValue() + dooble2.getValue());
}

Dooble::~Dooble() {
    std::cout << "dooble des" << value << std::endl;
}

class Integer {
    int value;
public:
    Integer(int value) : value(value) {}

    int getValue() const {
        return value;
    }

    friend Integer operator+(const Integer &integer1, const Integer &integer2);

    operator Dooble() {
        return Dooble((double) value);
    }
};

Integer operator+(const Integer &integer1,const Integer &integer2) {
    return Integer(integer1.getValue() + integer2.getValue());
}

int main() {
    Integer i1 = 1;
    Dooble d1 = 0.5;
    Dooble d2 = d1 + i1;
}

//output:
//dooble des1
//dooble des1.5
//dooble des0.5