C++的名称来源于递增运算符,从字面理解就是C语言的增强版,而且增强的不只一点,所以是两个+。最初C++主要是在C语言的基础上增加了OOP特性,所以也有把C++看作是C with Classes的。另一个基于C语言扩展OOP特性发展出来的语言是macOS下的Objective-C,但是两者差别比较大。

因为C++是基于C的,并且还是兼容C的,也就是说C++的编译器是可以直接编译C语言源程序的,所以从学习路线的角度,先学C,再学C++没什么不妥,并不会因为多学一门语言多花时间,因为C就是C++的重要组成部分。但是如果不先学C而直接就学C++倒很有可能因为一下子太复杂而带来很多困难。所以,下面的笔记都是基于C语言而记的,如果没有C语言作为基础,可能看不懂。

一、C++与C的一些区别

先看最简单的HelloWorld程序,这是C语言版

#include <stdio.h>
int main(){
    printf("Hello World!\n");
}

这是C++版

#include <iostream>
using namespace std;
int main(){
    cout<<"Hello World!"<<endl;
}

从这个例子来看,区别有几点:1.头文件不再有扩展名.h,C++在库这一块修改还是很多,重新定义了很多头文件,很有必要把新的头文件与C语言的头文件区别开,据说制定标准的那帮人争持不下,决定不了用什么扩展名,所以最终干脆就不用扩展名了。C语言原来的头文件仍然可用,因为要保持兼容,有的实现在原来的头文件名前加上字母c然后去掉扩展名,变成了cstdio这样的形式。2.C++引入了『名称空间』,名称空间解决了不同的代码库中名称冲突的问题,也就是完整的命名应该是『空间名』::『变量名』这样的形式,不过,为了简化使用,可以用using namespace语句打开『名称空间』。using语句在其作用域内有效,也就是全局的或者在{}范围内。一般常用的std名称空间,using成全局的比较方便,其他用得比较少的,通常就using在一定范围内,甚至还可以直接只using某一个名称以尽量减少名称冲突。3.用cout代替printf函数输出,cout是一个ostream类的对象,而『«』是重载的运算符,本质上是一个成员函数,这个成员函数又有多种重载形式,简面言之,cout会根据『«』后面的变量类型决定输出的格式,而无需像printf函数一样需要在格式字符串中通过控制字符来指定输出变量的类型,减少了很多格式不匹配的错误。另外,endl是在iostream头文件中预定义的常量(const),表示换行符。

这里也可以看出,C++不像JAVA,JAVA号称什么都是class,在JAVA的源程序里,class之外除了import之外没有其他的东西,而import导入的也是一个一个的类。所以,JAVA可以称为纯面向对象的语言,而C++并不是。也就是说,使用C++完全可以不用其OOP特性,但是这样就没有什么意义了。

二、指针和引用

指针并不是C++增加的,C语言里面就有指针,本质上讲,指针就是一个地址,一般的变量名称、函数名称等实际上也是标记了一个地址,但是这些地址是静态的,程序经过编译、链接之后就固定了。通过指针指向的地址去访问,表面上看多了一个环节,但是却增加了灵活性,因为指针的内容是可变的,也就是说,通过同一指针可以访问到不同的内容。这种灵活性主要用于处理程序运行时才能确定的一些问题,主要是空间问题,比如动态分配内存,这些动态的空间通常使用指针来管理,否则的话,常规变量就已经足够。当然,函数也有地址,同样也可以把函数地址保存在指针里,这样的话,就可以实现根据运行时输入的不同选择调用不同的函数来实现不同的处理方式,这样的话,程序就更加灵活了。

指针还用来解决一个问题,就是C语言的函数本质上都是传值的,也就是说,C语言在函数调用时,是把实参的值传递给形参,而不像其他语言,比如Pascal,函数参数分为值参数和变量参数,默认是值参数,如果使用变量参数,需要在函数参数定义前加var。事实上,C语言规定函数参数只有值参数是为了避免函数内部的操作传递到函数外部,准确来说是为了避免无意识的编程错误修改了外部的变量,因为必竟如果函数完全封闭,所有东西都传递不出来,那就没有任何用处了。但是,只能传值也带来了一些编程上的麻烦,比如,典型的swap(a,b)函数,其本意就是交换两个变量的值,但是由于C语言的传值特性,像下面这样写是没有效果的。

void swap(int a,int b){
    int temp=a;
    a=b;
    b=temp;
}

这个函数的确交换了两个参数的值,但是这两个参数都是局部变量,不会影响函数外部的值。这种情况下,就可以利用指针,也就是把变量的地址传给函数,函数就可以把指定地址的内容进行交换,像下面这样。

void swap(int* a,int * b){
    int temp=*a;
    *a=*b;
    *b=temp;
}

然后这样调用:swap(&x,&y)。C语言利用指针解决了这一问题,既保持了函数的封闭性,又达到了目的,在新的swap函数中,交换的并不是局部变量,交换的是变量指向的地址的内容。在函数调用时这么没什么问题。但是,在函数外部,指针是可变的,难以控制的,也就是说稍不注意,指针指向的就可能不是实际希望指向的内容。所以,C++又引入了引用变量来解决这样的问题。这时,swap函数可以写成下面这样:

void swap(int& a,int& b){
    int temp=a;
    a=b;
    b=temp;
}

调用时直接调用:swap(x,y)

采用引用参数的swap函数实现了在C语言中需要用指针才能达到的效果,调用时传递的实际上也是变量的地址,只不过,与指针不同的是,传递到函数的地址并不是保存在一个变量中,而直接就是传递给引用参数,也就是说,这时,函数内部的引用参数变量跟传递给它的实参代表的是同一个东西,所以这时函数内部变量的用法跟在外部一样,不需要*、&等操作符。或者说,C++通过引用变量,实现了Pascal语言的变量参数。

引用变量和普通变量一样,本质上都是一个标识符,都是表示一个地址,而指针则是其内容是一个地址,用汇编的术语来讲就是,变量和引用都是直接寻址,而指针是间接寻址。普通的变量和引用都是直接寻址,本质上也都是一个地址的标识符,那么为什么还需要引用呢?主要还是为了解决函数调用时参数值的传递方式问题,把函数传值方式默认改为传引用方式虽然也是一种可行的办法,但那样的话,一旦程序出错,排错将会很麻烦,因为变量的值可能在每一个层次被修改。用指针也可以解决问题,但那必竟是间接的,指针使用不当又会带来其他的问题。当然,在没有引用变量之前,C语言就是这么做的,当确需在函数内部修改传入的内容时,就用指针,否则就采用默认的传值。但是C++扩展了OOP之后,传递到函数的可能是一个复杂对象的实例,这时,仍然采用传值的方式,复制的效率是很低的,为了提高效率,同时又避开指针的难用问题,引用变量就应运而生。同时,为解决函数传返回值的效率,引用不仅可以用在函数参数处,而且也可以作为函数的返回值,也就是说函数外部也可以定义引用变量。

引用变量相当于是另一个变量的别名,而且不可变,也就是说不能一会儿引用这个,一会儿引用那个,那样的话就成指针了。所以引用变量必须在定义的同时初始化。在函数参数使用引用,是不可能同时确定参数值的,但我们可以理解为函数参数是在调用时初始化的。

采用引用参数后,传递值的效率是提高了,但仍然难以避免无意中修改到传入的变量的问题,所以C++又增加了一个访问控制描述符:const,被const描述的变量,一旦被初始化后就不能再修改。这种限制是由编译器实现的,这样,需要利用引用高效传值,又不需要修改传入的值时,就可以用const描述函数参数。这样就完美的解决了问题。

三、类

3.1类的基本概念

类其实有点像结构,可以把多个变量组合在一起用来表示一个复杂的对象,与结构不同的是,类不但可以包含数据成员,还可以包含成员函数。如:

class point{
    int x;
    int y;
    int print(){
        cout<<'('<<x<<','<<y<<')';
    };
};

只不过,结构成员是可以直接访问的,而类的数据成员默认情况下不能从外部访问。即默认访问属性是private,当然,也可以用public属性予以公开,或者用protected对子类公开。C++的访问属性不必像JAVA那样标在每个成员前面,而是采取访问控制域的形式,一个访问控制属性可以连续定义的一系列成员直到下一个访问控制属性,一个类可以有多访问控制域。成员函数可以在类内部声明,在类外部定义,在外部定义时,用类名加上范围解析运算符::来标明该函数是哪一个类的成员函数。当成员函数在类内部直接定义时,会被编译器生成为内联函数,即便没有用inline声明也是如此,内联函数的实现其实是把函数代码插入调用的位置,省去了普通函数调用时程序跳转但是如果函数本身执行的时间较长,则节约的效果并不明显,反而会使得生成的程序有大量重复的代码,所以,通常只会将少量的、简单的成员函数直接放在类内部定义。

类实际上是把数据以及对数据的操作进行了封装,一般来说,只公开一部分成员函数作为对外的接口,外部对对象的操作都通过公开的接口进行,这样,即使内部实现有所改变,只要接口不变,程序的其他部分就不需要改变。

3.2构造函数和析构函数

构造函数用来对数据成员进行初始化,如果没有定义构造函数,编译程序会自动生成一个,但默认的构造函数几乎什么也不做,其生成对象的数据成员的值是不确定的。如果类成员需要动态分配内存,那么就可以在构造函数中完成申请内存的操作,此时,还需要定义一个析构函数来释放内存。构造函数名称和类名一样,析构函数名称则是在类名前加~符号,它们都无需返回值,声明和定义时不能声明返回类型。构造函数可以重载,以支持不同的初始化方式,但是析构函数就不必重载。需要注意的是,只要显式定义了构造函数,那么编译器就不会再生成默认的无参构造函数,这就意味着只定义类对象而不进行初始化这种行为无法进行,所以通常一旦显式地定义了构造函数,那么同时也应该定义一个无参的构造函数,或者构造函数的所有参数都设定默认值。除非明确知道不用这样做。

有一种构造函数叫复制构造函数或者叫拷贝构造函数,原型如下:class_name(const class_name&)。复制构造函数在定义一个对象并且用已有对象对其进行初始化时调用,常见形式是class_name b=a;函数参数是在调用时初始化的,如果函数参数不是引用参数,那么把一个对象作为值传递给函数参数时,调用函数时就发生参数初始化,会调用拷贝构造函数。如果不显式定义拷贝构造函数的话,那么编译器也会自动生成一个,但是默认的复制构造函数只会进行浅复制,也就是只简单地复制数据成员,而对于指针,并不会去复制指针指向的内存区域。这样的话,一个对象修改了指针指向的内容,那一个对象对应的内容也会发生改变,这种情况通常都是不希望发生的,另外,在用指针作为成员的情况下,通常是动态申请内存,那么就应该用析构函数释放内存,而进行浅复制之后,同一块内存会被释放多次,这会引出程序错误。所以,在这种情况下,需要显式地声明和定义复制构造函数。

另外,如果先声明对象,再将已有对象传给它,可以用赋值运算符=,但这不是初始化,不一定会调用复制构造,之所以说不一定是因为这要看编译器如何实现,编译器有可能先生成一个临时对象,初始化后再赋值给新对象。赋值的话实际上是默认的运算符重载,如果不显式定义,编译器也会默认生成,当然,默认生成的赋值运算符也只会浅复制,所以,在数据成员有指针的情况下,还需显式进行赋值运算符重载,以进行深复制。

3.3运算符重载与友元函数

运算符重载实际上在编程语言中是很常见的现象:同一运算符可以运用在不同类型的数据上,比如+,可以用于两个int值相加,也可以用于两个float相加,甚至可以用于int与float相加,当然这里需要先进行类型转换。

C++允许把运算符重载为类的成员函数,比如:

#include <iostream>
using namespace std;
class complex{
    private:
    float x;
    float y;
    public:
    complex();
    complex (float,float);
    void print();
    complex operator+(const complex&);
};
complex::complex(){};
complex::complex(float a,float b){
    x=a;
    y=b;
}
complex complex::operator+(const complex& a){
    complex temp;
    temp.x=x+a.x;
    temp.y=y+a.y;
    return temp;
}
void complex::print(){
    cout<<this->x<<'+'<<this->y<<'i';
}
int main(){
    complex a(1.2,1.1),b(2.0,3.0);
    complex c=a+b;
    c.print();
}

像这样重载了操作符+之后,复数的运算看上去就直观多了。如果再定义只有一个参数或者其他参数都有默认值的构造函数,那么还能进行a+2这样直观的运算。但是2+a就不行了,那是因为a+2的话,编译器会用构造函数把2转换为complex类型的对象,然后再用+运算,但是2+a的话,因为2不是一个类对象,编译器也不知道应该把2转换成哪一种类才合适。这种情况下,实际上可以不把+运算符定义为类的成员函数,而是像下面这样定义:

complex operator+(int a,complex b){
    complex temp(a+b.x,b.y);
    return temp;
}

这样的话,编译器在遇到2+a这样的表达式时,就会匹配这个+运算。但是仅仅这样还不行,因为在对象外部是不能直接访问类成员的。这种时候可以有两种解决办法:一种是定义友元函数,也就是在类定义中用friend声明友元函数(运算符重载其实也是函数),像这样friend complex operator+(int,complex&);声明过友元之后,在友元函数内部就可以直接操作类的私有成员了;另一种方式就更简单,就是把2+a改成a+2来执行,这样就无需定义友元。也就是

complex operator+(int a,complex b){
    return b+a;
}

这个例子看出,友元函数其实也可以不用,好像其他面向对象的语言就没有这个概念。所以有人认为友元破坏了类的封装性,因为从类的外部可以直接访问私有成员,但这个指责是不存在的,因为友元也需要在类中声明,也是公开类的接口的一种方式。事实上,类的成员函数是所有类对象公用的,编译器并不会把成员函数的代码真正的放在每个一对象内部,只不过是用类名作了范围限定,调用的时候需要用【类名.函数名】这样的形式而已,其实和友元函数并没有多大差别,当然,成员函数还传递了一个this指针,友元函数就没有,所以在用法上,用成员函数可以少传一个参数,但是前面需要加对象,而友元函数需要显示传递对象。使用方式不一样,比如,例设m是一个matrix(矩阵)对象,矩阵的转置操作用成员函数定义的话用法是m.invert(),而如果定义成友元,用法是invert(m),其实也差不多,就看习惯了。

回到运算符重载,大多数运算符都可以重载,包括new、delete都是运算符,都可以重载。只不过,单目运算符重载显然是没有返回值的,也就是返回值为void。然后单目运算符++、–有前缀形式和后缀形式,后缀形式一般实现为i++(0)这样。还有就是运算符重载既可以采用成员函数的形式,也可以采用友元形式,但是有几个运算符不能用友元函数重载,包括:=,(),[],->,这几个运算符作用于类对象默认是有语义的,其中=是由编译器默认重载,如果通过成员函数重载,则可以重新定义语义,但是用友元重载,则覆盖不了默认的语义,产生二义性,所以干脆规定这几个运算符不能采用友元函数方式重载,硬要这样干是编译不过的。