C++面试基础知识-2
- 2.7 OOP面向对象的思想
- 2.8 构造函数
- 2.7.2 定义一个空类,会默认生成哪些函数
- 2.8 C++类的初始化顺序,在多重继承下的顺序
- 2.9 向上转型和向下转型
- 2.10 深拷贝和浅拷贝
- 2.11 简述C++中的多态
- 2.12 为什么要虚析构,为什么不能虚构造
- 2.13 模板类是什么实现的
- 2.14 简述什么是移动构造函数,什么库用到这个函数
- 2.15 C++类定定义定义数据成员
- 2.16 什么是常函数,有什么作用
- 2.17 什么是虚继承,解决什么问题,如何实现?
- 2.18 什么是虚函数和纯虚函数,以及实现原理
- 2.19 什么是菱形继承,如何解决
- 2.20 如何理解抽象类
- 2.21 简述什么是虚析构函数,什么作用?
- 2.22 简述一下拷贝赋值和移动赋值
- 2.23 C++ 中哪些函数不能被声明为虚函数?
- 2.23 虚函数表里存放的内容是什么时候写进去的?
2.7 OOP面向对象的思想
面向对象:将数据与操作内部数据的函数绑定到一起,进行封装,这样能够更快速的开发程序,减少了重复代码的重写过程。
三大特性之一:多态:用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。实现多态,有二种方式,重写,重载。
2.7.1 重写和重载的区别?
- 重写
是指派生类中存在重新定义的函数。其函数名,参数列表,返回值类型,所有都必须同基类中被重写的函数一致。只有函数体不同(花括号内),派生类对象调用时会调用派生类的重写函数,不会调用被重写函数。重写的基类中被重写的函数必须有virtual修饰。 虚函数修饰。
【示例】
#include<bits/stdc++.h>
usding namespace std;
class A{
public:
virtual void fun(){
cout<<"A";
}
};
class B :public A{
public:
virtual void fun(){
cout<< "B";
}
};
int main(){
A* a = new B(); // 基类执行派生类
a->fun(); // 输出B,A类中的fun在B中被重写
}
- 重载
函数重载是指同一可访问区内被声明的几个具有不同参数列(参数的类型,个数,顺序不同)的同名函数,根据参数列表确定调用哪个函数,重载不关心函数返回类型。
【示例】
#include<bits/stdc++.h>
using namespace std;
class A
{
void fun() {};
void fun(int i) {};
void fun(int i, int j) {};
};
2.7.2 C++的重载和重写是如何实现的
- 如何实现重载
C++利用命名倾轧(name mangling)技术,来改名函数名,区分参数不同的同名函数。命名倾轧是在编译阶段完成的。 函数名会加上之前的不同来唯一标识,会添加参数类型和返回类型作为函数编译后的名称。
#include<iostream>
using namespace std;
int func(int a,double b)
{
return ((a)+(b));
}
int func(double a,float b)
{
return ((a)+(b));
}
int func(float a,int b)
{
return ((a)+(b));
}
int main()
{
return 0;
}
-
如何实现重写
在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。 -
用virtual关键字申明的函数叫做虚函数,虚函数肯定是类的成员函数。
-
存在虚函数的类都有一个一维的虚函数表叫做虚表,类的对象有一个指向虚表开始的虚指针。虚表是和类对应的,虚表指针是和对象对应的。
-
多态性是一个接口多种实现,是面向对象的核心,分为类的多态性和函数的多态性。
-
重写用虚函数来实现,结合动态绑定。
-
纯虚函数是虚函数再加上 = 0。
-
抽象类是指包括至少一个纯虚函数的类。
-
纯虚函数:virtual void fun()=0。即抽象类必须在子类实现这个函数,即先有名称,没有内容,在派生类实现内容。
2.8 构造函数
C++中的构造函数可以分为4类:默认构造函数、初始化构造函数、拷贝构造函数、移动构造函数。
1 默认构造函数和初始化函数
在定义类的对象的时候,完成对象的初始化工作。
class Student
{
public:
//默认构造函数
Student()
{
num=1001;
age=18;
}
//初始化构造函数
Student(int n,int a):num(n),age(a){}
private:
int num;
int age;
};
int main()
{
//用默认构造函数初始化对象S1
Student s1;
//用初始化构造函数初始化对象S2
Student s2(1002,18);
return 0;
}
- 拷贝构造函数
#include "stdafx.h"
#include "iostream.h"
class Test
{
int i; int *p;
public:
Test(int ai,int value)
{
i = ai;
p = new int(value);
}
~Test()
{
delete p;
}
Test(const Test& t)
{
this->i = t.i;
this->p = new int(*t.p);
}
};
//复制构造函数用于复制本类的对象
int main(int argc, char* argv[])
{
Test t1(1,2);
Test t2(t1);//将对象t1复制给t2。注意复制和赋值的概念不同
return 0;
}
赋值构造函数默认实现的是值拷贝(浅拷贝).
- 移动构造函数。用于将其他类型的变量,隐式转换为本类对象。见得少。
2.7.1 只定义析构函数,编译器会自动生成哪些构造函数
只定义了析构函数,编译器将自动为我们生成拷贝构造函数和默认构造函数。
2.7.2 定义一个空类,会默认生成哪些函数
定义一个空类:
class Empty
{
};
默认会生成如下四个函数:
- 无参的构造函数
在定义类的对象的时候,完成对象的初始化工作。
Empty()
{
} - 拷贝构造函数
拷贝构造函数用于复制本类的对象
Empty(const Empty& copy)
{
} - 赋值运算符
Empty& operator = (const Empty& copy)
{
} - 析构函数(非虚)
~Empty(){
}
2.8 C++类的初始化顺序,在多重继承下的顺序
- 创建派生类的对象,基类的构造函数优先被调用(也优先于派生类里的成员类);
- 如果类里面有成员类,成员类的构造函数优先被调用;(也优先于该类本身的构造函数)
- 基类构造函数如果有多个基类,则构造函数的调用顺序是某类在类派生表中出现的顺序而不是它们在成员初始化表中的顺序;
- 成员类对象构造函数如果有多个成员类对象,则构造函数的调用顺序是对象在类中被声明的顺序而不是它们出现在成员初始化表中的顺序;
- 派生类构造函数,作为一般规则派生类构造函数应该不能直接向一个基类数据成员赋值而是把值传递给适当的基类构造函数,否则两个类的实现变成紧耦合的(tightly coupled)将更加难于正确地修改或扩展基类的实现。(基类设计者的责任是提供一组适当的基类构造函数)
【总结】综上可以得出,初始化顺序:
- 父类构造函数–>成员类对象构造函数–>自身构造函数
- 其中成员变量的初始化与声明顺序有关,构造函数的调用顺序是类派生列表中的顺序。
- 析构顺序和构造顺序相反。
2.9 向上转型和向下转型
- 子类转换为父类:向上转型,使用dynamic_cast(expression),这种转换相对来说比较安全不会有数据的丢失;
- 父类转换为子类:向下转型,可以使用强制转换,这种转换时不安全的,会导致数据的丢失,原因是父类的指针或者引用的内存中可能不包含子类的成员的内存。
2.10 深拷贝和浅拷贝
- 浅拷贝:又称值拷贝,将源对象的值拷贝到目标对象中去,本质上来说源对象和目标对象共用一份实体,只是所引用的变量名不同,地址其实还是相同的。举个简单的例子,你的小名叫西西,大名叫冬冬,当别人叫你西西或者冬冬的时候你都会答应,这两个名字虽然不相同,但是都指的是你。
- 深拷贝,拷贝的时候先开辟出和源对象大小一样的空间,然后将源对象里的内容拷贝到目标对象中去,这样两个指针就指向了不同的内存位置。并且里面的内容是一样的,这样不但达到了我们想要的目的,还不会出现问题,两个指针先后去调用析构函数,分别释放自己所指向的位置。即为每次增加一个指针,便申请一块新的内存,并让这个指针指向新的内存,深拷贝情况下,不会出现重复释放同一块内存的错误。
解决了我们的指针悬挂问题,通过不断的开空间让不同的指针指向不同的内存,以防止同一块内存被释放两次的问题
2.11 简述C++中的多态
派生类重写基类方法,然后用基类引用指向派生类对象,调用方法时候会进行动态绑定,这就是多态。
多态分为静态多态和动态多态。
- 静态多态–重载
编译器在编译期间完成的,编译器会根据实参类型来推断该调用哪个函数,如果有对应的函数,就调用,没有则在编译时报错。
include<iostream>
using namespace std;
int Add(int a,int b)//1
{
return a+b;
}
char Add(char a,char b)//2
{
return a+b;
}
int main()
{
cout<<Add(666,888)<<endl;//1
cout<<Add('1','2');//2
return 0;
}
- 动态多态–动态绑定条件
- 虚函数----基类中必须有虚函数,在派生类中必须重写虚函数。
- 通过基类类型的指针或引用来调用虚函数。
重写——也就是基类中有一个虚函数,而在派生类中也要重写一个原型(返回值、名字、参数)都相同的虚函数。协变是重写的特例,基类中返回值是基类类型的引用或指针,在派生类中,返回值为派生类类型的引用或指针。
//协变测试函数
#include<iostream>
using namespace std;
class Base
{
public:
virtual Base* FunTest()
{
cout << "victory" << endl;
return this;
}
};
class Derived :public Base
{
public:
virtual Derived* FunTest()
{
cout << "yeah" << endl;
return this;
}
};
int main()
{
Base b;
Derived d;
b.FunTest();
d.FunTest();
return 0;
}
2.12 为什么要虚析构,为什么不能虚构造
2.12.1 为什么虚析构
虚析构:将可能会被继承的父类的析构函数设置为虚函数,可以保证当我们new一个子类,然后使用基类指针指向该子类对象,释放基类指针时可以释放掉子类的空间,防止内存泄漏。
- 用派生类类型指针绑定派生类实例,析构的时候,不管基类析构函数是不是虚函数,都会正常析构
- 用基类类型指针绑定派生类实例,析构的时候,如果基类析构函数不是虚函数,则只会析构基类,不会析构派生类对象,从而造成内存泄漏。
C++默认的析构函数不是虚函数是因为虚函数需要额外的虚函数表和虚表指针,占用额外的内存。而对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存。因此C++默认的析构函数不是虚函数,而是只有当需要当作父类时,设置为虚函数。
2.12.2 为什么不能虚构造
虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
2.13 模板类是什么实现的
- 模板实例化:模板的实例化分为显示实例化和隐式实例化,前者是研发人员明确的告诉模板应该使用什么样的类型去生成具体的类或函数,后者是在编译的过程中由编译器来决定使用什么类型来实例化一个模板不管是显示实例化或隐式实例化,最终生成的类或函数完全是按照模板的定义来实现的
- 模板具体化:当模板使用某种类型类型实例化后生成的类或函数不能满足需要时,可以考虑对模板进行具体化。具体化时可以修改原模板的定义,当使用该类型时,按照具体化后的定义实现,具体化相当于对某种类型进行特殊处理。
#include <iostream>
using namespace std;
// #1 模板定义
template<class T>
struct TemplateStruct
{
TemplateStruct()
{
cout << sizeof(T) << endl;
}
};
// #2 模板显示实例化
template struct TemplateStruct<int>;
// #3 模板具体化
template<> struct TemplateStruct<double>
{
TemplateStruct() {
cout << "--8--" << endl;
}
};
int main()
{
TemplateStruct<int> intStruct;
TemplateStruct<double> doubleStruct;
// #4 模板隐式实例化
TemplateStruct<char> llStruct;
}
2.14 简述什么是移动构造函数,什么库用到这个函数
移动操作的概念对对象管理它们使用的存储空间很有用的,诸如对象使用new和delete分配内存的时候。在这类对象中,拷贝和移动是不同的操作:从A拷贝到B意味着,B分配了新内存,A的整个内容被拷贝到为B分配的新内存上。而从A移动到B意味着分配给A的内存转移给了B,没有分配新的内存,它仅仅包含简单地拷贝指针。
// 移动构造函数和赋值
#include <iostream>
#include <string >
using namespace std;
class Example6{
private:
string* ptr;
public:
Example6 (const string& str) : ptr(new string(str)) {}; // 初始化赋值操作
~Example6 () {delete ptr;}
// 移动构造函数,参数x不能是const Pointer&& x,
// 因为要改变x的成员数据的值;
// C++98不支持,C++0x(C++11)支持
Example6 (Example6&& x) : ptr(x.ptr)
{
x.ptr = nullptr;
}
// move assignment
Example6& operator= (Example6&& x)
{
delete ptr;
ptr = x.ptr;
x.ptr=nullptr;
return *this;
}
// access content:
const string& content() const {return *ptr;}
// addition:
Example6 operator+(const Example6& rhs)
{
return Example6(content()+rhs.content());
}
};
int main () {
Example6 foo("Exam"); // 构造函数
// Example6 bar = Example6("ple"); // 拷贝构造函数
Example6 bar(move(foo)); // 移动构造函数
// 调用move之后,foo变为一个右值引用变量,
// 此时,foo所指向的字符串已经被"掏空",
// 所以此时不能再调用foo
bar = bar+ bar; // 移动赋值,在这儿"="号右边的加法操作,
// 产生一个临时值,即一个右值
// 所以此时调用移动赋值语句
cout << "foo's content: " << foo.content() << '\n';
return 0;
}
}
// 执行结果
foo's content: Example
2.15 C++类定定义定义数据成员
遵循以下三个规则:
- 不能用默认构造函数初始化,必须提供构造函数来初始化引用成员变量。否则会造成引用未初始化错误。
- 构造函数的形参也必须是引用类型。
- 不能在构造函数里初始化,必须在初始化列表中进行初始化。
2.16 什么是常函数,有什么作用
类的成员函数后面加 const,表明这个函数不会对这个类对象的数据成员(准确地说是非静态数据成员)作任何改变。
在设计类的时候,一个原则就是对于不改变数据成员的成员函数都要在后面加const,而对于改变数据成员的成员函数不能加 const。所以 const 关键字对成员函数的行为作了更明确的限定:有 const 修饰的成员函数(指 const 放在函数参数表的后面,而不是在函数前面或者参数表内),只能读取数据成员,不能改变数据成员;
#include<iostream>
using namespace std;
class CStu
{
public:
int a;
CStu()
{
a = 12;
}
void Show() const
{
//a = 13; //常函数不能修改数据成员
cout <<a << "I am show()" << endl;
}
};
int main()
{
CStu st;
st.Show();
system("pause");
return 0;
}
2.17 什么是虚继承,解决什么问题,如何实现?
虚继承是解决C++多重继承问题的一种手段,从不同途径继承来的同一基类,会在子类中存在多份拷贝。这将存在两个问题:
- 其一,浪费存储空间;
- 第二,存在二义性问题,通常可以将派生类对象的地址赋值给基类对象,实现的具体方式是,将基类指针指向继承类(继承类有基类的拷贝)中的基类对象的地址,但是多重继承可能存在一个基类的多份拷贝,这就出现了二义性。虚继承可以解决多种继承前面提到的两个问题。
虚基表:存放相对偏移量,用来找虚基类
2.18 什么是虚函数和纯虚函数,以及实现原理
- C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”。
- 非虚函数总是在编译时根据调用该函数的对象,引用或指针的类型而确定。如果调用虚函数,则直到运行时才能确定调用哪个函数,运行的虚函数是引用所绑定或指针所指向的对象所属类型定义的版本。
- 虚函数必须是基类的非静态成员函数。虚函数的作用是实现动态联编,也就是在程序的运行阶段动态地选择合适的成员函数,在定义了虚函数后,可以在基类的派生类中对虚函数重新定义,在派生类中重新定义的函数应与虚函数具有相同的形参个数和形参类型。以实现统一的接口,不同定义过程。如果在派生类中没有对虚函数重新定义,则它继承其基类的虚函数。
class Person{
public:
//虚函数
virtual void GetName(){
cout<<"PersonName:xiaosi"<<endl;
};
};
class Student:public Person{
public:
void GetName(){
cout<<"StudentName:xiaosi"<<endl;
};
};
int main(){
//指针
Person *person = new Student();
//基类调用子类的函数
person->GetName();//StudentName:xiaosi
将函数定义为纯虚函数,在基类中没有定义,但要求任何派生类都要定义自己的实现方法。则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。
定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。纯虚函数的意义,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的缺省实现
//抽象类
class Person{
public:
//纯虚函数
virtual void GetName()=0;
};
class Student:public Person{
public:
Student(){
};
void GetName(){
cout<<"StudentName:xiaosi"<<endl;
};
};
2.18.1 纯虚函数和虚函数的异同点
- 虚函数和纯虚函数可以定义在同一个类中,含有纯虚函数的类被称为抽象类,而只含有虚函数的类不能被称为抽象类。
- 虚函数可以被直接使用,也可以被子类重载以后,以多态的形式调用,而纯虚函数必须在子类中实现该函数才可以使用,因为纯虚函数在基类有声明而没有定义。
- 虚函数和纯虚函数都可以在子类中被重载,以多态的形式被调用。
- 虚函数和纯虚函数通常存在于抽象基类之中,被继承的子类重载,目的是提供一个统一的接口。
- 虚函数的定义形式:virtual{};纯虚函数的定义形式:virtual { } = 0;在虚函数和纯虚函数的定义中不能有static标识符,原因很简单,被static修饰的函数在编译时要求前期绑定,然而虚函数却是动态绑定,而且被两者修饰的函数生命周期也不一样。
class A
{
public:
virtual void foo()
{
cout<<"A::foo() is called"<<endl;
}
};
class B:public A
{
public:
void foo()
{
cout<<"B::foo() is called"<<endl;
}
};
int main(void)
{
A *a = new B();
a->foo(); // 在这里,a虽然是指向A的指针,但是被调用的函数(foo)却是B的!
return 0;
}
虚函数只能借助于指针或者引用来达到多态的效果。
2.19 什么是菱形继承,如何解决
使用虚继承来解决;
/*
*Animal类对应于图表的类A*
*/
class Animal { /* ... */ }; // 基类
{
int weight;
public:
int getWeight() { return weight;};
};
class Tiger : public Animal { /* ... */ };
class Lion : public Animal { /* ... */ }
class Liger : public Tiger, public Lion { /* ... */ }
int main( )
{
Liger lg ;
/*编译错误,下面的代码不会被任何C++编译器通过 */
int weight = lg.getWeight();
}
iger和Lion类都继承自Animal基类。所以问题是:因为Liger多重继承了Tiger和Lion类,因此Liger类会有两份Animal类的成员(数据和方法),Liger对象"lg"会包含Animal基类的两个子对象。
【解决办法】如果Lion类和Tiger类在分别继承Animal类时都用virtual来标注,对于每一个Liger对象,C++会保证只有一个Animal类的子对象会被创建。
class Tiger : virtual public Animal { /* ... */ };
class Lion : virtual public Animal { /* ... */ };
int main( )
{
Liger lg ;
/*既然我们已经在Tiger和Lion类的定义中声明了"virtual"关键字,于是下面的代码编译OK */
int weight = lg.getWeight();
}
拷贝构造函数的参数是什么传递方式:拷贝贝构造函数的参数必须使用引用传递
2.20 如何理解抽象类
- 抽象类的定义如下:
纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加“=0”,有虚函数的类就叫做抽象类。 - 抽象类有如下几个特点:
1)抽象类只能用作其他类的基类,不能建立抽象类对象。
2)抽象类不能用作参数类型、函数返回类型或显式转换的类型。
3)可以定义指向抽象类的指针和引用,此指针可以指向它的派生类,进而实现多态性。
动态多态是通过虚函数重写来实现,静态多态通过函数重载实现
2.21 简述什么是虚析构函数,什么作用?
- 虚析构函数,是将基类的析构函数声明为virtual,举例如下:
class TimeKeeper
{
public:
TimeKeeper() {}
virtual ~TimeKeeper() {}
};
- 虚构函数的主要作用:防止内存泄漏
声明为virtual之后,通过基类指针删除派生类对象就会释放整个对象(基类+派生类)。
2.22 简述一下拷贝赋值和移动赋值
- 拷贝赋值是通过拷贝构造函数来赋值,在创建对象时,使用同一类中之前创建的对象来初始化新创建的对象。
- 移动赋值是通过移动构造函数来赋值,二者的主要区别在于
1)拷贝构造函数的形参是一个左值引用,而移动构造函数的形参是一个右值引用;
2)拷贝构造函数完成的是整个对象或变量的拷贝,而移动构造函数是生成一个指针指向源对象或变量的地址,接管源对象的内存,相对于大量数据的拷贝节省时间和内存空间。
2.23 C++ 中哪些函数不能被声明为虚函数?
常见的不不能声明为虚函数的有:普通函数(非成员函数),静态成员函数,内联成员函数,构造函数,友元函数。
2.23 虚函数表里存放的内容是什么时候写进去的?
- 虚函数表是一个存储虚函数地址的数组,以NULL结尾。虚表(vftable)在编译阶段生成,对象内存空间开辟以后,写入对象中的 vfptr,然后调用构造函数。即:虚表在构造函数之前写入
- 除了在构造函数之前写入之外,我们还需要考虑到虚表的二次写入机制,通过此机制让每个对象的虚表指针都能准确的指向到自己类的虚表,为实现动多态提供支持。