本文档依据中国MOOC程序设计与算法(三)C++面向对象程序设计撰写
第一章从C到C++
类和对象的基本概念与用法(1)
对象的内存分配:成员函数不被包括在对象的内存吗?
第二章 类和对象基础
1.类和对象的基本概念(2)
question1
c++
//假设A是一个类的名字,下面的程序片段会调用类A的析构函数几次? 答案:3次
int main() {
A * p = new A[2];
A * p2 = new A;
A a;
delete [] p;
}
不能访问私有成员变量?
2. 构造函数
对象所占用的存储空间是不是也是构造函数分配的?
answer:对象函数是在对象已经占用存储空间以后,在对象存储空间中做初始化的操作
对象一定有构造函数,如果定义类的时候没有写构造函数,则编译器生成个默认的无参数的构造函数
- 默认构造函数无参数,不做任何操作
构造函数在数组中的使用
c++
class Test {
public:
Test( int n){}//(1)
Test( int n, int m) {}//(2)
Test(){}//(3)
};
Test array1[3]= { 1, Test(1,2)};//三个元素分别用(1),(2),(3)初始化
Test array2[3]= { Test(2,3), Test(1,2),1};(/三个元素分别用(2),(2), (1)初始化
Test * pArray[3]= { new Test(4), new Test(1,2)}//两个元素分别用(1),(2)初始化
对于代码中的 Test array1[3] = {1, Test(1,2)}
,它创建了一个名为 array1
的 Test
类型的数组,并初始化了其中的元素。
Test(1,2)
是以参数值 1
和 2
调用 Test
类的构造函数 (2)
来创建一个临时对象。这个临时对象将被用来初始化 array1
中的第二个元素。
因此,Test(1,2)
将调用 (2)
构造函数,而不是其他的构造函数 (1)
或 (3)
。
3.复制(拷贝)构造函数
一. 什么是拷贝构造函数
首先对于普通类型的对象来说,它们之间的复制是很简单的,例如:
c++
1. int a = 100;
1. int b = a;
而类对象与普通对象不同,类对象内部结构一般较为复杂,存在各种成员变量。
下面看一个类对象拷贝的简单例子。
c++
#include <iostream>
using namespace std;
class CExample {
private:
int a;
public:
//构造函数
CExample(int b)
{ a = b;}
//一般函数
void Show ()
{
cout<<a<<endl;
}
};
int main()
{
CExample A(100);
CExample B = A; //注意这里的对象初始化要调用拷贝构造函数,而非赋值
B.Show ();
return 0;
}
运行程序,屏幕输出100。从以上代码的运行结果可以看出,系统为对象 B 分配了内存并完成了与对象 A 的复制过程。就类对象而言,相同类型的类对象是通过拷贝构造函数来完成整个复制过程的。
下面举例说明拷贝构造函数的工作过程。
c++
#include <iostream>
using namespace std;
class CExample {
private:
int a;
public:
//构造函数
CExample(int b)
{ a = b;}
//拷贝构造函数
CExample(const CExample& C)
{
a = C.a;
}
//一般函数
void Show ()
{
cout<<a<<endl;
}
};
int main()
{
CExample A(100);
CExample B = A; // CExample B(A); 也是一样的
B.Show ();
return 0;
}
CExample(const CExample& C) 就是我们自定义的拷贝构造函数。可见,拷贝构造函数是一种特殊的 构造函数 ,函数的名称必须和类名称一致,它必须的一个参数是本类型的一个引用变量 。
在代码中,
a = C.a;
是拷贝构造函数CExample(const CExample& C)
中的一行代码。它用于将另一个CExample
类型对象C
的私有成员变量a
的值复制给当前对象的私有成员变量a
。这行代码的作用是将
C
对象的a
的值赋给当前对象的a
。通过这样的赋值操作,当前对象的a
成员变量将具有与C
对象相同的值。需要注意的是,由于拷贝构造函数是通过传递参数(引用)来初始化对象的,因此可以访问
C
对象的私有成员变量。并且拷贝构造函数通常用于创建新对象并复制另一个对象的状态。
构造函数和拷贝构造函数的区别
构造函数和拷贝构造函数是 C++ 中的两种特殊成员函数,它们在对象创建和复制时起着不同的作用。
-
构造函数(Constructor):
- 构造函数是类中的一种特殊函数,在创建对象时被调用。
- 构造函数用于初始化对象的状态和数据成员。
- 构造函数的名称与类名相同,没有返回类型(包括 void)。
- 可以重载构造函数,根据参数的类型和数量,可以有多个构造函数。
- 在对象创建时,会自动调用适当的构造函数。
-
拷贝构造函数(Copy Constructor):
-
拷贝构造函数是一种特殊的构造函数,用于创建一个新对象并将现有对象的值复制到新对象中。
-
拷贝构造函数在以下情况下被调用:
- 通过用已存在的对象初始化新对象时。
- 将对象作为函数参数按值传递时。
- 在函数中返回对象时以值的形式返回。
-
拷贝构造函数的参数是另一个相同类型的对象的引用。
-
如果没有提供自定义的拷贝构造函数,编译器会生成一个默认的拷贝构造函数。
-
区别:
- 构造函数用于创建对象并初始化其状态,而拷贝构造函数则用于创建新对象并复制现有对象的值。
- 构造函数没有特定的参数类型,而拷贝构造函数的参数类型是同一类的引用。
- 构造函数在对象创建时自动调用,而拷贝构造函数在特定情况下(如对象初始化、按值传递参数和以值返回对象)才会被调用。
- 如果没有提供自定义的拷贝构造函数,编译器会生成一个默认的拷贝构造函数,但对于构造函数没有默认的实现。
- 通过适当地组合构造函数和拷贝构造函数,可以实现对象的初始化和复制操作。
4.1类型转换构造函数
c++
class Complex{
public:
double real, imag;
Complex( int i) {//类型转换构造函数
cout << "IntConstructor calledn << endl;
real = i; imag = 0;
}
Complex(double r,double i) {real = r; imag = i;}
};
int main ()
{Complexc1(7,8);
Complexc2 = 12;
c1 = 9; // 9被自动转换成一个临时complex对象
c1.imag << endlcout << c1.real <<endl;
return 0;
}
编译器会把9转换为一个临时对象,再把这个对象的值赋给c1。而不是把9转换为c1
4.2 析构函数
析构函数(Destructor)是一种特殊的类成员函数,它在对象生命周期结束时自动被调用,用于进行对象的清理工作和资源释放。
析构函数执行的时机是在以下情况下:
- 当对象的作用域结束时,比如一个局部对象在离开其定义的作用域时,该对象的析构函数会被调用。
- 当对象以动态方式分配内存,通过
new
运算符创建时,需要手动使用delete
运算符释放内存时,会显式调用对象的析构函数。 - 对象被销毁时,比如当一个对象是另一个对象的成员变量,而该容器对象被销毁时,包含的成员对象的析构函数会被调用。
- 对象从容器中移除或销毁时,比如当一个对象从容器(如数组、链表等)中删除或销毁时,被删除或销毁的对象的析构函数将被调用。
需要注意的是:
- 如果没有显式定义析构函数,编译器会生成默认的析构函数,它执行对象的默认清理操作。
- 析构函数不接受任何参数,且没有返回值。
- 在对象的析构函数中,可以释放在对象生命周期中分配的资源,例如释放动态分配的内存、关闭文件或释放其他外部资源。
总之,对象的析构函数在对象生命周期结束时自动被调用,用于清理对象的资源和执行必要的清理操作。
使用new创建的话需要手动delete吗?不会自动执行析构函数吗?
是的,当你使用 new
运算符动态创建对象时,需要手动使用 delete
运算符来释放对象及其占用的内存。因为动态创建的对象不会自动执行析构函数和释放内存。
当使用 new
创建对象时,它会调用对象的构造函数进行初始化,但不会自动调用析构函数来销毁对象。同样,使用 delete
销毁对象时,它会调用对象的析构函数来清理资源并释放内存。
以下是一个示例:
c++
Class Ctest{
pubilc:
~Ctest() {cout<<"destructor called" <<endl ; }
};
ctest * pTest;
pTest = new Ctest;//构造函数调用
delete pTest;/析构函数调用
----------------------------------------
pTest = new Ctest[3];//构造函数调用3次
delete [] pTest;/析构函数调用3次
✨若new----个对象数组,那么用delete释放时应该写[ ].否则只delete一个对象(调用一次析构函数)
需要手动delete操作的原理是什么?
使用 new
创建对象时,内存会在堆(Heap)上进行分配,而不是栈(Stack)。在堆上分配的内存不会自动被释放,需要手动释放。
原因如下:
-
自由存储区域(Heap)和栈(Stack)的管理机制不同:
- 栈是由编译器自动管理的一块内存区域,用于存储局部变量和函数调用的上下文。栈的分配和释放是由编译器自动完成的,且遵循先进后出的原则。
- 堆是由开发者手动管理的一块内存区域,用于存储动态分配的对象。堆上的内存分配和释放需要显式地由开发者指定,以确保更灵活和精确的控制。
-
new
运算符返回的是指向对象的指针:- 使用
new
创建对象时,它会在堆上分配一块内存,并调用对象的构造函数进行初始化。然后,new
运算符会返回指向分配对象的指针。 - 由于返回的是指针,编译器无法推断何时应该释放对应的内存区域,因此需要开发者手动调用
delete
运算符来显式释放内存。
- 使用
-
C++ 的设计哲学:
- C++ 的设计理念之一是"你所使用的资源就是你所负责的资源"。这意味着程序员有更多的控制权和灵活性,可以精确地控制对象的生命周期和内存的分配和释放。
总结起来,new
创建对象需要手动调用 delete
运算符释放内存,是因为堆上分配的内存不会自动释放,而 C++ 的设计使得开发者能够更灵活地管理对象的生命周期和内存的分配。这样可以避免不必要的内存消耗,并提供更高的灵活性和控制性。
析构函数在对象作为函数返回值返回后被调用
c++
class CMyclass {
public:
~CMyclass() { cout <<"destructor" <endl ; }
};
CMyclass obj;
CMyclass fun (CMyclass sobj ) {//参数对象消亡也会导致析
//构函数被调用
return sobj;//函数调用返回时生成临时对象返回
}
int main () {
obj = fun (obj);//函数调用的返回值(临时对象)被
return 0 ;//用过后,该临时对象析构函数被调用
}
输出:
destructor
destructor
destructor
5. 构造函数析构函数调用时机
复制构造函数在不同编译器下的表现
第三章 类和对象提高
1. this指针
this指针作用
非静态
成员函数中可以直接使用this来代表指向该函数作用的对象的指针。
c++
class Complex{
public:
double real, imag;
void Print() {cout << real << "," << imag ; }
Complex(double r,double i):real(r),imag(i)
{ }
Complex AddOne(){
this->real ++;//等价于real ++;
this->Print();/等价于 Print
return*this;
}
int main() {
Complex c1(1,1),c2(0,0);
c2 =c1.AddOne();
return 0;
}
this指针和静态成员函数 静态成员函数中不能使用 this 指针! 因为静态成员函数并不具体作用于某个对象! 因此,静态成员函数的真实的参数的个数,就是程序中写出的参数个数!
1. this 指针的用处 :
一个对象的this指针并不是对象本身的一部分,不会影响sizeof(对象)的结果。this作用域是在类内部,当在类的非静态成员函数中访问类的非静态成员的时候,编译器会自动将对象本身的地址作为一个隐含参数传递给函数。也就是说,即使你没有写上this指针,编译器在编译的时候也是加上this的,它作为非静态成员函数的隐含形参,对各成员的访问均通过this进行。 例如,调用date.SetMonth(9) <===> SetMonth(&date, 9),this帮助完成了这一转换 .
2. this 指针的使用 :
一种情况就是,在类的非静态成员函数中返回类对象本身的时候,直接使用 return *this;另外一种情况是当参数与成员变量名相同时,如this->n = n (不能写成n = n)。
3. this 指针程序示例 :
this指针存在于类的成员函数中,指向被调用函数所在的类实例的地址。 根据以下程序来说明this指针
#include
class Point { int x, y;
public:
Point(int a, int b) { x=a; y=b;}
void MovePoint( int a, int b){ x+=a; y+=b;}
void print(){ cout<<"x="<
};
void main( ) {
Point point1( 10,10);
point1.MovePoint(2,2);
point1.print( );
}
当对象point1调用MovePoint(2,2)函数时,即将point1对象的地址传递给了this指针。
MovePoint函数的原型应该是 void MovePoint( Point *this, int a, int b);第一个参数是指向该类对象的一个指针,我们在定义成员函数时没看见是因为这个参数在类中是隐含的。这样point1的地址传递给了this,所以在MovePoint函数中便显式的写成:
void MovePoint(int a, int b) { this->x +=a; this-> y+= b;} 即可以知道,point1调用该函数后,也就是point1的数据成员被调用并更新了值。 即该函数过程可写成 point1.x+= a; point1. y + = b;
4. 关于 this 指针的一个经典回答 :
当你进入一个房子后,
你可以看见桌子、椅子、地板等,
但是房子你是看不到全貌了。
对于一个类的实例来说,
你可以看到它的成员函数、成员变量,
但是实例本身呢?
this是一个指针,它时时刻刻指向你这个实例本身
5. 类的 this 指针有以下特点:****
( 1 ) this 只能在成员函数中使用。****
全局函数、静态函数都不能使用this.
实际上,成员函数默认第一个参数为T * const this。
如:
class A
{
public:
int func(int p)
{
}
};
其中, func 的原型在编译器看来应该是:****
** int func(A * const this,int p);**
( 2 )由此可见, this 在成员函数的开始前构造,在成员函数的结束后清除。****
这个生命周期同任何一个函数的参数是一样的,没有任何区别。
当调用一个类的成员函数时,编译器将类的指针作为函数的this参数传递进去。如:
A a;
a.func(10);
此处,编译器将会编译成:
A::func(&a,10);
看起来和静态函数没差别,对吗?不过,区别还是有的。编译器通常会对****this 指针做一些优化, 因此,this指针的传递效率比较高--如VC通常是通过ecx寄存器传递this参数的。
( 3 )几个 this 指针的易混问题。****
A. this 指针是什么时候创建的?****
this在成员函数的开始执行前构造,在成员的执行结束后清除。
但是如果class或者struct里面没有方法的话,它们是没有构造函数的,只能当做C的struct使用。采用 TYPE xx的方式定义的话,在栈里分配内存,这时候this指针的值就是这块内存的地址。采用new的方式 创建对象的话,在堆里分配内存,new操作符通过eax返回分配 的地址,然后设置给指针变量。之后去调 用构造函数(如果有构造函数的话),这时将这个内存块的地址传给ecx,之后构造函数里面怎么处理请 看上面的回答。
B. this 指针存放在何处?堆、栈、全局变量,还是其他?****
this指针会因编译器不同而有不同的放置位置。可能是栈,也可能是寄存器,甚至全局变量。在汇编级 别里面,一个值只会以3种形式出现:立即数、寄存器值和内存变量值。不是存放在寄存器就是存放在内 存中,它们并不是和高级语言变量对应的。
C. this 指针是如何传递类中的函数的? 绑定?还是在函数参数的首参数就是this指针?那么,this指针 又是如何找到"类实例后函数的"?
大多数编译器通过ecx寄存器传递this指针。事实上,这也是一个潜规则。一般来说,不同编译器都会遵从一致的传参规则,否则不同编译器产生的obj就无法匹配了。
在call之前,编译器会把对应的对象地址放到eax中。this是通过函数参数的首参来传递的。this指针在调用之前生成,至于"类实例后函数",没有这个说法。类在实例化时,只分配类中的变量空间,并没有为函数分配空间。自从类的函数定义完成后,它就在那儿,不会跑的。
D. this 指针是如何访问类中的变量的?****
如果不是类,而是结构体的话,那么,如何通过结构指针来访问结构中的变量呢?如果你明白这一点的话,就很容易理解这个问题了。
在 C++ 中 , 类和结构是只有一个区别的:类的成员默认是 private ,而结构是 public 。****
this 是类的指针,如果换成结构,那 this 就是结构的指针了。****
E. 我们只有获得一个对象后,才能通过对象使用 this 指针。 如果我们知道一个对象this指针的位置,可以直接使用吗?
this指针只有在成员函数中才有定义。因此,你获得一个对象后,也不能通过对象使用this指针。所以,我们无法知道一个对象的this指针的位置(只有在成员函数里才有this指针的位置)。当然,在成员函数里,你是可以知道this指针的位置的(可以通过&this获得),也可以直接使用它。
F. 每个类编译后,是否创建一个类中函数表保存函数指针,以便用来调用函数?
普通的类函数(不论是成员函数,还是静态函数)都不会创建一个函数表来保存函数指针。只有虚函数才会被放到函数表中。但是,即使是虚函数,如果编译器能明确知道调用的是哪个函数,编译器就不会通过函数表中的指针来间接调用,而是会直接调用该函数。
2.静态成员变量
普通成员变量每个对象有各自的一份,而静态成员变量一共就一份.
为所有对象共享
。普通成员函数必须具体作用于某个对象,而静态成员函数并
不具体作用于某个对象
。因此静态成员
不需要通过对象
就能访问。
如何访问静态成员
- 类名::成员名 CRectangle::PrintTotal();
- 对象名.成员名 CRectangle r; r.PrintTotal();
- 指针->成员名 CRectangle * p = &r; p->PrintTotal();
- 引用.成员名 CRectangle &ref = r int n = ref nTotalNuimber:
静态成员变量本质上是全局变量,哪怕一个对象都不存在,类的静态成员变量也存在。
静态成员函数本质上是全局函数。
设置静态成员这种机制的目的是将和某些类紧密相关的全局变量和函数写到类里面,看上去像一个整体,易于维护和理解。
c++
class CRectanglel
{
private:int w, h,
static int nTotalArea;
static int nTotalNumber,
public:
CRectangle( int w_,int h_);
~CRectangle();
static void PrintTotal();
};
CRectangle::CRectangle(int w_.int h_)
{
W=W_;h =h_;
nTotalNumber ++;
nTotalArea +=w * h;
}
CRectangle:~CRectangle()
{
nTotalNumber --;
nTotalArea -= w * h;
}
void CRectangle::PrintTotal()
{
cout << nTotalNumber << ","<<nTotalArea<<endl;
}
静态成员变量的声明和初始化
c++
int CRectangle::nTotalNumber = 0;
int CRectangle:nTotalArea = 0;//见下
//必须在定义类的文件中对静态成员变量进行一次说明/或初始化。否则编译能通过,链接不能通过。
?静态成员函数能访问全局变量吗?静态全局函数能访问全局变量吗?
在静态成员函数中,不能访问非静态成员变量,也不能调用非静态成员函数(会访问非静态成员变量)。
c++
void CRectangle::PrintTotal(){
cout <<w <<"," <<nTotalNumber << "," <<nTotalArea <<endl;//wrong
}
CRetangle:PrintTotal();/解释不通,w到底是属于那个对象的?
✨由于复制构造函数会造成临时对象的生成,同时不经过复制构造函数,只在变量作用域结束时经过析构函数,会造成错误的输出。
eg. 在使用CRectangle类时,有时会调用复制构造函数生成临时的隐藏的CRectangle对象
解决办法:为CRectangle类写一个复制构造函数。
c++CRectangle :: CRectangle(CRectangle & r ) { w=r.W:; h =r.h; nTotfalNumber ++; nTotalArea tw * h; }
3. 成员对象和封闭类
⛔ 封闭类构造函数和成员的构造函数?
任何生成封闭类对象的语句,都要让编译器明白,对象中的成员对象,是如何初始化的。
具体的做法就是:通过封闭类的构造函数的初始化列表。
成员对象初始化列表中的参数可以是任意复杂的表达式,可以包括函数,变量,只要表达式中的函数或变量有定义就行。
封闭类构造函数和析构函数的执行顺序
- 封闭类对象生成时,先执行所有对象成员的构造函数,然后才执行封闭类的构造函数。
- 对象成员的构造函数调用次序和对象成员在类中的说明次序一致与它们在成员初始化列表中出现的次序无关。
- 当封闭类的对象消亡时,先执行封闭类的析构函数,然后再执行成员对象的析构函数。次序和构造函数的调用次序相反。
4. 常量对象、常量成员函数
常量对象
如果不希望某个对象的值被改变,则定义该对象的时候可以在前面加const关键字。
c++
class Demo{
private :
int value;
public:
void SetValue(){}
};
const Demo Obi: // 常量对象
常量成员函数
在类的成员函数说明后面可以加
const
关键字,则该成员函数成为常量成员函数
。>常量成员函数执行期间
不应修改其所作用的对象因此,在常量成员函数中不能修改成员变量的值(
静态成员变量除外),也不能调用同类的非常量成员函数(
静态成员函数除外`)
c++
void GetValue0) const:
✨常量对象上面不会执行非常量成员函数,编译器不会分析常量对象调用的函数是否会修改对象的值,而是直接编译失败
常量成员函数的重载
两个成员函数,名字和参数表都一样,但是一个是const,一个不是,算重载。
c++
class CTest {
private :
int n;
public:
CTest() { n= 1 ; }
int GetValue()const { return n ; }
int GetValue( return 2*n ; }
};
int main( {
const CTest objTestl;
CTest objTest2;
cout << objTest 1.GetValue() <<","<<objTest2.GetValue() ;
return O;
}
常引用
✨对象作为函数的参数时,生成该参数需要调用复制构造函数,效率比较低。用指针作参数,代码又不好看,如何解决?
c++
//可以用对象的引用作为参数,如:
class Sample{
...
};
void PrintfObj(Sample & o){
......
}
对象引用作为函数的参数有一定风险性,若函数中不小心修改了形参o,则实参也跟着变,这可能不是我们想要的。如何避免?
c++
//可以用对象的常引用作为参数,如:
class Sample{
...
};
void PrintfObj( const Sample & Lo){
......//这样函数中就能确保不会出现无意中更改o值的语句了。
}
5.友元
⛔友元类之间的关系不能传递,不能继承。
友元分为友元函数和友元类两种
- 友元函数:一个类的友元函数可以访问该类的私有成员
c++
class ClassName {
// 类的定义
public:
friend ReturnType FunctionName(ParameterList); // 友元函数的声明
};
ReturnType FunctionName(ClassName::ParameterList) {
// 友元函数的定义
}
在所在类中以 friend ReturnType FunctionName(ParameterList);
友元函数的声明的形式,声明的函数可以访问该类私有的成员变量;
ClassName
是要声明友元函数的类名。ReturnType
是友元函数的返回类型。FunctionName
是友元函数的名称。ParameterList
是友元函数的参数列表,可以包含类的对象或其他参数。
eg.
c++
class CCar ; //提前声明 ccar类,以便后面的cDriver类使用
class CDriver
{
public :
void ModifyCar ( cCar * pCar) ; //改装汽车
} ;
class CCar
{
private:
int price ;
friend int MostExpensiveCar( Ccar cars[l, int total);//声明友元
friend void CDriver: :ModifyCar (ccar * pCar) ;//声明友元
};
void CDriver:: ModifyCar ( CCar * pCar)
{
pCar->price += 1000; //汽车改装后价值增加
}
int MostExpensiveCar ( ccar cars[ ] ,int total)//求最贵汽车的价格
{
int tmpMax = -1;
for( int i = o; i < total; ++i )
if( cars[i].price > tmpMax)
tmpMax = cars[i].price;
return tmpMax ;
}
int main ()
{
return 0 ;
}
- 友元类:如果A是B的友元类,那么A的成员函数可以访问B的私有成员
友元类是指一个类可以访问另一个类的私有成员和保护成员。当一个类被声明为另一个类的友元类时,它可以在其成员函数中直接访问该类的私有成员和保护成员。
友元类的声明方式如下:
c++
class ClassName {
// 类的定义
friend class FriendClassName; // 友元类的声明
};
其中:
ClassName
是要声明友元类的类名。FriendClassName
是友元类的名称。
通过将一个类声明为另一个类的友元类,被声明的类就能够访问友元类中的所有成员,包括私有成员和保护成员。
eg.
c++
class CCar
{
private:
int price ;
friend class CDriver; l/声明cDriver为友元类
};
class CDriver
{
public:
CCar myCar;
void ModifyCar (){//改装汽车
myCar.price += 1000; //因cDriver是ccar的友元类,
//故此处可以访问其私有成员
}
int main{
return 0;
}
代码中定义了两个类 CCar
和 CDriver
,并且在 CCar
类中声明了 CDriver
为友元类。这意味着 CDriver
类可以访问 CCar
类的私有成员和保护成员。
在 CDriver
类中,有一个 CCar
类型的对象 myCar
。在 ModifyCar
成员函数中,它可以直接访问 myCar
的私有成员 price
。因为 CDriver
是 CCar
的友元类,所以可以在 ModifyCar
函数中修改 myCar
的私有成员。
第四章 运算符重载
1. 运算符重载的基本概念
运算符重载的目的是:扩展C++中提供的运算符的适用范围,使之能作用于对象。
- 重载为成员函数时,参数个数为运算符目数减一。
- 重载为普通函数时,参数个数为运算符目数。
eg
c++
class Complex
{
public:
double real ,imag ;
Cormplex( double r = 0.0,double i= 0.0 ) : real(r),imag(i){ }
complex operator-(const complex &c) ;
};
Complex operator+( const Complex & a, const Complex & b)
{
return Complex( a.real+b.real,a.imag+b.imag);//返回一个临时对象
}
Complex Complex :: operator- (const Complex & c)
{
return complex (real - c .real, imag-c.imag); //返回一个临时对象
}
int main ()
{
Complex a (4,4),b(1,1) ,c;
c= a + b; //等价于c=operator+(a,b) ;
cout<<c.real <<", " <<c.imag <<end
cout <<, (a-b).real << " , " <<(a-b) .imag << endl;//a-b等价于a. operator-(b)
return 0 ;
}
c= a + b ;等价于c=operator+(a, b) ;
a-b 等价于a.operator-(b)
2. 赋值运算符的重载
在C++中,赋值运算符"="只能重载为成员函数。
浅拷贝和深拷贝
对 operator = 返回值类型的讨论
void 好不好?
String 好不好?
为什么是 String&
对运算符进行重载的时候,好的风格是应该尽量保留运算符原本的特性
考虑:a=b=c;
和
(a=b)=c;//会修改a的值
qustion:赋值运算符的返回值是等号左边的引用,a=b的返回值是a的引用?
第五章 继承
1. 继承和派生的基本概念
- 在派生类的各个成员函数中,不能访问基类中的private成员。
派生类的写法
c++
class 派生类名: public 基类名
{
};
派生类对象的内存空间
派生类对象的体积,等于基类对象的体积,再加上派生类对象自己的成员变量的体积。在派生类对象中,包含着基类对象 ,而且基类对象的存储位置位于派生类对象新增的成员变量之前。
c++
classCBase
{
int v1, v2;
}
classCDerived:public CBase
{
int v3 ;
}
2. 继承关系和复合关系
继承:"是" 关系。
- 基类 A,B是基类A的派生类
- 逻辑上要求:"一个B对象也是一个A对象"
复合:"有"关系
- 类C中"有"成员变量k,k是类D的对象,则C和D是复合关系
- 一般逻辑上要求:"D对象是C对象的固有属性或组成部
复合关系的使用
正确的写法: 为"狗"类设一个"业主"类的对象指针;
为"业主"类设一个"狗"类的对象指针数组。
c++
class CMaster: //CMaster必须提前声明,不能先写CMaster类后写Cdog类
class CDog
{
CMaster* pm;
};
class CMaster
{
CDog*dogs[10];
};
3.覆盖和保护成员
覆盖
派生类可以定义一个和基类成员同名的成员,这叫覆盖。在派生类中访问这类成员时,缺省 的情况是访问派生类中定义的成员。要在派生类中访问由基类定义的同名成员时,要使用作用域符号::
。
基类和派生类有同名成员的情况:
eg
c++
class base{
int j;
public:
int i;
void func();
};
class derived :public base{
public:
int i;
void access();
void func();
};
void derived::access(){
j = 5; //error
i= 5; //引用的是派生类的i
base::i = 5;//引用的是基类的 i
func(); //派生类的
base::func0;//基类的
};
一般来说,基类和派生类不定义同名成员变量。
eg:存储空间
c++
class Base {
public:
int value;
};
class Derived : public Base {
public:
void setValue(int newValue) {
value = newValue; // 可以直接访问和赋值基类的成员变量
}
int getValue() {
return value; // 可以直接访问基类的成员变量
}
};
在上述代码中,Derived
派生类继承了 Base
基类的成员变量 value
。派生类中的函数 setValue()
和 getValue()
直接对 value
进行赋值和返回操作,而不需要重新声明 value
。
因此,基类的成员变量同样属于派生类,并且可以在派生类中进行直接访问和操作。 派生类的内存空间包含了基类的成员和派生类自己的成员。
当创建一个派生类的对象时,内存空间会按照以下方式进行分配:
- 首先,分配基类的内存空间。这包括基类中的所有非静态成员变量和成员函数。
- 接下来,分配派生类的附加内存空间。这包括派生类中新增加的非静态成员变量和成员函数。
派生类的内存布局可以看作是基类和派生类内存布局的组合。
在派生类中,可以通过派生类对象或指针访问基类的成员,因为基类的成员被继承到派生类中。同时,派生类还可以添加自己的成员,以实现其特定的功能。
需要注意的是,派生类对象的大小取决于基类和派生类成员的总和,以及可能的内存对齐要求。
总结:派生类的内存空间包括了基类的成员和派生类自己的成员。派生类的内存布局可以看作是基类和派生类内存布局的组合。通过派生类对象或指针,可以访问基类的成员和派生类自己的成员。
private、protected和public的范围
基类的private成员: 可以被下列函数访问
- 基类的成员函数
- 基类的友员函数
基类的public成员: 可以被下列函数访问
- 基类的成员函数
- 基类的友员函数
- 派生类的成员函数
- 派生类的友员函数
- 其他的函数
基类的protected成员:可以被下列函数访问
- 基类的成员函数
- 基类的友员函数
- 派生类的成员函数可以访问当前对象的基类的保护成员
4. 派生类的构造函数
- 在创建派生类的对象时,需要调用基类的构造函数:初始化派生类对象中从基类继承的成员。在执行一个派生类的构造函数之前,总是先执行基类的构造函数。
- 调用基类构造函数的两种方式
-
显式方式:在派生类的构造函数中,为基类的构造函数提供参数.
C++derived::derived(arg_derived-list/* 参数列表*/):base(arg_base-list/* 参数列表*/)/* 派生类成员初始化列表 */{ // 派生类构造函数体 }
-
隐式方式:在派生类的构造函数中,省略基类构造函数时,派生类的构造函数则自动调用基类的默认构造函数
(无参构造函数)
.
-
- 派生类的析构函数被执行时,执行完派生类的析构函数后,自动调用基类的析构函数。
先构造后析构
封闭派生类对象的构造函数的执行顺序
在创建派生类的对象时:
-
先执行基类的构造函数,用以初始化派 生类对象中从基类继承的成员;
-
再执行成员对象类的构造函数,用以初 始化派生类对象中成员对象。
-
最后执行派生类自己的构造函数
在派生类对象消亡时:
-
先执行派生类自己的析构函数
-
再依次执行各成员对象类的析构函数
-
最后执行基类的析构函数析构函数的调用顺序与构造函数的调用顺序相反。
public继承的赋值兼容规则
public继承的赋值兼容规则
c++
class base { };
class derived : public base: {};
base b;
derived d:
-
派生类的对象可以赋值给基类对象(不能把基类对象赋值给派生类对象,派生类可以对基类兼容)
b = d;
-
派生类对象可以初始化基类引用(可以认为基类的引用实际上引用了派生类对象多包含的基类的对象) base & br = d;
-
派生类对象的地址可以赋值给基类指针(基类的指针指向一个派生类的对象所包含的基类对象,所包含的基类对象位于派生类对象的存储空间最前面,即基类对象的起始地址就是派生类对象的起始地址) base* pb=& d;
如果派生方式是private或protected,则上述三条不可行。
直接基类和间接基类
-
在声明派生类时,只需要列出它的直接基类
-
派生类沿着类的层次自动向上继承它的间接基类
-
派生类的成员包括
- 派生类自己定义的成员
- 直接基类中的所有成员
- 所有间接基类的全部成员
派生类初始化时只需要指明直接基类,间接基类由直接基类初始化(递归初始化)
多层类嵌套的构造与析构
c++
Base constructed
Derived constructed
More Derived constructed
More Derived destructed
Derived destructed
Base 4 destructed
第六章 多态
1. 虚函数和多态的基本概念
虚函数
虚函数是C++中实现多态的一种机制。当在基类中声明一个函数为虚函数时,派生类可以重写该虚函数,并根据自己的需要提供专门的实现。在运行时,根据对象的实际类型来调用相应的函数。
在C++中,使用关键字virtual
来声明虚函数。基类中的虚函数可以通过在函数声明前加上virtual
关键字来定义,
例如:
c++
class Base {
virtual void init() { }
};
int base::get(){}
- virtual 关键字只用在类定义里的函数声明中写函数体时不用。
- 构造函数和静态成员函数不能是虚函数
派生类可以重写基类的虚函数,通过相同的函数名和参数列表来覆盖基类的虚函数。例如:
c++
class Derived : public Base {
public:
void init() override {
// 派生类对虚函数的重写
}
};
注意到,在派生类中重写虚函数时,可以使用override
关键字来显式标记,以提高代码的可读性。
override
关键字是C++11标准引入的一个特性,用于显式地表示派生类成员函数是覆盖(override)了基类中的虚函数。当我们使用该关键字时,编译器会检查派生类的这个函数是否确实重写了基类的虚函数(即函数名、参数列表和返回值类型都相同),否则会报错。
使用override
关键字的语法格式为:
csharp
void function() override;
多态的表现形式一(指针)
-
派生类的指针可以赋给基类指针。
-
通过基类指针调用基类和派生类中的同名虚函数时:
(1) 若该指针指向一个基类的对象,那么被调用是基类的虚函数;
(2) 若该指针指向一个派生类的对象,那么被调用的是派生类的虚函数。
这种机制就叫 "多态"。
eg.指针
c++
// 基类
class Base {
public:
virtual void func() {
cout << "Base::func()" << endl;
}
};
// 派生类
class Derived : public Base {
public:
void func() override {
cout << "Derived::func()" << endl;
}
};
int main() {
Base* ptr = new Derived();
ptr->func(); // 输出 "Derived::func()"
delete ptr;
return 0;
}
多态的表现形式二(引用)
-
派生类的对象可以赋给基类引用。
-
通过基类引用调用基类和派生类中的同名虚函数时:
(1) 若该引用引用的是一个基类的对象,那么被调用是基类的虚函数;
(2) 若该引用引用的是一个派生类的对象,那么被调用的是派生类的虚函数。
这种机制也叫做 "多态"。
eg.引用
c++
// 基类
class Base {
public:
virtual void func() {
cout << "Base::func()" << endl;
}
};
// 派生类
class Derived : public Base {
public:
void func() override {
cout << "Derived::func()" << endl;
}
};
int main() {
Derived derived;
Base& ref = derived;
ref.func(); // 输出 "Derived::func()"
return 0;
}
在面向对象的程序设计中使用多态,能够增强程序的可扩充性,即程序需要修改或增加功能的时候,需要改动和增加的代码较少
2.多态示例2:纯虚函数
在C++中,声明函数体为空(也称为空函数或虚函数的默认实现)和声明纯虚函数之间有明显的区别:
-
纯虚函数(Pure Virtual Function):
- 纯虚函数是一个虚函数,它在基类中声明但没有提供具体的实现。
- 声明纯虚函数时,在函数声明的末尾使用 "= 0" 来标记它,例如:
virtual void foo() = 0;
。 - 类中包含至少一个纯虚函数的类被称为抽象基类,无法实例化对象。
- 派生类必须提供纯虚函数的具体实现,否则它们也会成为抽象类。
示例:
c++
class Base {
public:
virtual void pureVirtualFunction() = 0; // 纯虚函数
};
-
声明函数体为空(Empty Function):
- 声明函数体为空的函数是一个虚函数,但它提供了一个默认的空实现,派生类可以选择是否覆盖这个实现。
- 在函数定义中使用关键字
virtual
,然后在函数体中仅提供一个空实现即可。 - 派生类可以选择性地覆盖这个虚函数,如果不覆盖,将继续使用默认的空实现。
示例:
c++
class Base {
public:
virtual void emptyVirtualFunction() {
// 默认的空实现
}
};
总结:
- 主要区别在于,纯虚函数要求派生类必须提供自己的实现,而声明函数体为空的虚函数提供了一个默认实现,但派生类可以选择是否覆盖它。
- 纯虚函数用于定义接口,强制派生类提供特定的功能,而函数体为空的虚函数提供了一种可选的默认实现,派生类可以继承或覆盖它,具有更大的灵活性。在C++中,声明函数体为空(也称为空函数或虚函数的默认实现)和声明纯虚函数之间有明显的区别:
3. 多态实例:几何形体程序(纯虚函数)
c++
已知:
几何形体处理程序: 输入若干个几何形体的参数,输出时要指明形状。要求按面积排序输出。
Input:
第一行是几何形体数目n (不超过100).下面有n行,每行以一个字母c开头.
若c是则代表一个矩形,本行后面跟着两个整数,分别是矩形的宽和高;
若 c 是 C',则代表一个圆,本行后面跟着一个整数代表其半径;
若c是"T',则代表一个三角形,本行后面跟着三个整数,代表三条边的长度.
#include <iostream>
#include <stdlib.h>
#include <math.h>
using namespace std;
class CShape{
public:
virtual double Area = 0; //纯虚函数
virtual void Printlnfo() = 0;
}
class CRectangle:public CShape{
public:int w,h;virtual double Area();virtual void PrintInfo()};
CShape * pShapes[100];int MyCompare(const void * s1, const void *s2):
int MyCompare(const void * s1, const void *s2)
{
double a1,a2;
CShape**pl; // s1,s2 是 void * ,不可写"* s1"来取得s1指向的内容
CShape**p2;
p1=(CShape ** )s1;//sl,s2指向pShapes数组中的元素,数组元素的类型是CShape*
p2 =( CShape**)s2; // pl,p2都是指向指针的指针,类型为 CShape **
a1 =(*p1)>Area0);// * p1 的类型是 Cshape *,是基类指针,故此句为多态
a2 =(*p2)->Area();
if( al <a2)
return -1:
else if( a2 <a1)
return 1;
else
return 0;
}
对 MyCompare的解释:
const void * s1, const void *s2指向pShape数组中待比较的元素
不可写"* s1"来取得s1指向的内容:编译时会出错,因为s1是void*类型的指针,s1的所占用的字节数无法判断。
在C++中,void*
是一种特殊的指针类型,称为"无类型指针"(void pointer),它可以指向任何类型的数据,因为它没有指定具体的数据类型。当你使用 void*
类型的指针时,编译器不知道它指向的数据类型是什么,因此不允许直接对其进行解引用操作,即不允许使用 *s1
来取得指向的内容。
如果你想在 MyCompare
函数中访问 s1
和 s2
指向的内容,你需要进行类型转换,将它们转换为正确的类型,然后再进行解引用操作。这是因为编译器需要明确知道要访问的数据的类型,以便正确地解释和处理数据。
在你的代码中,你进行了如下的类型转换:
c++
p1 = (CShape**)s1; // 将 void* 转换为 CShape** 类型的指针
p2 = (CShape**)s2; // 将 void* 转换为 CShape** 类型的指针
这些类型转换允许你将 s1
和 s2
视为 CShape**
类型的指针,然后使用 *p1
和 *p2
来解引用它们,访问指向的 CShape
对象。
总之,void*
类型的指针是一种通用指针,不具有具体的类型信息,因此不能直接解引用。必须通过类型转换将其转换为正确的指针类型后,才能安全地使用 *
运算符来获取指向的内容。
我们知道sl,s2指向pShapes数组中的元素,数组元素的类型是CShape*,因此需要强制类型转换。
因此需要让p1指针指向CShape*类型的指针,p1是指向指针的指针(p1 = (CShape**)s1)
对
Cshape**
的理解s1是void*类型,请解释为什么不转换为Cshape 类型而是Cshape*
Answer: 对于
qsort
函数的比较函数,void*
参数通常用于指向要排序的元素的指针。在这个特定的情境下,s1
和s2
实际上是指向CShape*
类型的指针数组中的元素的指针。因此,在这里使用
CShape**
是为了将s1
和s2
转换为指向CShape*
类型的指针,以便在比较函数中能够访问CShape
对象。这是因为qsort
函数需要比较函数的参数是const void*
类型,但你实际上要比较的是CShape*
类型的指针。具体来说,
s1
和s2
是指向CShape*
数组元素的指针,因此使用CShape**
类型来进行类型转换,以便在比较函数中访问元素的实际内容。你可以把它看作是一种解开多层指针以获取元素的方法,因为元素本身就是CShape*
类型的指针。这种转换方式允许你在比较函数中访问
CShape
对象,以便进行排序操作,而不是仅仅比较指针地址。这是为了确保排序是基于元素的属性而不是元素的地址。
拆分Cshape**
的层次理解 当你将s1
和s2
进行类型转换为CShape**
时,实际上是在拆分多层指针,以便在比较函数中访问CShape
对象。下面是拆分过程的详细解释:
- 假设你有一个数组
CShape* shapes[]
,其中包含了多个CShape*
类型的指针。qsort
的比较函数期望接收void*
类型的参数,这意味着它将接收指向数组元素的指针,而不是数组元素本身。- 当你将
s1
和s2
强制转换为CShape**
类型时,实际上是将它们转换为指向CShape*
类型指针的指针。- 这样,你就可以在比较函数中使用
*p1
和*p2
来访问指向CShape*
对象的指针,而不仅仅是数组元素的地址。在代码中,这个过程如下所示:
c++CShape** p1 = (CShape**)s1; // 将 void* 转换为 CShape** 类型的指针 CShape** p2 = (CShape**)s2; // 将 void* 转换为 CShape** 类型的指针
现在,
p1
和p2
分别是指向CShape*
对象的指针的指针。这使得你可以使用*p1
和*p2
来获取CShape*
对象的实际内容,然后在比较函数中进行排序操作,而不仅仅是比较指针地址。这允许你基于CShape
对象的属性来进行排序。
既然需要强制类型转换为什么 MyCompare(const void * s1, const void s2)写成void类型
在这段代码中,MyCompare
函数是用作 qsort
函数的比较函数。qsort
函数是一个通用的排序函数,它接受一个比较函数作为参数,并且该比较函数必须满足特定的签名要求。该比较函数需要以 const void*
类型的参数作为输入,因为 qsort
不知道要排序的元素的具体类型。因此,这里使用 const void*
类型作为参数类型。
下面是关于为什么使用 const void*
的解释:
- 通用性:
qsort
函数被设计成可以对任何类型的数组进行排序,因此它不能假设要排序的元素的具体类型。使用const void*
允许你传递任意类型的指针作为参数。 - 类型安全性: 如果
MyCompare
函数的参数类型是const CShape*
,那么它只能用于CShape
类型的指针,而不能用于其他类型的指针。使用const void*
允许你在比较函数中进行类型转换,以适应不同类型的元素。
在 MyCompare
函数中,参数 s1
和 s2
是 const void*
类型,因此你需要将它们转换为 CShape*
类型的指针,以便访问它们指向的对象。这就是为什么在函数中使用 p1
和 p2
进行类型转换,然后使用 *p1
和 *p2
来访问它们所指向的 CShape
对象的原因。
总之,使用 const void*
类型的参数是为了增加通用性,使 MyCompare
函数能够用于不同类型的元素,并且允许在函数内部进行必要的类型转换。这是一种常见的技巧,用于处理泛型代码或需要对不同类型的数据进行排序的情况。
非构造函数,非析构函数的成员函数中调用虚函数,是多态
c++
class Base {
public:
void fun1() {fun2();}//{this->fun2();} this是基类指针,fun2是虚函数,所以是多态
virtual void fun2(){cout << "Base::fun2("<< endl;}
};
class Derived:public Base {
public:
virtual void fun2() { cout << "Derived:fun2(0"<< endl;}
};
int main() {
Derived d;
Base * pBase= & d;
pBase->fun1();
return 0;
}
◎实际输出
c++
Derived:fun2()
在非构造函数,非析构函数的成员函数中调用虚函数,是多态! ! !
构造函数和析构函数中调用虚函数
在构造函数和析构函数中调用虚函数,不是多态。编译时即可确定,调用的函数是
自己的类或基类
中定义的函数,不会等到运行时才决定调用自己的还是派生类的函数。在构造和析构期间,对象的类型是已知的,不会发生多态性。只有在对象完全构造之后,并且通过派生类指针或引用访问对象时,多态性才会生效。
原理:由于编译时,派生类对象在初始化时先初始化基类,先执行基类的构造函数,此时派生类对象的构造函数还没有被初始化,如果在基类的初始化过程中调用了派生类还未被初始化的虚函数,这样的结果有可能是不正确的。
隐藏
派生类中和基类中虚函数同名同参数表的函数,不加virtual也自动成为虚函数
对于派生类中和基类中虚函数同名同参数表的函数,如果不加 virtual
关键字,它不会自动成为虚函数,而是被视为普通的函数。这意味着在派生类中的该函数将会隐藏基类中的同名函数,而不是覆盖它。这种情况通常被称为函数的隐藏(function hiding)。
要使派生类中的同名函数成为虚函数,你需要在派生类中显式使用 virtual
关键字进行声明,并确保函数签名(参数列表和返回类型)与基类中的虚函数完全匹配。
以下是一个示例,说明了这一点:
c
#include <iostream>
class Base {
public:
virtual void virtualFunction() {
std::cout << "Base virtual function" << std::endl;
}
};
class Derived : public Base {
public:
void virtualFunction() override {
std::cout << "Derived virtual function" << std::endl;
}
};
int main() {
Base* basePtr = new Derived();
basePtr->virtualFunction(); // 输出 "Derived virtual function"
delete basePtr;
return 0;
}
在上面的示例中,Base
类有一个虚函数 virtualFunction()
。在 Derived
类中,我们重写了同名的函数,但没有显式添加 virtual
关键字。然而,由于函数签名匹配,Derived
类中的函数仍然被视为虚函数,并且在运行时多态性会生效,因此在 main
函数中通过基类指针调用时,将调用 Derived
类的版本。这是因为编译器会自动将覆盖的虚函数标记为 override
,除非函数签名不匹配,否则可以省略 virtual
关键字。
4.多态的实现原理
"多态"的关键在于通过基类指针或引用调用一个虚函数时,编译时不确定到底调用的是基类还是派生类的函数,运行时才确定------这叫 "动态联编"。 "动态联编"底是怎么实现的呢?
eg
c++
#include <iostream>
using namespace std;
class Base {
public:
int i;
virtual void Print() { cout << "Base:Print"; }
};
class Derived : public Base {
public:
int n;
virtual void Print() { cout << "Drived:Print" << endl; }
};
int main() {
Derived d;
cout << sizeof(Base) << "," << sizeof(Derived);
return 0;
}
理论上输出的内存大小Base=4(int i),Derived=8(继承的int i;int n).程序输出为8,12.
注:这里是理想化的算法,实际上会由于内存对齐等原因输出16,16.
多态实现的关键-------虚函数表
每一个有虚函数的类(或有虚函数的类的派生类)都有一个虚函数表 ,该类的任何对象(该类的实例)中都放着虚函数表的指针。虚函数表中列出了该类的虚函数地址。多出来的4个字节就是用来放虚函数表的地址的。
解释:
- 基类:当一个基类中包含虚函数时,编译器会在基类中生成一个虚函数表,其中包含了基类的虚函数的地址。这个虚函数表与基类的类型关联,并且基类的所有子类都会共享这个虚函数表。
- 派生类:当你创建一个派生类时,它会继承基类的虚函数表,但也可以覆盖其中的虚函数,并在派生类的虚函数表中包含派生类的虚函数的地址。这样,派生类既包含了基类的虚函数表信息,又包含了自己的虚函数表信息。
- 对象:每个类的对象中都包含一个指向该类的虚函数表的指针,这个指针通常被称为虚指针(vptr)。无论对象是基类的实例还是派生类的实例,都有一个虚指针,它指向该对象的实际类型的虚函数表。这是多态性的关键:通过虚指针,程序能够在运行时根据对象的实际类型来调用正确的虚函数。
多态的函数调用语句被编译成一系列根据基类指针所指向的(或基类引用所引用的)对象中存放的虚函数表的地址,在虚函数表中查找虚函数地址,并调用虚函数的指令。
虚函数表的证明
c++
#include <iostream>
using namespace std;
class A {
public:
virtual void Func() { cout << "A::Func" << endl; }
};
class B : public A {
public:
virtual void Func() { cout << "B::Func" << endl; }
};
int main() {
A a;
A *pa = new B();
pa->Func();
//64位程序指针为8字节
long long *p1 = (long long *) &a;;
long long *p2 = (long long *) pa;
*p2 = *p1;
pa->Func();
return 0;
}
这段代码演示了C++中的虚函数和多态性,以及通过直接修改虚函数表指针来改变对象行为的技术。
让我们逐步解释这段代码:
-
首先,定义了两个类
A
和B
。A
类包含一个虚函数Func()
,而B
类继承自A
并重写了Func()
函数,给出了不同的实现。 -
在
main
函数中:- 创建了一个
A
类对象a
。 - 创建了一个指向
B
类对象的基类指针pa
。这里使用了多态性,因为pa
指向了B
类对象,但是声明为A
类指针。这意味着通过pa
调用虚函数Func()
时,将根据对象的实际类型调用相应的函数版本。
- 创建了一个
-
接下来,通过将
a
对象的内存内容拷贝到pa
对象的内存中,直接修改了虚函数表指针的值。这部分代码如下:c++long long *p1 = (long long *) &a; long long *p2 = (long long *) pa; *p2 = *p1;
这些操作实际上是将
a
对象的内存布局复制到pa
指向的对象中,包括虚函数表指针。这样,pa
现在指向的虚函数表与a
相同。 -
最后,再次通过
pa
调用虚函数Func()
。由于虚函数表指针已经被修改为与a
相同,因此将调用A
类中的Func()
函数。
c++等语言的指针设计可以直接访问内存而不是需要借助整个对象,使程序更加丰富和灵活
5. 虚析构函数、纯虚函数和抽象类
虚析构函数
- 通过基类的指针删除派生类对象时,通常情况下只调用基类的析构函数
- 但是,删除一个派生类的对象时,应该先调用派生类的析构函数,然后调用基类的析构函数。
- 解决办法:把基类的
析构函数声明为virtual
- 派生类的析构函数可以virtual不进行声明
- 通过基类的指针删除派生类对象时,首先调用派生类的析构函数,然后调用基类的析构函数
- 一般来说,一个类如果定义了虚函数,则应该将析构函数也定义成虚函数。3或者,一个类打算作为基类使用,也应该将析构函数定义成虚函数。
- 注意: 不允许以虚函数作为构造函数
纯虚函数和抽象类
- 包含纯虚函数的类叫抽象类
-
抽象类只能作为基类来派生新类使用,不能创建抽象类的对象
-
抽象类的指针和引用可以指向由抽象类派生出来的类的对象
A a ;// 错,A 是抽象类,不能创建对象
A*pa ; // ok,可以定义抽象类的指针和引用
pa = new A ;//错误,A 是抽象类,不能创建对象
-
- 在抽象类的成员函数内可以调用纯虚函数,但是在构造函数或析构函数内部不能调用纯虚函数。
- 如果一个类从抽象类派生而来,那么当且仅当它实现了基类中的所有纯虚函数,它才能成为非抽象类。
第七章 输入输出和模板
1. 输入输出流相关的类
输出重定向
freopen
函数是C/C++标准库中的一个函数,用于重新定向标准I/O流。你提到的代码片段 freopen("test.txt", "w", stdout);
的作用是将标准输出流 stdout
重定向到一个文件 "test.txt"
中,以便将输出写入到这个文件中,而不是默认的控制台输出。
具体解释如下:
"test.txt"
是要打开的文件的名称,这里是一个文本文件。"w"
表示以写入(write)模式打开文件。这将创建一个新文件(如果文件不存在),或者覆盖已存在的文件。stdout
是标准输出流的文件指针,它通常用于将输出写入控制台。
所以,freopen("test.txt", "w", stdout);
的效果是将标准输出流重定向到 "test.txt"
文件,之后所有的 cout
输出操作将被写入到 "test.txt"
文件中而不是显示在控制台上。
这种技术通常用于将程序的输出保存到文件中,以便稍后进行查看、分析或记录程序的输出。一旦调用了 freopen
,程序的输出将不再显示在终端上,而是写入到指定的文件中。如果需要再次将输出恢复到终端,可以使用类似的方法将 stdout
重定向到终端。
输入重定向
判断输入流结束
可以用如下方法判输入流结束:
c++
int x;
while(cin>>x){
...
}
已知
kotlinistream &operator >>(int a)//右移运算符在istream类的重载 { ... return *this ;//返回值是istream的引用,实际是cin的引用(即cin) }
cin是如何作为逻辑值的判断?
强制类型转换运算符的重载,返回值虽然是cin,但在istream类中有强制类型转换的重载,转换为布尔类型的值
- 如果是从文件输入,比如前面有freopen("some.txt","r",stdin);那么,读到文件尾部,输入流就算结束
- 如果从键盘输入,则在单独一行输入Ctrl+Z代表输入流结束
istream类的成员函数
istream
类是 C++ 标准库中用于输入流操作的基类,它提供了一系列成员函数来从输入源(通常是键盘、文件或其他输入设备)中读取数据。以下是一些常用的 istream
类成员函数:
operator>>
: 这是输入运算符重载函数,用于从输入流中读取数据并存储到变量中。它的参数取决于要读取的数据类型,例如int
、double
、char
、string
等。
dart
cppCopy code
istream& operator>>(Type& variable);
get
: 用于从输入流中逐字符读取数据,可以指定读取的字符数。
arduino
istream& get(char& ch);
istream& get(char* s, streamsize count, char delim = '\n');
-
getline
-
istream & getline (char * buf,int bufSize);
从输入流中读取
bufSize-1
个字符到缓冲区buf
,或读到碰到'\n'
为止(哪个先到算哪个)。 -
istream & getline (char * buf,int bufSize, char delim) ;
从输入流中读取
bufSize-1
个字符到缓冲区buf
,或读到碰到delim字符为止(哪个先到算哪个)。
两个函数都会自动在buf中读入数据的结尾添加
\0
。,
'n'
或delim都不会被读入buf,但会被从输入流中取走。 如果输入流中'\n'
或delim之前的字符个数达到或超过了bufSize个,就导致读入出错,其结果就是:虽然本次读入已经完成,但是之后的读入就都会失败了。 -
- 可以用
if(!cin.getline(...))
判断输入是否结束
-
good
、eof
、fail
和bad
:这些成员函数用于检查输入流的状态。
c++
bool good() const;//bool eof();判断输入流是否结束
bool eof() const;
bool fail() const;
bool bad() const;
peek
: 返回下一个字符,用于查看输入流中的下一个字符而不从流中移除它。
c++
int peek();
putback
: 用于将字符ch放回输入流中,以便后续读取。
c++
istream& putback(char ch);
ignore
: 用于跳过指定数量的字符或特定字符,通常用于清除输入流中的缓冲区或跳过不需要的字符。
arduino
istream& ignore(streamsize count = 1, int delim = EOF);//从流中删掉最多count个字符,遇到EOF时结束
unget
: 用于将一个字符放回输入流中,与putback
类似,但可以放回多个字符。
c++
istream& unget();
clear
: 用于清除流的错误状态标志,通常与ignore
和其他操作结合使用。
c++
void clear(iostate state = goodbit);
tie
: 用于设置或获取与输入流相关联的输出流。
c++
ostream* tie() const;
ostream* tie(ostream* new_tie);
以上是一些常用的 istream
类成员函数及其参数。这些函数允许你从输入流中读取和处理数据,并提供了一些控制输入流状态的方法,以满足不同的输入处理需求。具体的使用方式取决于要处理的数据类型和输入源。
2. 用流操纵算子控制输出格式
使用流操纵算子需要
#include <iomanip>
整数流的基数:流操纵算子dec,oct,hex
dec
: 这个流操纵算子用于将整数以十进制形式输出。
oct
: 这个流操纵算子用于将整数以八进制形式输出。
hex
: 这个流操纵算子用于将整数以十六进制形式输出。
c++
int main() {
int n = 10;
int m = 12;
cout << n << endl;
cout << hex << n << endl;
cout << m << endl;
cout << dec << n << endl << oct << n;
return 0;
}
/*输出结果
10
a
c
10
12
*/
5.泛型程序设计·函数模板
C++ 函数模板(Function Templates)是一种通用编程特性,允许你编写可以用于多种不同数据类型的函数。函数模板使得代码更加通用和可重用,它们在处理不同数据类型时可以减少代码的冗余。
注意事项:
- 函数模板的模板参数可以有多个,并且可以是不同的类型。
- 使用模板时,编译器会根据传递的参数类型自动匹配模板参数的具体类型。
- 如果你需要特殊化某个数据类型的函数行为,可以提供特殊化版本的模板函数。
函数模板是C++中强大的通用编程工具,它允许你编写通用、可重用的代码,以处理各种不同数据类型的情况。它在STL(标准模板库)中广泛使用,用于实现容器和算法。
函数模板︰
c++
template <class类型参数1 ,class类型参数2,......>
返回值类型 模板名(形参表)
{
函数体
};
在C++中,函数模板的实例化通常是通过函数的参数类型来自动确定的。这是函数模板的主要用途之一,因为它允许根据不同的参数类型生成不同的函数实例。
但是,如果你想不通过参数实例化函数模板,可以考虑使用以下两种方法:
- 显式指定模板参数类型:
你可以使用尖括号 < >
显式指定函数模板的模板参数类型,而不依赖于函数的参数类型。这样可以创建特定模板参数类型的函数实例。例如:
c++
template <typename T>
void MyFunction() {
// 函数模板的实现
}
int main() {
// 显式实例化函数模板为 int 类型
MyFunction<int>();
// 显式实例化函数模板为 double 类型
MyFunction<double>();
return 0;
}
在上面的示例中,通过在函数名后的尖括号中显式指定模板参数类型,可以实例化特定类型的函数。
- 使用模板特化:
另一种方法是使用模板特化(template specialization)来创建特定类型的函数实例。模板特化允许你为特定的数据类型提供定制的实现,而不仅仅是依赖于模板参数类型的自动推导。例如:
c++
// 通用模板
template <typename T>
void MyFunction() {
// 通用实现
}
// 模板特化为 int 类型
template <>
void MyFunction<int>() {
// int 类型的定制实现
}
int main() {
MyFunction<int>(); // 调用特化版本
MyFunction<double>(); // 调用通用版本
return 0;
}
在上面的示例中,我们创建了一个通用的函数模板 MyFunction()
,然后使用模板特化为 int
类型提供了一个定制的实现。当函数调用时,如果传递的参数类型是 int
,将调用特化版本;否则,将调用通用版本。
这两种方法都允许你在不依赖于函数的参数类型的情况下实例化函数模板。
函数模版的重载
函数模板可以重载,只要它们的形参表或类型参数表不同即可
在有多个函数和函数模板名字相同的情况下,编译器如下处理条函数调用语句
- 先找参数完全匹配的普通函数(非由模板实例化而得的函数
- 再找参数完全匹配的模板函数
- 再找实参数经过自动类型转换后能够匹配的普通函数
- 上面的都找不到,则报错
匹配模板函数时,不进行类型自动转换
c++
template <class T>
T myFunction(T arg1, T arg2)
{
cout << arg1 << " " << arg2 << "\n";
return arg1;
}
// ...
myFunction(5.8, 8.4); // ok: replace T with double
myFunction(5, 8.4); // error, no matching function for call 'myFunction(int, double)'
编译器不会将实参类型进行隐式转换,例如不会将5转换为5.0。
函数指针
-
函数指针:
- 函数指针是指向函数的指针变量。它允许你在运行时动态地选择要调用的函数,以便灵活地执行不同的操作。
- 在C/C++中,函数名本身就是指向函数的指针。
-
函数指针类型:
-
函数指针类型定义了函数指针所指向的函数的签名(参数类型和返回类型)。函数指针类型告诉编译器函数指针可以指向哪种类型的函数。
-
函数指针类型的声明方式为:
returnType (*pointerName)(parameterType1, parameterType2, ...)
,其中:returnType
是函数的返回类型。pointerName
是函数指针的名称。parameterType1
,parameterType2
, ... 是函数的参数类型。
-
-
函数模板:
- 函数模板允许你编写通用的函数,可以用于多种不同的数据类型。
- 在你的例子中,
Map
函数是一个函数模板,它接受迭代器范围和函数指针作为参数。
-
使用函数指针:
- 函数指针可以像普通函数一样调用,使用
()
运算符,并传递相应的参数。例如,int result = addPtr(5, 3);
。 - 在你的例子中,
Map
函数接受一个函数指针作为参数Pred op
,并在迭代器范围上应用该函数。
- 函数指针可以像普通函数一样调用,使用
-
模板函数的参数推断:
- 在函数模板调用时,编译器会自动推断模板参数的类型。在你的例子中,
Pred op
的类型与传递给它的函数的类型一致,因此函数指针类型double (*op)(double)
可以自动推导。
- 在函数模板调用时,编译器会自动推断模板参数的类型。在你的例子中,
-
函数指针的用途:
- 函数指针可以用于实现回调函数,允许你传递函数作为参数,以在特定事件发生时调用这些函数。
- 函数指针还常用于设计模式中,如策略模式,以根据需要在运行时选择不同的算法。
总结起来,函数指针是一项强大的C/C++特性,它允许你以灵活的方式处理和操作函数,使代码更具通用性和可扩展性。函数指针类型用于定义函数指针的签名,使编译器能够正确推断函数指针的类型。函数模板和函数指针的结合使得你可以编写通用的代码,能够处理多种不同的数据类型和操作。
eg
c++
template<class T,class Pred>
void Map(T s, T e,T x, Pred op) { for( ; s != e; ++s ,++x){ *x=op (*s); } }
double square (double ){return x * x; }
int a[5] = {1,2,3,4,5}, b[5];
double d[5] = { 1.1,2.1,3.1,4.1,5.1} , c[5];
int main()
{
Map (a ,a+5,b, Square) ;
for(int i = 0; i < 5; ++i)
cout <<b[i] <<" , ";
cout <<endl ;
}
这段代码运行会实例化出
void Map(int * s, int * e, int * x,double ( *op)(double))
,请逐步详细解释double ( *op)(double))
怎么得到的
在你提供的代码中,Map
函数是一个通用的模板函数,它接受两个迭代器 s
和 e
,还有一个指针 x
,以及一个函数指针 op
作为参数。Map
函数的目的是将一个操作 op
应用于输入范围 [s, e)
中的每个元素,并将结果存储在输出数组 x
中。
在你的代码中,Map
函数的调用如下:
c++
Map(a, a+5, b, Square);
这里传递了以下参数:
a
是一个整数数组的指针,表示输入范围的起始位置。a+5
是整数数组的指针,表示输入范围的结束位置(不包括)。b
是一个整数数组的指针,表示输出数组的起始位置。Square
是一个函数指针,它指向double Square(double)
函数。
现在,让我们逐步解释如何得到 double (*op)(double)
类型的函数指针:
Square
是一个函数,它接受一个double
参数并返回一个double
值。因此,Square
具有类型double (double)
。- 当你将
Square
作为参数传递给Map
函数时,编译器会自动进行函数指针的类型推断,将Square
的类型转化为函数指针类型。 - 因为
op
参数在Map
函数中的类型是Pred op
,而Pred
是模板参数,所以op
的类型将与传递给它的参数类型一致。在这种情况下,Square
的类型double (double)
被转化为double (*)(double)
,这表示一个指向接受double
参数并返回double
值的函数指针。
所以,double (*op)(double)
表示一个函数指针类型,该指针指向一个函数,该函数接受一个 double
参数并返回一个 double
值。在 Map
函数中,它被用于对输入范围中的元素进行操作。
6.泛型程序设计·类模板
类模板的定义
c++
template<class类型参数1 ,class类型参数2, .....>//类型参数表 注: <typename T>=<class T>
class 类模板名
{
成员函数和成员变量
};
类模板里成员函数的写法:
c++
template <class类型参数1,class类型参数2,......>//类型参数表
返回值类型 类模板名 <类型参数名列表>::成员函数名(参数表)
{
......
}
用类模板定义对象的写法:
c++
模板名<真实类型参数表>对象名(构造函数实参表);
eg.类模板
c++
template<class T1, class T2>
class Pair {
public:
T1 key;//关键字
T2 value;//值
Pair(T1 k, T2 v) : key(k), value(v) {};
bool operator<(const Pair<T1, T2> &p) const;
};
template<class T1, class T2>
bool Pair<T1, T2>::operator<(const Pair<T1, T2> &p) const
//Pair的成员函数operator <
{
return key < p.key;
}
int main() {
Pair<string, int> student("Tom", 19);//实例化出一个类Pair<string ,int>
cout << student.key << " " << student.value;
return 0;
}
同一个类模板的两个模板类是不兼容的
c++Pair<string ,int> *p; Pair<string , double> a ; p=&a; //wrong
函数模板作为类模板成员
c++
#include <iostream>
using namespace std;
template<class T>
class A {
public:
template<class T2>
void Func(T2 t) { cout << t; }//成员函数模板
};
int main() {
A<int> a;
a.Func('K'); //成员函数模板Func被实例化<char>
a.Func("he1lo"); //成员函数模板Func再次被实例化<char*>
return 0 ;
}
类模板与非类型参数
类模板的"<类型参数表>"中可以出现非类型参数:template <class T, int size>
c++
template<class T, int size>
class CArray {
T array[size];
public:
void Print() {
for (int i = 0; i < size; ++i)
cout << array[i] < endl;
}
};
CArray<double, 40> a2;
CArray<int, 50> a3;// a2和a3属于不同的类
7. 类模板与派生、友元和静态成员变量
类模板与继承
- 类模板从类模板派生
- 类模板从模板类派生
- 类模板从普通类派生
- 普通类从模板类派生
类模板与友元
- 函数、类、类的成员函数作为类模板的友元
- 函数模板作为类模板的友元
- 函数模板作为类的友元
- 类模板作为类模板的友元
类模板与static成员
- 类模板中可以定义静态成员,那么从该类模板实例化得到的所有类,都包含同样的静态成员。
c++
#include <iostream>
using namespace std;
template <class T>
class A
{
private:
static int count;
public:
A() { count ++;}
~A() { count -- ; };
A( A &) { count ++ ; }
static void PrintCount() { cout <<count <<endl; }
};
template<> int A<int> :: count = 0 ;
template<> int A<double> ::count = 0 ;
int main()
{
A<int> ia;
A<double> da;
ia PrintCount() ;
da.PrintCount() ;
return 0 ;
}
//输出:1
// 1
"template<> int A<int>::count = 0;
" 行和 "template<> int A<double>::count = 0;
" 行,对静态成员变量在类外部加以声明是必需的。在 Visual Studio 2008 中,这两行也可以简单地写成:
ini
int A<int>::count = 0;
int A<double>::count = 0;
A<int>
和 A<double>
是两个不同的类。虽然它们都有静态成员变量 count
,但是显然,A<int>
的对象 ia
和 A<double>
的对象 da
不会共享一份 count
。
同一个类模板实例出来类的静态成员变量名字一样,但是存储在不同的位置,不能共享。
静态成员变量必须拿到类的外面单独声明一下,从包含静态变量的类模板实例化出来的类,同样要把这个类的静态成员拿出来声明一下。生命的同时你可以对他进行初始化也可以不进行初始化
参考文章
当类模板中有静态成员变量时,情况与普通类的静态成员变量不同。普通类中的静态成员函数需要在某个代码文件中显式声明,以便在该代码文件编译后可以为静态成员变量留出存储空间以供之后链接使用。而类模板中的静态变量却无法如此处理。
C++标准提倡将模板的所有实现都放在头文件中以便编译器可以当场实现模板实例,这样能够避免产生跨目标链接 。但是类模板静态成员变量却与这一提倡冲突。类模板的静态成员变量是所有同类型的类模板实例共享的一块数据。 当多个目标文件中声明了同一类模板的同类型实例后,必然会产生跨目标文件链接。为了与标准所倡导的风格一致,C.++编译器都会对类模板静态成员变量做特殊处理。
只要静态成员变量的模板与其类模板同时可见,编译器就可针对类模板的静态成员变量做特殊处理:
- 在目标文件中写入类模板实例中静态成员变量的初始值。
- 将此模板实例静态成员变量做类似外部变量处理,即在汇编代码中为该变量临时分配一个内存地址,但在目标文件中标记该地址所关联的变量名以及链接属性等,以便在随后又链接器修改地址,以正确实现多个类模板实例共享同一内存地址
在链接时同样需要对类模板静态成员变量做特殊处理。因为类模板静态成员变量的实现以及初始值是写在头文件中,故而在每个包含了该头文件的代码文件中,都会存在若干个该类实例的静态成员变量"副本"。如果在不同文件中都生成了同一模板参数值的实例,则会有多个该实例的"副本",从而产生冲突。此时,链接器需要解决此冲突。
前言:
在c++中我们可以使用 static 关键字来把类成员定义为静态的。当我们声明类的成员为静态时,这意味着无论创建多少个类的对象,静态成员都只有一个副本。静态成员在类的所有对象中是共享的。如果不存在其他的初始化语句,在创建第一个对象时,所有的静态数据都会被初始化为零。
在c++中,我们不能把静态成员放置在类的定义中,但是可以在类的外部通过使用范围解析运算符 :: 来重新声明静态变量从而对它进行初始化,如下面的实例所示。
c++
#include <iostream>
using namespace std;
class Obj{
public:
// 声明类静态成员,这里不能对它进行初始化赋值
static int m_a;
};
// 初始化类静态成员(通过范围解析符::)
int Obj::m_a = 0;
int main(){
// 通过对象访问静态属性
Obj o;
o.m_a = 10;
cout << o.m_a << endl;
// 通过类访问静态属性
cout << Obj::m_a <<endl;
}
c++类模板遇到static
当类模板中出现static修饰的静态类成员的时候,我们只要按照正常理解就可以了。static的作用是将类的成员修饰成静态的,所谓的静态类成员就是指类的成员为类级别的,不需要实例化对象就可以使用,而且类的所有对象都共享同一个静态类成员,因为类静态成员是属于类而不是对象。那么,类模板的实现机制是通过二次编译原理实现的。c++编译器并不是在第一个编译类模板的时候就把所有可能出现的类型都分别编译出对应的类(太多组合了),而是在第一个编译的时候编译一部分,遇到泛型不会替换成具体的类型(这个时候编译器还不知道具体的类型),而是在第二次编译的时候再将泛型替换成具体的类型(这个时候编译器知道了具体的类型了)。由于类模板的二次编译原理再加上static关键字修饰的成员,当它们在一起的时候实际上一个类模板会被编译成多个具体类型的类,所以,不同类型的类模板对应的static成员也是不同的(不同的类),但相同类型的类模板的static成员是共享的(同一个类)。
c++
#include <iostream>
using namespace std;
template<typename T>
class Obj{
public:
static T m_t;
};
template<typename T>
T Obj<T>::m_t = 0;
int main(){
Obj<int> i1,i2,i3;
i1.m_t = 10;
i2.m_t++;
i3.m_t++;
cout << Obj<int>::m_t<<endl;
Obj<float> f1,f2,f3;
f1.m_t = 10;
f2.m_t++;
f3.m_t++;
cout << Obj<float>::m_t<<endl;
Obj<char> c1,c2,c3;
c1.m_t = 'a';
c2.m_t++;
c3.m_t++;
cout << Obj<char>::m_t<<endl;
}
输出:
c++12 12 c
可以看到相同类型如int对应的类模板的对象之间的static成员是共享的,不同类型之间如int,float,char对应的类模板的对象之间的static是不共享的。