c++八股文整理(四)

1.继承机制中对象之间如何转换?指针和引用之间如何转换?

 

1. 向上类型转换

将派生类指针或引用转换为基类的指针或引用被称为向上类型转换,向上类型转换会自动进行,而且向 上类型转换是安全的。

2. 向下类型转换

将基类指针或引用转换为派生类指针或引用被称为向下类型转换,向下类型转换不会自动进行,因为一 个基类对应几个派生类,所以向下类型转换时不知道对应哪个派生类,所以在向下类型转换时必须加动态类型识别技术。RTTI技术,用dynamic_cast进行向下类型转换。

2. C++的四种强制转换reinterpret_cast/const_cast/static_cast /dynamic_cast

reinterpret_cast :reinterpret_cast<type-id> (expression) ;用于类型之间进行强制转换。

const_cast:const_cast<type_id> (expression);用来修改类型的const或volatile属性。用法如下:1. 常量指针被转化成非常量的指针,并且仍然指向原来的对象 2.常量引用被转换成非常量的引用,并且仍然指向原来的对象

​​​​​​​static_cast:static_cast < type-id > (expression); 没有运行时类型检查来保证转换的安全性。它主要有如下几种用法:

  •  1.用于类层次结构中基类(父类)和派生类(子类)之间指针或引用引用的转换

  • 进行上行转换(把派生类的指针或引用转换成基类表示)是安全的
  • 进行下行转换(把基类指针或引用转换成派生类表示)时,由于没有动态类型检查,所以是不安全的
  • 2.用于基本数据类型之间的转换,如把int转换成char,把int转换成enum。这种转换的安全性也要开发人员来保证。

  • 3.把空指针转换成目标类型的空指针

  • 4.把任何类型的表达式转换成void类型

注意:static_cast不能转换掉expression的const、volatile、或者__unaligned属性。

dynamic_cast:dynamic_cast <type-id> (expression);有类型检查基类向派生类转换比较安全,但是派生类向基类转换则不太安全?(与static_cast相反?

dynamic_cast运算符可以在执行期决定真正的类型,也就是说expression必须是多态类型。如果下行转换是安全的(也就说,如果基类指针或者引用确实指向一个派生类对象)这个运算符会传回适当转型过的指针。如果 如果下行转换不安全,这个运算符会传回空指针(也就是说,基类指针或者引用没有指向一个派生类对象)

dynamic_cast主要用于类层次间的上行转换和下行转换,还可以用于类之间的交叉转换

在类层次间进行上行转换时,dynamic_cast和static_cast的效果是一样的

在进行下行转换时,dynamic_cast具有类型检查的功能,比static_cast更安全

举个例子:

#include <bits/stdc++.h>
using namespace std;

class Base
{
public:
	Base() :b(1) {}
	virtual void fun() {};
	int b;
};

class Son : public Base
{
public:
	Son() :d(2) {}
	int d;
};

int main()
{
	int n = 97;

	//reinterpret_cast
	int *p = &n;
	//以下两者效果相同
	char *c = reinterpret_cast<char*> (p); 
	char *c2 =  (char*)(p);
	cout << "reinterpret_cast输出:"<< *c2 << endl;
	//const_cast
	const int *p2 = &n;
	int *p3 = const_cast<int*>(p2);
	*p3 = 100;
	cout << "const_cast输出:" << *p3 << endl;
	
	Base* b1 = new Son;
	Base* b2 = new Base;

	//static_cast
	Son* s1 = static_cast<Son*>(b1); //同类型转换
	Son* s2 = static_cast<Son*>(b2); //下行转换,不安全
	cout << "static_cast输出:"<< endl;
	cout << s1->d << endl;
	cout << s2->d << endl; //下行转换,原先父对象没有d成员,输出垃圾值

	//dynamic_cast
	Son* s3 = dynamic_cast<Son*>(b1); //同类型转换
	Son* s4 = dynamic_cast<Son*>(b2); //下行转换,安全
	cout << "dynamic_cast输出:" << endl;
	cout << s3->d << endl;
	if(s4 == nullptr)
		cout << "s4指针为nullptr" << endl;
	else
		cout << s4->d << endl;
	
	
	return 0;
}
//输出结果
//reinterpret_cast输出:a
//const_cast输出:100
//static_cast输出:
//2
//-33686019
//dynamic_cast输出:
//2
//s4指针为nullptr

从输出结果可以看出,在进行下行转换时,dynamic_cast安全的,如果下行转换不安全的话其会返回空指针,这样在进行操作的时候可以预先判断。而使用static_cast下行转换存在不安全的情况也可以转换成功,但是直接使用转换后的对象进行操作容易造成错误。

3.C++函数调用的压栈过程

以例子进行讲解,从代码入手,解释这个过程:

#include <iostream>
using namespace std;

int f(int n) 
{
	cout << n << endl;
	return n;
}

void func(int param1, int param2)
{
	int var1 = param1;
	int var2 = param2;
	printf("var1=%d,var2=%d", f(var1), f(var2));//如果将printf换为cout进行输出,输出结果则刚好相反
}

int main(int argc, char* argv[])
{
	func(1, 2);
	return 0;
}
//输出结果
//2
//1
//var1=1,var2=2

当函数从入口函数main函数开始执行时,编译器会将我们操作系统的运行状态,main函数的返回地址、main的参数、mian函数中的变量(分别指什么?)进行依次压栈;

当main函数开始调用func()函数时,编译器此时会将main函数的运行状态进行压栈,再将func()函数的返回地址、func()函数的参数从右到左、func()定义变量依次压栈;

当func()调用f()的时候,编译器此时会将func()函数的运行状态进行压栈,再将的返回地址、f()函数的参数从右到左、f()定义变量依次压栈

从代码的输出结果可以看出,函数f(var1)、f(var2)依次入栈,而后先执行f(var2),再执行f(var1),最后打印整个字符串,将栈中的变量依次弹出,最后主函数返回。

4.方法调用的原理(栈,汇编)

  • 机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复,以及本地存储。而为单个过程分配的那部分栈称为帧栈;帧栈可以认为是程序栈的一段,它有两个端点,一个标识起始地址,一个标识着结束地址,两个指针结束地址指针esp,开始地址指针ebp;
  • 由一系列栈帧构成,这些栈帧对应一个过程,而且每一个栈指针+4的位置存储函数返回地址;每一个栈帧都建立在调用者的下方,当被调用者执行完毕时,这一段栈帧会被释放。由于栈帧是向地址递减的方向延伸,因此如果我们将栈指针减去一定的值,就相当于给栈帧分配了一定空间的内存。如果将栈指针加上一定的值,也就是向上移动,那么就相当于压缩了栈帧的长度,也就是说内存被释放了。​​​​​​​

5.C++中将临时变量作为返回值时的处理过程

  • 临时变量,在函数调用过程中是被压到程序进程的栈中的,当函数退出时,临时变量出栈,即临时变量已经被销毁,临时变量占用的内存空间没有被清空,但是可以被分配给其他变量,所以有可能在函数退出时,该内存已经被修改了,对于临时变量来说已经是没有意义的值了
  • C语言里规定:16bit程序中,返回值保存在ax寄存器中,32bit程序中,返回值保持在eax寄存器中,如果是64bit返回值,edx寄存器保存高32bit,eax寄存器保存低32bit
  • 由此可见,函数调用结束后,返回值被临时存储到寄存器中,并没有放到堆或栈中,也就是说与内存没有关系了。当退出函数的时候,临时变量可能被销毁,但是返回值却被放到寄存器中与临时变量的生命周期没有关系。
  • 如果我们需要返回值,一般使用赋值语句就可以了

6.静态类型和动态类型,静态绑定和动态绑定的介绍

  • 静态类型:对象在声明时采用的类型,在编译期既已确定;
  • 动态类型:通常是指一个指针或引用目前所指对象的类型,是在运行期决定的;
  • 静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期;
  • 动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;
  • 非虚函数一般都是静态绑定,而虚函数都是动态绑定(如此才可实现多态性)。 举个例子:
    #include <iostream>
    using namespace std;
    
    class A
    {
    public:
    	/*virtual*/ void func() { std::cout << "A::func()\n"; }
    };
    class B : public A
    {
    public:
    	void func() { std::cout << "B::func()\n"; }
    };
    class C : public A
    {
    public:
    	void func() { std::cout << "C::func()\n"; }
    };
    int main()
    {
    	C* pc = new C(); //pc的静态类型是它声明的类型C*,动态类型也是C*;
    	B* pb = new B(); //pb的静态类型和动态类型也都是B*;
    	A* pa = pc;      //pa的静态类型是它声明的类型A*,动态类型是pa所指向的对象pc的类型C*;
    	pa = pb;         //pa的动态类型可以更改,现在它的动态类型是B*,但其静态类型仍是声明时候的A*;
    	C *pnull = NULL; //pnull的静态类型是它声明的类型C*,没有动态类型,因为它指向了NULL;
        
        pa->func();      //A::func() pa的静态类型永远都是A*,不管其指向的是哪个子类,都是直接调用A::func();
    	pc->func();      //C::func() pc的动、静态类型都是C*,因此调用C::func();
    	pnull->func();   //C::func() 不用奇怪为什么空指针也可以调用函数,因为这在编译期就确定了,和指针空不空没关系;
    	return 0;
    }
     

    如果将A类中的virtual注释去掉,则运行结果是:

    pa->func();      //B::func() 因为有了virtual虚函数特性,pa的动态类型指向B*,因此先在B中查找,找到后直接调用;
    pc->func();      //C::func() pc的动、静态类型都是C*,因此也是先在C中查找;
    pnull->func();   //空指针异常,因为是func是virtual函数,因此对func的调用只能等到运行期才能确定,然后才发现pnull是空指针;
    

    在上面的例子中,

  • 如果基类A中的func不是virtual函数,那么不论pa、pb、pc指向哪个子类对象,对func的调用都是在定义pa、pb、pc时的静态类型决定,早已在编译期确定了。
  • 同样的空指针也能够直接调用no-virtual函数而不报错(这也说明一定要做空指针检查啊!),因此静态绑定不能实现多态;
  • 如果func是虚函数,那所有的调用都要等到运行时根据其指向对象的类型才能确定,比起静态绑定自然是要有性能损失的,但是却能实现多态特性;

     本文代码里都是针对指针的情况来分析的,但是对于引用的情况同样适用。

    至此总结一下静态绑定和动态绑定的区别:

  1. 静态绑定发生在编译期,动态绑定发生在运行期;
  2. 对象的动态类型可以更改,但是静态类型无法更改;
  3. 要想实现动态,必须使用动态绑定;
  4. 在继承体系中只有虚函数使用的是动态绑定,其他的全部是静态绑定;

建议:

绝对不要重新定义继承而来的非虚(non-virtual)函数(《Effective C++ 第三版》条款36),因为这样导致函数调用由对象声明时的静态类型确定了,而和对象本身脱离了关系,没有多态,也这将给程序留下不可预知的隐患和莫名其妙的BUG;另外,在virtual函数中,要注意默认参数的使用。当缺省参数和virtual函数一起使用的时候一定要谨慎,不然出了问题怕是很难排查。 看下面的代码:

#include <iostream>
using namespace std;

class E
{
public:
	virtual void func(int i = 0)
	{
		std::cout << "E::func()\t" << i << "\n";
	}
};
class F : public E
{
public:
	virtual void func(int i = 1)
	{
		std::cout << "F::func()\t" << i << "\n";
	}
};

void test2()
{
	F* pf = new F();
	E* pe = pf;
	pf->func(); //F::func() 1  正常,就该如此;
	pe->func(); //F::func() 0  哇哦,这是什么情况,调用了子类的函数,却使用了基类中参数的默认值!
}
int main()
{
	test2();
	return 0;
}

7.引用是否能实现动态绑定,为什么可以实现?

可以。引用在创建的时候必须初始化,在访问虚函数时,编译器会根据其所绑定的对象类型决定要调用哪个函数。注意只能调用虚函数,非虚函数不行。

举个例子:

#include <iostream>
using namespace std;

class Base 
{
public:
	virtual void  fun()
	{
		cout << "base :: fun()" << endl;
	}
};

class Son : public Base
{
public:
	virtual void  fun()
	{
		cout << "son :: fun()" << endl;
	}
	void func()
	{
		cout << "son :: not virtual function" <<endl;
	}
};

int main()
{
	Son s;
	Base& b = s; // 基类类型引用绑定已经存在的Son对象,引用必须初始化
	s.fun(); //son::fun()
	b.fun(); //son :: fun()
	return 0;
}

需要说明的是虚函数才具有动态绑定,上面代码中,Son类中还有一个非虚函数func(),这在b对象中是无法调用的,如果使用基类指针来指向子类也是一样的。

8.C++中的指针参数传递和引用参数传递有什么区别?底层原理你知道吗?

作为参数时,传指针的实质是传值,传递的值是指针的地址;传引用的实质是传地址,传递的是变量的地址

引用访问一个变量是直接访问,而指针访问一个变量是间接访问。

指针和引用的自增运算结果不一样。(指针是指向下一个空间,引用时引用的变量值加1)。

指针是一个实体,需要分配内存空间。引用只是变量的别名,不需要分配内存空间。

引用在定义的时候必须进行初始化,并且不能够改变。指针在定义的时候不一定要初始化,并且指向的空间可变。(注:不能有引用的值不能为NULL)

1) 指针参数传递本质上是值传递,它所传递的是一个地址值。

值传递过程中,被调函数的形式参数作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本(替身)。

值传递的特点是,被调函数对形式参数的任何操作都是作为局部变量进行的,不会影响主调函数的实参变量的值(形参指针变了,实参指针不会变)。

2) 引用参数传递过程中,被调函数的形式参数也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。

被调函数对形参(本体)的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量(根据别名找到主调函数中的本体)。

因此,被调函数对形参的任何操作都会影响主调函数中的实参变量。

3) 引用传递和指针传递是不同的,虽然他们都是在被调函数栈空间上的一个局部变量,但是任何对于引用参数的处理都会通过一个间接寻址的方式操作到主调函数中的相关变量。

而对于指针传递的参数,如果改变被调函数中的指针地址,它将应用不到主调函数的相关变量。如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使用指向指针的指针或者指针引用。

4) 从编译的角度来讲,程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应地址。

指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值(与实参名字不同,地址相同)。

符号表生成之后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),而引用对象则不能修改。

引用和指针的方式作为传参方式都会保留该函数对传入参数的改变,但这两种方法还是有区别的。这个区别表现在,引用的方式下可以直接使用该参数,而指针的方式通常都需要解引用,或者直接看成一个指针具体表现如下:

void printVector(vector<int>& a)
{
    for(int i=0; i<a.size();i++)
        cout << a[i] << " ";
    
    cout << endl;
}
 
void swapFirstandLast(vector<int>* a)
// 这里a是指针名,a指向的内容*a,才是原来的vector<int> a 
{
    int temp = (*a)[0];
    (*a)[0] = (*a)[a->size()-1];
    (*a)[a->size()-1] = temp;
}
 
void test1()
{
    vector<int> a;
    a.insert(a.end(), 1);
    a.insert(a.end(), 2);
    a.insert(a.end(), 3);
    
    printVector(a);
    swapFirstandLast(&a);
    printVector(a);

在引用传递的情况下,可以直接使用a.size()。而在指针传递时,就需要使用a->size(),或者(*a).size()。即指针传入时,要么使用解引用,要么就直接看成指针,使用->来调用内部的函数。

当我们传入的参数是一个数组时,是一种特殊情况。因为数组名同时可以作为指针,所以传入时可以不用使用取地址符&,在取数组中的某个数时,也可以不用解引用。

void printVectorB(int b[], int n)
{
    for(int i=0; i<n; i++)
        cout << b[i] << " ";
    cout << endl;
}
 
void swapFirstandLastB(int* b, int n)
{
    int temp = b[0];
    b[0] = b[n-1];
    b[2] = temp;
}
 
void test2()
{
    int b[] = {4,5,6};
    int n = sizeof(b)/sizeof(int);
    printVectorB(b, n);
    swapFirstandLastB(b, n);
    printVectorB(b, n);
}

1 如果数据对象较小,如内置数据类型或小型结构,则按值传递
2 如果数据对象是数组,则使用指针,并将指针声明为指向const的指针
3 如果数据对象是较大的结构,则使用const指针或const引用,以提高运行效率,以减少复制结构所需要的时间和空间
4 如果数据对象是类对象,则使用const引用,传递类对象参数的标准方式是按引用传递

函数返回对象时,还会进行两次复制构造:先将返回的对象复制到临时空间,这是第一次复制构造;而后释放函数所占空间;最后将临时空间的对象复制到调用者的对象,这是第二次复制构造。这个过程复制构造的代价甚至超过了参数值传递。那么,能否也使用引用传递避免复制构造呢?当然可以,不过这就需要用到C++11最重要的新特性——右值引用。右值引用非常复杂,这里不展开,后面有时间再详细讲解。

既然引用传递这么好,是不是说,只要函数体内不会更改变量值,我们都可以使用引用传递替代值传递呢?回答这个问题之前,我们需要先了解引用传递底层的实现机制。看下面两个函数:

第一个函数是引用传递,而第二个函数,实际上是编译器对第一个函数进行解释的结果。也就是说,引用传递,底层是用一个指针实现的,该指针不可指向其他变量,但指针所指内容可以修改(记不清const和指针关系的,赶紧去补课…)在第一个函数中所有对n的操作,会被翻译为第二个函数中对*ptr_n的操作,二者完全等价。也就是说,C++的引用传递,底层是用指针实现,在传递时会产生一个指针变量的开销。

那么问题来了,对于我们现在普遍使用的64位机,指针变量占空间是8字节。而所有基本类型,长度都小于等于8字节。因此,如果参数是基本类型,引用传递反而比值传递产生了更大的内存开销!因此,如果传递的是基本数据类型,则值传递一般优于引用传递。

总结:如果是传递对象,引用传递可以避免复制构造带来的大量开销;如果是传递基本数据类型,引用传递因其指针带来的开销,反而不如值传递。

9.类如何实现只能静态分配和只能动态分配。(不同于动态绑定)

1) 前者是把new、delete运算符重载为private属性。后者是把构造、析构函数设为protected属性,再用子类来动态创建

2) 建立类的对象有两种方式:

① 静态建立,静态建立一个类对象,就是由编译器为对象在栈空间中分配内存;

② 动态建立,A *p = new A();动态建立一个类对象,就是使用new运算符为对象在堆空间中分配内存。这个过程分为两步,第一步执行operator new()函数,在堆中搜索一块内存并进行分配;第二步调用类构造函数构造对象;

3) 只有使用new运算符,对象才会被建立在堆上,因此只要限制new运算符就可以实现类对象只能建立在栈上。可以将new运算符设为私有。

10.全局变量和局部变量有什么区别?

生命周期不同:全局变量随主程序创建和创建,随主程序销毁而销毁;局部变量在局部函数内部,甚至局部循环体等内部存在,退出就不存在;

使用方式不同:通过声明后全局变量在程序的各个部分都可以用到;局部变量分配在堆栈区,只能在局部使用。

操作系统和编译器通过内存分配的位置可以区分两者,全局变量分配在全局数据段并且在程序开始运行的时候被加载。局部变量则分配在堆栈里面 。

非静态全局变量的作用域是整个源程序,当一个源程序由多个原文件组成时,非静态的全局变量在各个源文件中都是有效的。

静态全局变量则限制了其作用域,即只在定义该变量的源文件内有效,在同一源程序的其它源文件中不能使用它。由于静态全局变量的作用域限于一个源文件内,只能为该源文件内的函数公用,因此可以避免在其他源文件中引起错误。

11.C++模板是什么,底层怎么实现的?

1) 编译器并不是把函数模板处理成能够处理任意类的函数;编译器从函数模板通过具体类型产生不同的函数;编译器会对函数模板进行两次编译:在声明的地方对模板代码本身进行编译,在调用的地方对参数替换后的代码进行编译。

2) 这是因为函数模板要被实例化后才能成为真正的函数,在使用函数模板的源文件中包含函数模板的头文件,如果该头文件中只有声明,没有定义,那编译器无法实例化该模板,最终导致链接错误。

12.​​​​​​​移动构造函数

1) 我们用对象a初始化对象b,后对象a我们就不在使用了,但是对象a的空间还在呀(在析构之前),既然拷贝构造函数,实际上就是把a对象的内容复制一份到b中,那么为什么我们不能直接使用a的空间呢?这样就避免了新的空间的分配,大大降低了构造的成本。这就是移动构造函数设计的初衷;

2) 拷贝构造函数中,对于指针,我们一定要采用深层复制,而移动构造函数中,对于指针,我们采用浅层复制。浅层复制之所以危险,是因为两个指针共同指向一片内存空间,若第一个指针将其释放,另一个指针的指向就不合法了。所以我们只要避免第一个指针释放空间就可以了。避免的方法就是将第一个指针(比如a->value)置为NULL,这样在调用析构函数的时候,由于有判断是否为NULL的语句,所以析构a的时候并不会回收a->value指向的空间;

3) 移动构造函数的参数和拷贝构造函数不同,拷贝构造函数的参数是一个左值引用,但是移动构造函数的初值是一个右值引用。意味着,移动构造函数的参数是一个右值或者将亡值的引用。也就是说,只用用一个右值,或者将亡值初始化另一个对象的时候,才会调用移动构造函数。而那个move语句,就是将一个左值变成一个将亡值。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值