C++知识点总结(二)
12.类和对象
12.1 类的封装
1.成员变量和成员函数
(1)成员变量
C++中用于表示类属性的变量。
类的成员变量和普通变量一样,也有数据类型和名称,占用固定长度的内存。但是,在定义类的时候不能对成员变量赋值,因为类只是一种数据类型或者说是一种模板,本身不占用内存空间,而变量的值则需要内存来存储。
(2)成员函数
它的作用范围由类来决定;而普通函数是独立的,作用范围是全局的,或位于某个命名空间内。
(3)C++中可以给成员变量和成员函数定义访问级别。
public:成员变量和成员函数可以在类的内部和外界访问和调用。
private:成员变量和成员函数只能在类的内部被访问和调用。
(4)注意:
1)所有对象共享类的成员函数。
2)普通成员函数不能通过类名来直接调用,它需要通过具体的对象来调用。
3)通过类名可以直接访问public成员变量。 下面的语句中:
class Test
{
public:
int n;
int func()
{
return n;
}
};
Test :: n //合法
Test :: func() //不合法
2.类的作用域
-
类成员的作用域都只在类的内部,外部无法直接访问。
-
成员函数可以直接访问成员变量和调用成员函数。
-
类的外部可以通过类变量访问public成员。
-
类成员的作用域与访问级别没有关系。
-
C++中用struct定义类时,所有成员的默认访问级别为public,用class定义类时,所有成员的默认访问级别为private。
12.2 对象的构造和析构
1.构造函数
C++提供了一种特殊的成员函数,它的名字和类名相同,没有返回值,不需要用户显式调用,而是在创建对象时自动执行。这种特殊的成员函数就是构造函数,构造函数主要进行初始化工作。构造函数的调用是强制性的,一旦在类中定义了构造函数,那么创建对象时就一定要调用,不调用是错误的。如果有多个重载的构造函数,那么创建对象时提供的实参必须和其中的一个构造函数匹配;反过来说,创建对象时只有一个构造函数会被调用。
和普通成员函数一样,构造函数是允许重载的。一个类可以有多个重载的构造函数,创建对象时根据传递的实参来判断调用哪一个构造函数。
2.初始化列表
初始化与赋值不同:
初始化:对正在创建的对象进行初值设置。
赋值:对已经存在的对象进行值设置。
C++提供了初始化列表用于在构造函数中对成员变量进行初始化。初始化列表可以用于全部成员变量,也可以只用于部分成员变量。
注意:
(1)成员变量的初始化顺序与初始化列表中列出的变量的顺序无关,它只与成员变量在类中声明的顺序有关。
(2)初始化 const 成员变量的唯一方法就是使用初始化列表,如下所示:
class A
{
public:
A(int len);
private:
const int m_len;
};
//必须使用初始化列表来初始化 m_len
A::A(int len): m_len(len)
{
...
}
3.析构函数
与构造函数相对应的是析构函数,主要进行对象销毁工作,在销毁对象时自动执行。
注意:析构函数没有参数,不能被重载,因此一个类只能有一个析构函数。如果用户没有定义,编译器会自动生成一个默认的析构函数。
4.特殊的构造函数
(1)无参构造函数
如果类中没有定义构造函数,那么编译器会自动生成一个默认的无参构造函数,只是这个构造函数的函数体是空的,也没有形参,也不执行任何操作。
(2)拷贝构造函数
如果类中没有定义拷贝构造函数,那么编译器会自动生成一个默认的拷贝构造函数,该函数的功能是复制成员变量。以下情况会调用拷贝构造函数:
-
一个对象通过另外一个对象初始化时。
-
一个对象以值传递的方式传入函数体,需要拷贝构造函数创建一个临时对象压入到栈空间中。
-
一个对象以值传递的方式从函数返回,需要执行拷贝构造函数创建一个临时对象作为返回值。
拷贝构造函数必须使用引用传递。当一个对象需要以值传递的方式进行传递时,编译器会调用它的拷贝构造函数生成一个副本,如果类A的拷贝构造函数的参数不是引用传递,而采用值传递,那么就又需要为了创建传递给拷贝构造函数的参数的临时对象,而又一次调用类A的拷贝构造函数,这是一个无限递归调用。
(3)浅拷贝和深拷贝的区别:
浅拷贝没有开辟新的空间,拷贝的指针和原来的指针指向同一片内存区域,如果原来的指针所指向的资源释放了,那么再次释放浅拷贝的指针就会出错。
对于简单的类,默认的拷贝构造函数一般就够用了,我们也没有必要再显式地定义一个功能类似的拷贝构造函数。但是当类持有其它资源时,例如动态分配的内存、指向其他数据的指针等,默认的拷贝构造函数就不能拷贝这些资源了,我们必须显式地定义拷贝构造函数,以完整地拷贝对象的所有数据,这就是深拷贝。深拷贝会开辟新的空间用来存放新的值,即使原来的对象被释放掉,不会影响深拷贝得到的值。
如果一个类拥有指针类型的成员变量,那么绝大部分情况下就需要深拷贝。因为只有这样,才能将指针指向的内容再复制出一份来,让原有对象和新生对象相互独立,彼此之间不受影响。如果类的成员变量没有指针,一般浅拷贝足以。
示例如下:
class Student
{
private:
char *name;
public:
Student()
{
name = new char(10);
cout << "Student()" << endl;
}
Student(const Student &s)
{
//浅拷贝,对象的name和传入对象的name指向相同的地址
//this->name = s.name;
//cout << "shallow copy" << endl;
//深拷贝
this->name = new char(10);
memcpy(this->name, s.name, strlen(s.name));
cout << "deep copy" << endl;
}
~Student()
{
cout << "~Student() " << &name << endl;
delete name;
name = nullptr;
};
};
int main()
{
Student stu1;
Student stu2(stu1);
return 0;
}
/*浅拷贝由于两个指针指向相同的地址,所以释放s2时会报错,运行结果如下
*Student()
*shallow copy
*~Student() 001EFBC0
*~Student() 001EFBCC
*报错
*/
/*深拷贝正常运行,运行结果如下:
*Student()
*deep copy
*~Student() 001EFBC0
*~Student() 001EFBCC
*/
(4)转换构造函数
转换构造函数用于将其他类型的变量,隐式转换为本类对象,转换构造函数的形参是其他类型变量,且只有一个形参,如下所示:
class Student
{
private:
string name;
int score;
public:
Student{}:name(""), score(0){}
//转换构造函数,形参是其他类型变量,且只有一个形参
Student(int grade):name("Jack"), score(grade){ }
~Student(){}
}
(5)移动构造函数
移动构造函数是C++ 11新增的构造函数,该构造函数以移动而非深拷贝的方式初始化含有指针成员的类对象。对于程序执行过程中产生的临时对象,往往只用于传递数据(没有其它的用处),并且会很快会被销毁。因此在使用临时对象初始化新对象时,我们可以将其包含的指针成员指向的内存资源直接移给新对象所有,无需再新拷贝一份,这大大提高了初始化的执行效率。
5.赋值函数
(1)C++的一个空类,编译器会默认加入的成员函数:
1)无参构造函数
2)拷贝构造函数
3)析构函数
4)赋值函数
(2)构造函数和赋值函数的区别
1)对象不存在,且没用别的对象来初始化,就是调用了构造函数;
2)对象不存在,且用别的对象来初始化,就是拷贝构造函数;
3)对象存在,用别的对象来给它赋值,就是赋值函数。
//调用拷贝构造函数
A a;
A b(a);
A b=a;
//调用赋值函数
A a;
A b;
b=a;
12.3 对象的内存模型
类是创建对象的模板,不占用内存空间,不存在于编译后的可执行文件中;而对象是实实在在的数据,需要内存来存储。对象被创建时会在栈区或者堆区分配内存。C++编译器会将成员变量和成员函数分开存储:分别为每个对象的成员变量分配内存,但是所有对象都共享同一段函数代码(成员变量在堆区或栈区分配内存,成员函数在代码区分配内存)。如下图所示:
对象的大小只受成员变量的影响,和成员函数没有关系。示例代码如下:
class Student
{
private:
char *m_name;
int m_age;
float m_score;
public:
void setname(char *name){}
void setage(int age){}
void setscore(float score){}
void show(){}
};
int main()
{
//在栈上创建对象
Student stu;
cout<<sizeof(stu)<<endl;
//在堆上创建对象
Student *pstu = new Student();
cout<<sizeof(*pstu)<<endl;
//类的大小
cout<<sizeof(Student)<<endl;
return 0;
}
/*运行结果如下
*12
*12
*12
*解释:Student类包含三个成员变量,它们的类型分别是 char *、int、float,都占用 4 个字节的内存,加起来共占用 12 个字节的内存
*/
上述代码中:假设 stu 的起始地址为 0X1000,那么该对象的内存分布如下所示:
13.继承
13.1 继承的访问级别
继承指类之间的父子关系,子类的对象可以获得父类的对象的属性和方法,并可以添加父类中没有的属性和方法。子类对象可以直接初始化父类对象,也可以直接赋值给父类对象。
面向对象中的访问级别主要有三种,分别是:public、private和protected。其中private成员不能被外界访问,子类也不能访问父类的private成员;protected成员也不能被外界访问,但是子类可以访问父类的protected成员。
面向对象中还存在三种继承关系:
-
public继承:父类成员在子类中保持原有访问级别
-
private继承:父类在子类中变为私有成员
-
private继承:父类中的public成员在子类中变为protected成员,其它成员不变
一般情况下,只使用public继承。
不同的继承方式和访问级别总结如下表:
继承方式\父类成员访问级别 | public | protected | private |
---|---|---|---|
public | public | protected | private |
protected | protected | protected | private |
private | private | private | private |
使用 using 关键字可以改变父类成员在子类中的访问权限,例如将 public 改为 private、将 protected 改为 public。但是using 只能改变父类中 public 和 protected 成员的访问权限,不能改变 private 成员的访问权限,因为父类中 private 成员在子类中是不可见的,根本不能使用,所以父类中的 private 成员在子类中无论如何都不能访问。 示例如下:
class Parent
{
public:
int a;
protected:
char *name;
private:
double num;
}
class Child : public Parent
{
public:
using Parent::name; //将protected改为public
private:
using Parent::a; //将public改为private
}
13.2 继承的构造函数和析构函数
1.构造函数
子类中的构造函数需要对从父类中继承的成员进行初始化,对于父类中的private对象,需要子类调用父类的构造函数来完成初始化。
子类对象创建时,构造函数的调用顺序为:1)调用父类的构造函数;2)调用自身的构造
注意:子类构造函数中只能调用直接父类的构造函数,不能调用间接父类的。
例如:假设A——>B——>C。
以上面的 A、B、C 类为例,C 是最终的子类,B 就是 C 的直接父类,A 就是 C 的间接父类。
2.析构函数
析构函数调用的调用顺序与构造函数相反。
示例如下:
class A
{
public:
A()
{
cout << "A()"<< endl;
}
~A()
{
cout << "~A()"<< endl;
}
};
class B : public A
{
public:
B()
{
cout << "B()"<< endl;
}
~B()
{
cout << "~B()"<< endl;
}
};
class C : public B
{
public:
C()
{
cout << "C()"<< endl;
}
~C()
{
cout << "~C()"<< endl;
}
};
int main()
{
C c;
return 0;
}
/*运行结果如下:
*A()
*B()
*C()
*~C()
*~B()
*~A()
*/
13.3 继承时对象的内存模型
当发生类的继承关系时,子类的内存模型可以看成是父类成员变量和子类新增成员变量的总和,其中内存分布时父类对象排在前面,子类对象排在后面。示例如下:
class A
{
public:
int m_a;
int m_b;
...
};
class B: public A
{
public:
int m_c;
...
};
class C: public B
{
public:
int m_d;
...
};
int main()
{
C obj_c;
}
上述示例中,假设 obj_c 的起始地址为 0X1200,那么它的内存分布如下图所示:
如果有成员变量遮蔽,仍然会留在子类对象的内存中,示例如下:
class A
{
public:
int m_a;
int m_b;
...
};
class B: public A
{
public:
int m_c;
...
};
class C: public B
{
public:
int m_b; //遮蔽A类的成员变量
int m_c; //遮蔽B类的成员变量
int m_d;
...
};
int main()
{
C obj_c;
}
上述示例中,假设 obj_c 的起始地址为 0X1300,那么它的内存分布如下图所示:
13.4 多重继承和虚继承
1.多重继承
C++支持多重继承,即一个子类可以有两个或多个父类,子类继承所有父类的成员函数,子类对象可以当作任意父类对象使用(多重继承容易让代码逻辑复杂、思路混乱,一直备受争议,中小型项目中较少使用),多重继承示例如下:
class D: public A, private B, protected C
{
...
}
//D 是多重继承形式的子类,它以公有的方式继承 A 类,以私有的方式继承 B 类,以保护的方式继承 C 类。
多重继承形式下的构造函数和单继承形式基本相同,只是要在子类的构造函数中调用多个父类的构造函数。父类构造函数的调用顺序和和它们在子类构造函数中出现的顺序无关,而是和声明子类时父类出现的顺序相同。析构函数的执行顺序则和构造函数的执行顺序相反,例如:
class D: public A, private B, protected C
{
public:
D(形参列表): B(实参列表), C(实参列表), A(实参列表)
{
...
}
}
//上述代码中根据声明子类时父类出现的顺序,先调用 A 类的构造函数,再调用 B 类构造函数,最后调用 C 类构造函数。
当两个或多个基类中有同名的成员时,如果直接访问该成员,就会产生命名冲突,编译器不知道使用哪个父类的成员。这个时候需要在成员名字前面加上类名和域解析符::
,以显式地指明到底使用哪个类的成员,消除二义性。
2.虚继承和虚基类
多重继承会引发各种各样的问题,例如,发生菱形继承时,可能产生冗余的成员,如下图所示:
类 A 派生出类 B 和类 C,类 D 继承自类 B 和类 C,这个时候类 A 中的成员变量和成员函数继承到类 D 中变成了两份,访问时也会产生歧义。
为了解决多重继承时的命名冲突和冗余数据问题,C++提出了虚继承,使得在子类中只保留一份间接父类的成员。在继承方式前面加上 virtual 关键字就是虚继承,示例如下:
//间接父类A
class A
{
protected:
int m_a;
};
//直接父类B
class B: virtual public A //虚继承
{
protected:
int m_b;
};
//直接父类C
class C: virtual public A //虚继承
{
protected:
int m_c;
};
//子类D
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;
};
虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类(Virtual Base Class),本例中的 A 就是一个虚基类。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含一份虚基类的成员。但是在实际应用,无法确定使用直接继承还是虚继承。
14.多态
14.1 同名函数覆盖
同名覆盖:如果子类中的成员(包括成员变量和成员函数)和父类中的成员同名,那么就会覆盖从父类继承过来的成员。在子类中使用该成员时,实际上使用的是子类新增的成员,而不是从父类继承来的。需要通过域解析符::
访问父类中同名成员。
赋值兼容:子类对象可以当作父类对象使用,父类指针和引用也可以作用于子类对象
函数重写:
①子类可以重定义父类中已经存在的成员函数,这种重定义发生在继承中,叫做函数重写。子类可以重写父类中的函数,父类中被重写的函数依然会继承给子类,子类中重写的函数将覆盖父类中的函数。
②父类指针可以指向子类对象,但是通过父类指针只能访问子类的成员变量,但是不能访问子类的成员函数。这是因为编译期间,编译器只能根据指针的类型判断所指向的对象,根据赋值兼容原理,编译器认为父类指针指向的是父类对象,所以父类指针只会调用父类中的同名函数。
③子类指针不能指向父类对象,子类引用也不能引用父类对象,除非进行强制类型转换。
示例如下:
class A
{
public:
void func()
{
cout << "Parent Class" << endl;
}
};
class B : public A
{
public:
void func()
{
cout << "Child Class" << endl;
}
};
int main()
{
A a;
B b;
a.func();
A *pa = &b; //父类指针指向子类对象
pa->func();
return 0;
}
/*运行结果如下:
*Parent Class
*Parent Class
*/
重写和重载的区别:
静态联编:在程序的编译期间就能确定具体的函数调用(重载)
动态联编:在程序实际运行后才能确定具体的函数调用(重写)
14.2 多态和虚函数
1.多态的概念和意义
C++中,通过父类指针只能访问子类的成员变量,但是不能访问子类的成员函数。我们期望父类指针指向父类对象则调用父类的成员(包括成员函数和成员变量),指向子类对象则调用子类中的成员。C++中新增了虚函数,虚函数的声明需要在函数声明前面增加 virtual 关键字。
有了虚函数,父类指针可以按照基类的方式来做事,也可以按照子类的方式来做事,它有多种形态,或者说有多种表现方式,我们将这种现象称为多态(Polymorphism)。示例如下:
class A
{
public:
virtual void func() //虚函数
{
cout << "Parent Class" << endl;
}
};
class B : public A
{
public:
void func()
{
cout << "Child Class" << endl;
}
};
int main()
{
A a;
B b;
a.func();
A *pa = &b; //父类指针指向子类对象
pa->func();
return 0;
}
/*运行结果如下:
*Parent Class
*Child Class
*/
上述示例中,将func改为虚函数,使得父类指针指向子类对象时,访问的是子类成员。
构成多态的条件
1)必须存在继承关系;
2)继承关系中必须有同名的虚函数,并且它们是覆盖关系(函数原型相同)。
3)存在父类的指针,通过该指针调用虚函数。
2.虚函数使用注意事项
1)虚函数是根据指针的指向来调用的,指针指向哪个类的对象就调用哪个类的虚函数。
2)只需要在虚函数的声明处加上 virtual 关键字,函数定义处可以加也可以不加。
3)为了方便,可以只将父类中的函数声明为虚函数,这样所有子类中具有覆盖关系的同名函数都将自动成为虚函数。
4)当在父类中定义了虚函数时,如果派生类没有定义新的函数来覆盖此函数,那么将使用父类的虚函数。
5)只有子类的虚函数覆盖父类的虚函数(函数原型完全相同)才能构成多态(通过父类指针访问子类函数),例如:
父类虚函数的原型为:virtual void func(),
子类虚函数的原型为:virtual void func(int),
那么当父类指针p指向子类对象时,语句p -> func(100);将会出错,而语句p -> func();将调用父类的函数。
6)声明虚函数的条件: 首先看成员函数所在的类是否会作为父类,然后看成员函数在类的继承后有无可能被更改功能,如果希望更改其功能的,一般应该将它声明为虚函数。如果成员函数在类被继承后功能不需修改,或子类用不到该函数,则不要把它声明为虚函数。
7)不能是虚函数的函数
构造函数。对于父类的构造函数,它仅仅是在子类构造函数中被调用,这种机制不同于继承。也就是说,子类不继承父类的构造函数,将构造函数声明为虚函数没有什么意义。
内联函数。 内联函数表示在编译阶段进行函数体的替换操作,而虚函数在运行期间确定,所以不能是虚函数。
静态函数。静态函数不属于对象属于类,没有this指针,因此将静态函数设置为虚函数没有什么意义。
友元函数。友元函数不属于类的成员函数,不能被继承,所以不能是虚函数。
普通函数。普通函数不是类的成员函数,不存在继承关系。
14.3 虚函数的实现原理
1.虚函数表和虚指针
当声明一个虚函数时,编译器会为该类生成一个虚函数表,虚函数表是存储成员函数地址的数据结构,类中virtual成员函数会被放入虚函数表中。继承该类的子类也会生成一个虚函数表,当使用该类定义对象时,会为该类的对象定义一个虚函数指针,指向该类型的虚函数表,这个虚函数指针的初始化是在构造函数中完成的。如果有一个父类指针指向子类对象,那么当调用虚函数时,就会根据所指真正对象的虚函数表去寻找虚函数的地址,也就可以调用虚函数表中的虚函数,以此实现多态。
C++中一般情况下,空类的大小是1,这是为了让对象的实例能够相互区别。具体来说,空类同样可以被实例化,并且每个实例在内存中都有独一无二的地址,因此,编译器会给空类隐含加上一个字节,这样空类实例化之后就会拥有独一无二的内存地址。当该空类作为父类时,该类的大小就优化为0了,子类的大小就是子类本身的大小。
静态成员存放在静态存储区,不占用类的大小, 普通函数也不占用类大小。
但如果类中包含一个虚函数,那么此时类的大小就变为了4(64位机器是8),因为有虚函数的类对象中都有一个虚函数表指针。
class A {}; //类的大小为1
class B {int a; static int b}; //类的大小为4
class C {virtual void func(){}}; //类的大小为4(64位机器为8)
虚函数指针和虚函数表示例如下:
上图中,调用成员函数时,首先会判断该函数是不是虚函数,如果是,编译器会到当前对象里去查找虚指针,目的就是查找该虚指针所指向的虚函数表,再在虚函数表里找到该成员函数的地址,最后通过这个地址调用具体的成员函数。这个过程中涉及到两个地址,一个是虚指针指向的虚函数表的地址,一个是虚函数表中要查找的对应虚函数的地址。
当发生继承关系时,子类会拷贝父类的虚函数表,如果子类中有重写父类中的虚函数,就替换成已经重写的虚函数地址,如果子类有自身的虚函数,就追加自身的虚函数到虚函数表中。
2.虚函数表和虚函数的存放区域
首先整理一下虚函数表的特征:
-
虚函数表是全局共享的元素,即全局仅有一个,在编译时就构造完成。
-
虚函数表类似一个数组,类对象中存储虚指针,指向虚函数表,即虚函数表不是函数,不是程序代码。
-
虚函数表存储虚函数的地址,即虚函数表的元素是指向类成员函数的指针,而类中虚函数的个数在编译时期可以确定,即虚函数表的大小可以确定,即大小是在编译时期确定的。
在C++中,内存模型一般分为五个区域:栈区、堆区、函数区(存放函数体等二进制代码)、全局静态区、常量区。
C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。
3.析构函数声明成虚函数
一般要将析构函数声明成虚函数,这是为了降低内存泄露的可能性。例如:一个父类指针指向子类的对象,在使用完准备销毁时,如果父类析构函数没有声明成虚函数,那么编译器根据指针类型就会认为当前对象是父类,调用父类的析构函数,而子类的析构函数则没有被调用。而如果父类析构函数声明为虚函数,那么编译器就会根据实际对象,执行子类的析构函数,再执行父类的析构函数。
4.构造函数调用顺序总结
-
虚基类构造函数(被继承的顺序)
-
非虚基类构造函数(被继承的顺序)
-
成员对象构造函数(声明顺序)
-
自己的构造函数
15.抽象类和接口
可以将虚函数声明为纯虚函数,示例如下:
virtual void func(int a, int b) = 0;
纯虚函数没有函数体,只有函数声明,在虚函数声明的结尾加上=0
,表明此函数为纯虚函数。
包含纯虚函数的类称为抽象类(Abstract Class)。抽象类无法实例化,也就是无法创建对象。原因很明显,纯虚函数没有函数体,不是完整的函数,无法调用,也无法为其分配内存空间。抽象类只能被继承并重写相关函数。一个纯虚函数就可以使类成为抽象类,但是抽象类中除了包含纯虚函数外,还可以包含其它的成员函数(虚函数或普通函数)和成员变量。
当设计一个类时,如果确认这个类是父类,那就需要考虑它有没有可能成为一个抽象类。 判断标准:父类有没有必要产生对象?如果没有,就可以设计成一个抽象类。
例如:图形就是一个抽象的概念,因为图形有很多种,圆形、矩形或者三角形等等。在现实中需要知道具体的图形类型才能求面积。所以,如果存在图形类Shape,那么它只是一个概念上的类型,没有具体的对象。Shape这个类的的作用就是专门用来被继承,由继承它的类来实现不同的功能。
有一种特殊的抽象类,叫做接口,满足如下条件的C++类称为接口:
-
类中没有定义任何的成员变量。
-
所有的成员函数都是公有的。
-
所有的成员函数都是纯虚函数
16.模板
16.1 函数模板
函数模板是一种特殊的函数,可用不同类型进行调用,看起来和普通函数很相似,区别是,类型可被参数化。
函数模板语法规则如下:
template <typename T1, typename T2,...>
void func(T1& a, T2& b)
{
...
}
其中,template关键字表示声明这是函数模板,它后面紧跟尖括号,typename关键字表示声明具体的类型参数。typename关键字也可以使用class关键字替代,它们没有任何区别。
从整体上看,template<typename T>
被称为模板头。模板头中包含的类型参数可以用在函数定义的各个位置,包括返回值、形参列表和函数体,定义了函数模板后,就可以像调用普通函数一样来调用它们了。编译器会根据实参的类型自动推导T的类型(编译器对函数模板进行两次编译,对模板进行编译,对参数替换后的代码进行编译)。
函数模板可以像普通函数一样被重载,对于多参数函数模板,可以从左向右显式的指明部分实参,例如:
//重载函数模板
template<class T> void func(T &a, T &b);
template<typename T> void func(T a[], T b[], int len);
//显式指明实参
template<typename T1, typename T2> void func(T1 a)
{
T2 b;
}
func<int>(10); //省略 T2 的类型
func<int, int>(20); //指明 T1、T2 的类型
16.2 类模板
1.类模板的定义
C++除了支持函数模板,还支持类模板,函数模板中定义的类型参数可以用在函数声明和函数定义中,类模板中定义的类型参数可以用在类声明和类实现中。类模板的目的同样是将数据的类型参数化。
类模板语法规则如下:
template<typename T1 , typename T2 , ...>
class Test
{
...
};
类模板只能显式指定具体类型,不能自动推导类型,且类模板必须在头文件中定义,声明和实现必须在同一文件中。
类模板定义和使用示例如下所示:
template<typename T1, typename T2> //这里不能有分号
class Test
{
private:
T1 m_a;
T2 m_b;
public:
T1 getA() const
{
return m_a;
}
T2 getB() const
{
return m_b;
}
};
//创建对象
Test<int, double> t1(1, 1.5);
Test<int, char*> t2(12.4, "Hello Template");
Test<int, double*> *p1 = new Test<int, double>(5, 7.2);
2.类模板的特例化
类模板可以被特例化,特例化的本质是实例化一个模板,是模板的分开实现,而非重载它,特例化不影响参数匹配,参数匹配都以最佳匹配为原则。与函数模板不同,函数模板只支持完全特例化,而在类中,可以对类模板进行完全特例化,也可以对类模板进行部分特例化。例如:
template<typename T1, typename T2>
class Test
{
public:
void print()
{
cout << "普通类模板" << endl;
}
};
//完全特例化
template<>
class Test<int, int> // 当 Test 类模板的两个参数都是int时,使用这个实现
{
public:
void print()
{
cout << "完全特例化" << endl;
}
};
//部分特例化
template<typename T>
class Test< T, T > // 当 Test 类模板的两个参数类型完全相同时,使用这个实现
{
public:
void print()
{
cout << "部分特例化" << endl;
}
};
int main()
{
Test<int, double> t1;
Test<int, int> t2;
Test<double, double> t3;
t1.print();
t2.print();
t3.print();
return 0;
}
/*运行结果如下:
*普通类模板
*完全特例化
*部分特例化
*/
17.异常
1.异常的概念
程序的错误大致可以分为三种,分别是语法错误、逻辑错误和运行时错误:
1) 语法错误在编译和链接阶段就能发现,只有 100% 符合语法规则的代码才能生成可执行程序。语法错误是最容易发现、最容易定位、最容易排除的错误,程序员最不需要担心的就是这种错误。
2) 逻辑错误是说我们编写的代码思路有问题,不能够达到最终的目标,这种错误可以通过调试来解决。
3) 运行时错误是指程序在运行期间发生的错误,例如除数为 0、内存分配失败、数组越界、文件不存在等。异常(Exception)机制就是为解决运行时错误而引入的。
C++ 异常处理机制让我们能够捕获运行时错误,主要涉及try、catch、throw 三个关键字,异常处理流程如下:
抛出(Throw)--> 检测(Try) --> 捕获(Catch)
异常的语法如下:
//捕获异常
try
{
// 可能抛出异常的语句
}
catch(exceptionType variable)
{
// 处理异常的语句
}
//抛出异常
throw exceptionData; //exceptionData为异常数据
throw抛出的异常必须被catch处理,如果当前能够处理,则程序继续执行,否则将异常向上传递,如果所有函数都无法处理异常,程序将停止执行
2.异常类型及匹配规则
catch 关键字后面可以定义异常类型,它指明了当前的 catch 可以处理什么类型的异常,异常类型可以是 int、char、float、bool 等基本类型,也可以是指针、数组、字符串、结构体、类等聚合类型。可以将 catch 看做一个没有返回值的函数,当异常发生后 catch 会被调用,并且会接收实参(异常数据)。不同的是,异常类型和 catch 能处理的类型是在运行阶段匹配的。
一个 try 后面可以跟多个 catch,异常抛出后,自上而下严格匹配每个catch的类型,可以使用catch(...)的方式捕获任何异常,例如:
try
{
throw 0;
}
catch(exceptionType variable_1)
{
}
catch(exceptionType variable_2)
{
}
catch(exceptionType_n variable_n)
{
}
catch(...)
{
}
注意:
1)catch 在匹配异常类型的过程中,也会进行类型转换,但是这种转换受到了更多的限制,仅能进行:
-
向上转型:子类向父类的转换
-
const 转换:将非 const 类型转换为 const 类型,例如将 char * 转换为 const char *
-
数组或函数指针转换:如果函数形参不是引用类型,那么数组名会转换为数组指针,函数名也会转换为函数指针。
其他的都不能应用于 catch。
2)当有继承关系时,子类的异常对象可以被父类的catch语句块抓住。
3)构造函数和析构函数最好不要抛出异常,否则万一异常处理失败则容易发生内存泄露。
3.exception类
C++语言本身或者标准库抛出的异常都是 exception 的子类,称为标准异常(Standard Exception)。可以通过下面的语句来捕获所有的标准异常:
try
{
//可能抛出异常的语句
}
catch(exception &e)
{
//处理异常的语句
}
exception 类位于 <exception> 头文件中,它被声明为:
class exception
{
public:
exception () throw(); //构造函数
exception (const exception&) throw(); //拷贝构造函数
exception& operator= (const exception&) throw(); //运算符重载
virtual ~exception() throw(); //虚析构函数
virtual const char* what() const throw(); //虚函数
}
what() 函数返回一个能识别异常的字符串,正如它的名字“what”一样,可以粗略地告诉你这是什么异常。不过C++标准并没有规定这个字符串的格式,各个编译器的实现也不同,所以 what() 的返回值仅供参考。
exception类的继承层次如图所示:
总结如表所示:
异常名称 | 说明 | 子类异常名称 | 子类异常说明 |
---|---|---|---|
logic_error | 逻辑错误。 | length_error | 试图生成一个超出该类型最大长度的对象时抛出该异常,例如 vector 的 resize 操作。 |
domain_error | 参数的值域错误,主要用在数学函数中,例如使用一个负值调用只能操作非负数的函数。 | ||
out_of_range | 超出有效范围。 | ||
invalid_argument | 参数不合适。在标准库中,当利用string对象构造 bitset 时,而 string 中的字符不是 0 或1 的时候,抛出该异常。 | ||
runtime_error | 运行时错误。 | range_error | 计算结果超出了有意义的值域范围。 |
overflow_error | 算术计算上溢。 | ||
underflow_error | 算术计算下溢。 | ||
bad_alloc | 使用 new 或 new[ ] 分配内存失败时抛出的异常。 | ||
bad_typeid | 使用 typeid 操作一个 NULL指针,而且该指针是带有虚函数的类,这时抛出 bad_typeid 异常。 | ||
bad_cast | 使用 dynamic_cast 转换失败时抛出的异常。 | ||
ios_base::failure | io 过程中出现的异常。 | ||
bad_exception | 这是个特殊的异常,如果函数的异常列表里声明了 bad_exception 异常,当函数内部抛出了异常列表中没有的异常时,如果调用的 unexpected() 函数中抛出了异常,不论什么类型,都会被替换为 bad_exception 类型 |
18.智能指针
内存泄露发生之后,软件可能会遇到卡死、无响应或者反应很慢等问题, C++中智能指针主要用于解决内存泄露问题。智能指针是一个类,用来存储指向动态分配对象的指针,负责自动释放动态分配的对象,防止堆内存泄漏。动态分配的资源,交给一个类对象去管理,当类对象声明周期结束时,自动调用析构函数释放资源。
实现智能指针的关键步骤:重载指针特征操作符(->和*);只能通过类的成员函数重载(智能指针的本质是类的对象);
更多关于智能指针的知识点,将在C++11新特性中总结。
参考:
-
《C++ Primer 第5版》
-
《深入理解C++对象内存模型》
-
《Effective C++》