该文章内容整理自《C++ Primer Plus(第6版)》、《Effective C++(第三版)》、以及网上各大博客
C++对结构进行了扩展,结构和类唯一的区别是,结构默认访问类型是public,而类为private
成员函数定义时,有两种定义方式,一种是直接在类声明内部定义函数,此时函数都将会自动成为内联函数;第二种是在类声明外定义,此时需要在函数名前加上所属类以及作用域运算符,如void Stock::update(double price) {}。要想在类声明外定义的函数为内联函数只需要在定义前加上inline限定符就可以了(另外,由于内联函数的特殊规则要求每个使用他们的文件中都对其进行定义,因而要确保内敛定义对多个文件都可用,最简单的方法是将内敛定义放到定义类的头文件中)
每个类对象都有自己的存储空间用于存储其内部变量和类成员,但对于用一个类的所有对象共享同一组类方法
C++会自动提供一些特殊的成员函数:
在C++11中提供了default关键字来控制默认函数的使用。假如要使用某个默认函数,但是这个函数由于某种原因不会自动创建,如编写了移动构造函数,因而编译器不会自动创建默认构造函数、复制构造函数、和复制赋值构造函数,此时可使用default显式声明这些方法的默认版本,而编译器将为这些函数自动生成函数体。这种特性只适用于类的特殊成员函数。如
class Test {
public:
Test(Test &&);
Test() = default;
Test(const Test &) = default;
Test & operator=(const Test &) = default;
}
另一方面,C++11还提供了delete来禁止编译器使用特定方法。如要禁止复制对象,可禁用复制构造函数和复制赋值构造函数。注意default仅适用于特殊成员函数,而delete则可用于任何成员函数。如
class Test {
public:
Test() = default;
Test(const Test &) = delete;
Test & operator=(const Test &) = delete;
}
要禁止复制,还可以将复制构造函数和赋值运算符放在private部分,但是使用delete则会更容易理解
delete的另外一个用法是禁止特定的转换。如下面程序,若仅有参数类型为double的函数,func(5)则会将5转换为5.0再调用。但是此时检测到func(int)被禁用后则会将func(5)当做编译错误
class Test {
public:
void func(double);
void func(int) = delete;
}
构造函数专门用于构造新对象,将值赋给它们的数据成员。结构函数的名称与类名相同,没有返回值以及声明类型,位于类声明的公有部分
调用构造函数
//显示调用
Stock food = Stock("World Cabbage", 250, 1.25);
//隐式调用
Stock garment("Furry Mason", 50, 2.5);
//new创建
Stock *pstock = new Stock("Electroshock Games", 18, 19);
//也可以用列表初始化
Stock a1 = {"A1", 1, 1.1};
Stock a2 {"A1", 1, 1.1};
Stock a3 {};
//另外,当构造函数只有一个形参时允许直接使用等号进行赋值
Stock tmp = 1;
C++中的explicit关键字只能用于修饰只有一个参数或只有第一个参数没有默认参数的类构造函数,它的作用是表明该构造函数是显式的不能进行隐式类型转换,跟它相对应的另一个关键字是implicit。类构造函数默认情况下即声明为implicit。如上面的Stock tmp = 1;就有从int到Stock的隐式转换,在构造函数名前加上explicit关键字后则禁止了这种转换,此时要只能进行显式转换Stock tmp = (Stock)1;
默认构造函数是指未提供显式初始值时用来创建对象的构造函数。默认构造函数定义方式有两种,一种是不接受任何参数,另一种是所有参数都提供默认值。当没有提供任何构造函数时C++会自动提供默认构造函数,它是默认构造函数的隐式版本。但当提供有非默认构造函数时则不会自动提供默认构造函数,此时直接定义对象而不赋予初始值则会发生错误
Stock first("Concrete Conglomerate");//调用非默认构造函数
Stock second();//声明无参数且返回值为Stock类型的函数
Stock third;//调用默认构造函数
注意构造函数也能被声明为protected或private,此时外部就不能创建这个类实例或者从这个类派生(当声明为protected时则能够派生)。因为只能从内部访问到类的构造函数,因而此时只能通过static成员函数或者友元来创建类对象,同时,还能在创建时进行某些限定,如限定程序中最多只能创建特定数量的类对象
在C++11中,为了简化构造函数的编写,提供了委托构造函数和继承构造函数。委托构造函数是指在一个构造函数的定义中使用另一个构造函数,因为构造函数暂时将创建对象的工作委托给另一个构造函数,如
class Notes {
public:
Notes();
Notes(int);
Notes(int, double);
Notes(int, double, string);
private:
int k;
double x;
string st;
}
Notes::Notes(int _k, double _x, string _st) : k(_k), x(_x), st(_st) {};
Notes::Notes() : Notes(0, 0.01, "A") {};
Notes::Notes(int _k) : Notes(_k, 0.01, "A") {};
Notes::Notes(int _k, double _x) : Notes(_k, _x, "A") {};
而继承构造函数则能进一步简化编写工作。首先在C++98中允许这么编写成员函数。此时,using表明使用了A命名空间中的func函数以及其所有重载版本,此时在B中有三个func函数,但是double类型的版本是使用B中的版本。
class A {
int func(int);
double func(double);
void func(const char* s);
}
class B : class A {
using A::func;
double func(double);
}
而在C++11中将这种方法用于构造函数,让派生类继承基类的所有构造函数(默认构造函数、复制构造函数和移动构造函数除外),但不会使用与派生类构造函数的特征标匹配的构造函数。如下面程序,此时B tmp(1, 1.1);会调用基类A中对应的构造函数,但是此时只会给基类数据成员初始化
class A {
public:
A();
A(int);
A(double);
A(int, double);
}
class B : class A {
public:
using A::A;
B();
B(int);
B(double);
}
析构函数一般用于delete在构造函数中使用new分配的内存空间。若构造函数中没有使用new则只需让编译器自动生成一个什么都不做的隐式析构函数即可。析构函数名称和构造函数一样,只需要再在函数名前加~
因为析构函数只有一个而构造函数可以有多个,所以当构造函数用到new时因当使多个构造函数都使用同样形式的new,以方便析构函数中统一delete
析构函数能显式调用,此时相当于调用一个普通的成员函数并不会真正销毁对象,而在类对象声明周期完结时还是会再隐式调用一次。若类的数据成员都在栈上时多次调用析构函数不会出现问题,而当有数据成员是存储在堆上时,多次调用析构函数则会多次调用里面的delete释放空间,造成错误
当类对象存储在栈上时,代码块运行结束则空间销毁,隐式调用析构函数。当类对象存储在堆上时,调用delete销毁空间则隐式调用析构函数。而类对象存储在定位new上(可以是堆或者栈)时销毁空间则不会调用其析构函数,因此这个为数不多的需要显示调用析构函数的例子
同构造函数一样,析构函数也能声明为private,此时可以使得类只能使用new动态地在堆上new一个新的类对象。 原因是C++是一个静态绑定的语言。在编译过程中,所有的非虚函数调用都必须分析完成。即使是虚函数,也需检查可访问性。因些,当在栈上生成对象时,对象会自动析构,也就说析构函数必须可以访问。而堆上生成对象,由于析构时机由程序员控制,所以不一定需要析构函数。此时,由于析构函数私有,所以直接delete系统不能调用其析构函数,所以还需要增加一个成员函数来调用析构函数
复制构造函数用于将一个对象以按值传递的方式复制到新创建的对象中,其一般形式为Class_name(const Class_name &);其调用方法有四种:
Date a1(a0);
Date a2 = a1;
Date a3 = Date(a2);
Date* a4 = new Date(a3);
若数据成员中含有指针类型时,则最好定义一个显式复制构造函数进行深复制
和复制构造函数一样,默认赋值运算符是用浅复制的方式,因而当有指针类型的成员函数时最后重新重载=运算符
this是一个指向当前对象的const指针,其值是当前对象的地址且不能自行修改。this指针只能在类的内部使用。通过this可以使用->来访问类的所有成员。另外,只有当对象被创建后this指针才有意义,因此不能再static成员函数中使用
this指针其实是成员函数的一个隐式形参,在调用成员函数时将对象的地址作为实参传递给this
与C语言同理,在C++类中定义的const常量的生命周期不是整个程序的运行时间,而是对象的声明周期(另外,const数据成员的初始化只能在构造函数中进行)。而类中数组的初始化需要用到的长度因此也不能用const常量来定义。此时有两种方法
//一是用static const常量,此时这些常量需要在类声明外定义
class A {
public:
static const int THREAD_NUM;
static const int MEM_BLOCK_SIZE;
static const int PORT;
};
const int A::THREAD_NUM = 100;
const int A::MEM_BLOCK_SIZE = 1024;
const int A::PORT = 8080;
//注意是static类型主要在类外初始化而不是const,并且此时需要加上const关键字而不用加static关键字
//二是用枚举常量。枚举常量不会占用对象的存储空间,它们在编译时被全部求值。枚举常量的缺点是:它的隐含数据类型是整数,其最大值有限,且不能表示浮点数
class A {
public:
enum {
THREAD_NUM = 100,
MEM_BLOCK_SIZE = 1024,
PORT = 8080
};
};
另外,和C语言一样,C++类中也不能定义两个含相同枚举元素的枚举量,此时C++提供了一种新的枚举量,其作用域为类,如
enum class egg {Small, Medium, Large, Jumbo};
enum class shirt {Small, Medium, Large, Xlarge};
egg choice = egg::Large;
shirt Jack = shirt::Large;
//也可以这样
enum egg {Small, Medium, Large, Jumbo};//此时能隐式转换成int类型
enum class shirt {Small, Medium, Large, Xlarge};//不能隐式转换成int类型,用于与int型数据比较会报错
egg choice = Large;
shirt Jack = shirt::Large;
//指定底层类型为short类型
enum class :short shirt {Small, Medium, Large, Xlarge};
类中const以及引用类型数据成员的初始化必须要在构造函数形参列表后,花括号前用冒号来初始化,这种语法叫作成员初始化列表。此时,数据成员初始化的顺序与它们出现在类声明中的顺序相同,而与成员初始化列表的顺序无关。如
class A {
public:
A(int& c): _b(2), _c(c) {
_a = 1;
}
private:
int _a;
const int _b;
int& _c;
};
注意创建对象时是先执行构造函数冒号后面的内容再执行构造函数里面的内容,且构造函数里面的初始化并不是真正意义上的初始化,而是先由声明提前创建好空间然后再在构造函数中进行赋值,所以不能直接在构造函数中初始化const常量以及引用变量,只有冒号后面的内容才是真正的初始化。此时必须在冒号后初始化这些数据成员
const成员函数有两种写法,其中的const真正修饰的是隐含参数this指针
const成员函数有两种功能
同时,const成员函数可以被具有同样函数名以及形参列表的成员函数重载,此时const类对象只能调用const版本而非const类对象则能调用两个版本但一般都会调用非const版本,两者的本质区别在于类中所有对象都会自动增加一个this指针形参,而const版本的是const指针而非const版本的是非const指针(虽然const到非const不算重载,但在类里面合法)
静态成员函数需要在函数声明时加static关键字。此时不同通过对象调用静态成员函数,同理也不能使用this指针,此时在外部调用静态成员函数时需要用类名加作用域解析运算符来调用。静态成员函数只能使用静态数据成员
运算符重载不能重载基本数据类型的运算,若要对基本数据类型进行运算符重载,也要将其放在结构体、类或者枚举类型中再进行重载。下面所讲的运算符重载均以类对象为例
对类进行运算符重载时,函数可以放在类声明内部也可以放在外部,如下
Box operator+(const Box& a); //内部,注意此时算术式的左操作数才是调用对象,所以算术式中第一个操作数为非类项只能在外部定义
Box operator+(const Box& a, const Box& b); //外部,一般将二目运算符重载函数放在外面定义并设置为友元函数,这会让程序更容易适应自动类型转换
重载限制
注意若想类对象能进行连续运算,则一些重载运算符函数最好返回对象的引用
上面有说当类含有只有一个参数的构造函数时能进行参数类型到类类型的隐式转换和显式转换。而当需要从类类型转换为其他类型时,则需要重载类型转换函数operator Typename();。转换类型函数没有返回值,没有参数。如operator int();,此时就能使用Stock tmp = 1; int a = tmp; int b = (int)tmp;。但是当类存在多个类型转换重载函数,如operator int();和operator double();时,有时会因为隐式转换而存在二义性,不知道应该转换为int还是double类型,此时仍可用显式转换解决
用new创建动态对象时,首先会使用operatoe new()为对象分配内存(通常用malloc),然后调用构造函数来初始化内存。相应的,调用delete运算符时,首先调用析构函数,然后调用operator delete()释放内存(通常用free)
使用了new和delete的内存分配系统是为了通用目的而设计的,但是在特殊的情形下并不能满足需要。最常见的改变分配系统的原因常常是出于效率考虑:
重载全局的new和delete:
void *operator new(size_t sz) {
void *m = malloc(sz);
if(!m) puts("out of memory");
return m;
}
void operator delete(void *m) {
printf("Delete");
free(m);
}
//使用printf()和puts()而不是iostream,是因为创建一个iostream对象时(像全局的cin/cout/cerr),它们调用new去分配内存。用printf()不会进入死锁状态,因为它不会调用new来初始化自身
对类重载new和delete:
class Widget {
int i[10];
public:
Widget() {}
~Widget() {}
void* operator new(size_t sz) {
cout<<"Widget::new: "<
注意事项:
通常公有类成员是访问类的唯一途径,而C++提供了另一种形式的访问途径:友元。友元有友元函数、友元类、友元成员函数三种
友元函数是定义在类外部,但有权访问类的所有私有成员和保护成员。尽管友元函数的原型有在类的定义中出现过,但是友元函数并不是成员函数。友元函数通常用于外部定义重载类的运算符时访问类的内部数据
class Box {
public:
//在类声明内部声明,在外部定义
friend void printWidth(Box box);
void setWidth(double wid);
private:
double width;
};
void Box::setWidth(double wid) { width = wid; }
void printWidth(Box box) { cout << box.width <
重载<<运算符
//第一种版本
void operator<<(ostream& os, const Date& d) {
os << d.month << " " << d.day << endl;
}
cout << data; //使用时直接用cout输出
//第二种版本
ostream& operator<<(ostream& os, const Date& d) {
os << d.month << " " << d.day;
return os;
}
cout << "Today is" << data << "!";
//第一个版本中不允许多个<<连用是因为cout << "Today is"后是先返回一个os引用,然后这个os引用再调用下一个<<运算符函数。因而要想连用只需要返回os引用就行了
重载<<运算符必须是Date类的友元函数,但并不一定是ostream类的友元函数
如在电视类和遥控器类中,两者属性并不具有包含关系,但是遥控器类需要访问到电视类的成员,因此此时需要用到友元类。做法是在电视类中声明遥控器类为友元类friend class Remote;。
可以选择仅让特定的类成员成为另一个类的友元,此时需要用到友元成员函数。在电视类中逐一声明遥控器类的与友元成员函数,此时还要加上作用域解析运算符friend void Remote::set_chan(Tv & t, int c);。
有时需要类A是类B的友元,类B也是类A的友元,此时只需要在两者类声明中各自声明另一个类是该类的友元类就可以了
C++提供了比修改代码更好的办法来扩展和修改类,这种方法叫类继承,它能够从已有的类派生出新的类,而派生类继承了原有类(基类)的特征
继承定义的一般格式为class DerivedClassName : acess-lable BaseClassName {}
acess-lable是指继承的形式,共有如下三种。当不写继承的关键字时,类默认为私有继承,结构体默认为公有继承
不管派生类以何种形式继承基类,派生类都不能访问基类的私有数据成员,只能通过基类的公有数据成员带来访问,因此派生类的构造函数必须用到基类的构造函数。又因类的构造函数实际上并不是真正意义上的初始化,所以必须用成员初始化列表来调用基类的构造函数,如下
DerivedClassName(…) : BaseClassName(…) {}
若不使用成员初始化列表来初始化基类,则会自动调用基类的默认构造函数
基类的指针或引用能指向派生类的对象,但是只能调用其基类的函数。反之派生类的指针或引用不能指向基类对象
因此,用派生对象初始化基类类对象,用派生对象给基类对象赋值也是可以的。因为此时基类对象的复制构造函数以及=重载运算符函数的参数都是基类对象的引用,因此此时会发生隐式转换
而反过来,若要基类对象能转换到派生类对象,一种方法是在派生类对象中定义参数为基类类型的复制构造函数或者赋值函数
系统会先执行基类构造函数,然后再执行派生类构造函数;析构函数则相反,系统会先执行派生类构造函数,然后再执行基类构造函数(注意派生类不能继承基类的构造函数、析构函数以及赋值函数)
如果基类有用new,派生类也用new时,派生类中的重载=运算符函数需要显式调用基类的重载=运算符函数,如
Derive& operator=(const Derive& d) {
...
Base::operator=(d);//以函数表示法的方式调用基类赋值函数
}
多态能使同一个方法在派生类和基类中的行为是不同的,也就是方法的行为取决于调用该方法的对象。C++有两种多态方式:静态多态和动态多态
派生类和基类同名成员函数的关系有三种
虚函数:在基类中在成员函数原型前添加关键字 virtual声明函数为虚函数,并告诉编译器不要静态链接到该函数。另外,C++还提供了final关键字,可以用其来限定类,声明该类不能被继承;或者是限定虚函数,声明该函数不能被重写
纯虚函数:若在基类中不想对虚函数给出有意义的实现,则需将函数声明为纯虚函数,如virtual int func() = 0;。而当基类具有至少一个纯虚函数时,称基类为抽象基类,此时:
虚析构函数:若基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时则只会调用基类的析构函数。此时派生类中申请的空间就得不到释放从而产生内存泄漏。因而需要用到虚析构函数来解决这个问题。因此通常即使不需要析构函数都应给基类提供一个虚析构函数
虚函数表:在内存中每个类的开头都有一个虚指针指向虚函数表,而虚函数表在编译时创建,存储着这个类的虚函数,关于虚函数:
class Base {
private:
virtual void f() { cout << "Base::f" << endl; }
};
class Derive : public Base {};
typedef void(*Fun)(void);
//可以使用typedef来隐藏一些指向成员函数的复杂指针
int main() {
Derive d;
Fun pFun = (Fun)*((int*)*(int*)(&d));
//(int*)(&d)为虚函数表地址
pFun();
return 0;
}
多继承是指一个派生类可以有多个基类。对于类可以继承的基类的数目,编译器没有语言强加的限制,但在一个给定派生列表中,一个基类只能出现一次。然而多继承容易让代码逻辑复杂、思路混乱,中小型项目中较少使用,后来的 Java、C#、PHP 等干脆取消了多继承。多继承的一般形式为class D : public A, private B, protected C {}
多继承的构造函数和单继承形式基本相同,一般写法为
D(形参列表) : A(实参列表), B(实参列表), C(实参列表) {}
此时多个基类构造函数的调用顺序取决于派生类继承基类的顺序,而与成员初始化列表中的调用顺序无关。对于析构函数则为其逆序
当多个基类中有同名的成员时,直接访问该成员会产生命名冲突,编译器不知道使用哪个基类的成员。此时需要在成员名字前面加上基类名和作用域解析符::以显式地指明到底使用哪个类的成员,消除二义性
多继承是指一个派生类继承自多个基类,而多重继承是指派生类继承自另一个派生类,即有多层继承关系
在多重继承中,容易造成冲突的是菱形继承,即类B、C均继承自类A,而类D则继承自类B、C。此时类D则会存在命名冲突及冗余数据等问题。C++使用虚继承来解决这个问题,使得在派生类中只保留一份间接基类的成员,虚继承的一般形式为
class A {
protected:
int m_a;
};
class B: virtual public A { //虚继承
protected:
int m_b;
};
class C: virtual public A { //虚继承
protected:
int m_c;
};
class D: public B, public C {
public:
void seta(int a) { m_a = a; }
void setb(int b) { m_b = b; }
void setc(int c) { m_c = c; }
void setd(int d) { m_d = d; }
private:
int m_d;
};
此时派生类 D 中就只保留了一份成员变量 m_a,直接访问就不会再有歧义了。虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含一份虚基类的成员。在实际开发中,将位于中间层次的基类继承声明为虚继承一般不会带来什么问题
有间接虚基类的派生类可以直接调用间接基类构造函数,但有间接非虚基类则不可以
当虚基类和非虚基类混用时,设类B为类C、D的虚基类,以及类X、Y的非虚基类,而类M则从C、D、X、Y派生而来,此时类M从虚派生祖先类C、D共同继承一个类B子对象,并从每个非虚派生祖先类X、Y分别继承了一个类B子对象。因而类M共包含三个类B子对象
另外,二义性通常发生在不同的派生路径之间,而同一条派生路径中派生类的成员优先级会比基类成员的优先级要高,此时会优先使用派生类的成员而不会产生二义性
嵌套类或嵌套类型是指在一个类的内部定义另一个类。嵌套类只用于外围类的实现,且同时可以对用户隐藏该底层实现。外围类对嵌套类成员的访问没有任何特权,嵌套类对外围类成员的访问也是如此。在外部定义嵌套类时,需要加上作用域运算符
class A {
public:
void func();
private:
class B;
B* m_b;//嵌套类只能声明嵌套类的指针和引用,定义为B m_b而不是B* m_b将会引发一个编译错误
};
当嵌套类是在外围类的保护部分或私有部分声明时嵌套类只能由外围类本身访问使用,而当嵌套类是在公有部分声明时则外部世界也是使用嵌套类,只是此时需要加上作用域解析运算符
另外,嵌套类可以直接引用外围类的静态成员、类型名和枚举成员(假定这些成员是公有的)(类型名是一个typedef名字、枚举类型名、一个类名、或模板中的类型参数)
若要想让嵌套类能够访问外围类,因为嵌套类的数据块存储在外围类的数据块中,因此只需要用嵌套类this指针减去外围类this指针得到偏移量就能在嵌套类内得到指向外围类的指针,进而就能访问并修改外围类了
局部类是指定义在函数体内的类。 局部类只在定义它的局部域内可见,与嵌套类不同的是,在定义该类的局部域外没有语法能够引用局部类的成员。注意局部类的成员函数必须被定义在类定义中,并且局部类中不能说明静态成员函数
void func() {
static int s = 0;
class Local {
public:
void init(int i) { s = i; }
};
Local m;
m.init(10);
}