C++面试
内存
- 栈区: 由编译器自动分配释放,像局部变量,函数参数,都是在栈区。会随着作用于退出而释放空间。
- 堆区:程序员分配并释放的区域,像malloc(c),new(c++)
- 全局数据区(静态区):全局变量和静态量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。程序结束释放。
- 代码区
多态
静态多态
静态多态:也称为编译期间的多态
- 函数重载:包括普通函数的重载和成员函数的重载
- 函数模板的使用
函数签名
为什么C语言中没有重载呢?
C编译器的函数签名不会记录参数类型和顺序,
C++中的函数签名(function signature):包含了一个函数的信息——包括函数名、参数类型、参数个数、顺序以及它所在的类和命名空间,普通函数签名并不包含函数返回值部分。所以对于不同函数签名的函数,即使函数名相同,编译器和链接器都认为它们是不同的函数。
调用协议
stdcall是Pascal方式清理C方式压栈,通常用于Win32 Api中,参数入栈规则是从右到左,自己在退出时清空堆栈。
cdecl是C和C++程序的缺省调用方式,参数入栈规则也是从右至左,由调用者把参数弹出栈。对于传送参数的内存栈是由调用者来维护的。每一个调用它的函数都包含清空堆栈的代码,所以产生的可执行文件大小会比调用stdcall函数的大。
fastcall调用的主要特点就是快,通过寄存器来传送参数,从左开始不大于4字节的参数放入CPU的ECX和EDX寄存器,其余参数从右向左入栈
C语言:
__stdcall | 编译后,函数名被修饰为“_functionname@number” |
---|---|
__cdecl | 编译后,函数名被修饰为“_functionname” |
__fastcall | 编译后,函数名给修饰为“@functionname@nmuber” |
C++:
__stdcall | 编译后,函数名被修饰为“?functionname@@YG******@Z” |
---|---|
__cdecl | 编译后,函数名被修饰为“?functionname@@YA******@Z” |
__fastcall | 编译后,函数名给修饰为“?functionname@@YI******@Z” |
函数实现和函数定义时如果使用了不同的函数调用协议,则无法实现函数调用。C语言和C++语言间如果不进行特殊处理,也无法实现函数的互相调用。
动态多态
运行时多态
重载(overload):函数名相同,参数列表不同,override只是在类的内部存在
重写(override):也叫覆盖。子类重新定义父类中有相同名称和参数的虚函数(virtual)。
- 被重写的函数不能是static的,且必须是virtual的
- 重写函数必须有相同的类型,名称和参数列表
- 重写函数的访问修饰符可以不同。尽管父类的virtual方法是private的,派生类中重写改写为public,protected也是可以的。这是因为被virtual修饰的成员函数,无论他们是private/protect/public的,都会被统一放置到虚函数表中。
对父类进行派生时,子类会继承到拥有相同偏移地址的虚函数标(相同偏移地址指的是各虚函数先谷底与VPTR指针的偏移),因此就允许子类对这些虚函数进行重写
重定义(redefining),也叫隐藏。子类重新定义父类有相同名称的非虚函数(参数列表可以不同)。
子类若有和父类相同的函数,那么,这个类将会隐藏其父类的方法。除非你在调用的时候,强制转换成父类类型。在子类和父类之间尝试做类似重载的调用时不能成功的。
虚函数表
当一个类在实现时,如果存在一个或以上的虚函数,这个类便会包含一张虚函数表。而当一个子类继承了基类,子类也会有自己的一张虚函数表。
当我们在设计类的时候,如果把某个函数设置成虚函数时,也就表明我们希望子类在继承的时候能够有自己的实现方式;如果我们明确这个类不会被继承,那么就不应该有虚函数的出现。
对于虚函数的调用是通过查虚函数表来进行的,每个虚函数在虚函数表中都存放着自己的一个地址。这张虚函数表是在编译时产生的,否则这个类的结构信息中也不会插入虚指针的地址信息。
每个类使用一个虚函数表,每个类对象用一个虚表指针。
static
静态局部变量:变量属于函数本身,仅受函数的控制。保存在全局数据区,而不是在栈中,每次的值保持到下一次调用,直到下次赋新值。
静态全局变量:定义在函数体外,用于修饰全局变量,表示该变量只在本文件可见,不能被其它文件所用(全局变量可以)
静态函数:静态函数不能被其它文件所用,其它文件中可以定义相同名字的函数,不会发生冲突
静态数据成员:静态数据成员的生存期大于 class 的实例(静态数据成员是每个 class 有一份,普通数据成员是每个 instance 有一份)
- 静态成员之间可以相互访问,包括静态成员函数访问静态数据成员和访问静态成员函数;
- 非静态成员函数可以任意地访问静态成员函数和静态数据成员;
- 静态成员函数不能访问非静态成员函数和非静态数据成员;
- 调用静态成员函数,可以用成员访问操作符(.)和(->)为一个类的对象或指向类对象的指针调用静态成员函数,也可以用类名::函数名调用(因为他本来就是属于类的,用类名调用很正常)
const
修饰普通类型的变量
修饰指针:顶层const(指针本身是个常量)和底层cosnt(指针指向对象是一个常量)
修饰函数参数:
- 函数参数为值传递:值传递(pass-by-value)是传递一份参数的拷贝给函数,因此不论函数体代码如何运行,也只会修改拷贝而无法修改原始对象,这种情况不需要将参数声明为const。
- 函数参数为指针:指针传递(pass-by-pointer)只会进行浅拷贝,拷贝一份指针给函数,而不会拷贝一份原始对象。因此,给指针参数加上顶层const可以防止指针指向被篡改,加上底层const可以防止指向对象被篡改。
- 函数参数为引用:引用传递(pass-by-reference)有一个很重要的作用,由于引用就是对象的一个别名,因此不需要拷贝对象,减小了开销。这同时也导致可以通过修改引用直接修改原始对象(毕竟引用和原始对象其实是同一个东西),因此,大多数时候,推荐函数参数设置为pass-by-reference-to-const。给引用加上底层const,既可以减小拷贝开销,又可以防止修改底层所引用的对象。
修饰函数返回值:令函数返回一个常量,可以有效防止因用户错误造成的意外,比如 “=” 。
const成员函数:const成员函数不可修改类对象的内容(指针类型的数据成员只能保证不修改该指针指向)。原理是cost成员函数的this指针是底层const指针,不能用于改变其所指对象的值。
当成员函数的 const 和 non-const 版本同时存在时:const object 只能调用 const 版本,non-const object 只能调用 non-const 版本。
const object(data members不得变动) | non-const objectdata members可变动) | |
---|---|---|
const member function(保证不更改data members) | 可 | 可 |
non-const member function(不保证) | 不可 | 可 |
stl容器
序列容器
vector
动态数组,在堆中分配内存,元素连续存放,有保留内存,如果减少大小后,内存也不会释放;如果新值大于当前大小时才会重新分配内存。
push_back()
,pop_back()
,insert()
,访问时间是O1,erase()
时间是On,查找On
扩容方式:倍数开辟二倍的内存,旧的数据开辟到新内存,释放旧的内存,指向新内存。
- 对头部和中间进行添加删除元素操作需要移动内存,如果元素是结构或类,那么移动的同时还会进行构造和析构操作
- 对任何元素的访问时间都是O1,常用来保存需要经常进行随机访问的内容,并且不需要经常对中间元素进行添加删除操作
- 属性与string差不多,同样可以使用capacity看当前保留的内存,使用swap来减少它使用的内存,如push_back 1000个元素,capacity返回值为16384
- 对最后元素操作最快(在后面添加删除元素最快),此时一般不需要移动内存,只有保留内存不够时才需要
list
双向循环链表
元素存放在堆中,每个元素都是放在一块内存中,他的内存空间可以是不连续的,通过指针来进行数据的访问,这个特点使得它的随机存取变得非常没有效率,因此它没有提供[]
操作符的重载。但是由于链表的特点,它可以很有效率的支持任意地方的删除和插入操作。
增删erase()
都是O1,访问On,查找头O1,其余查找On。
deque
双端队列 deque 是对 vector 和list 优缺点的结合的一种容器。
由于分段数组的大小是固定的,并且他们的首地址被连续存放在索引数组中,因此可以对其进行随机访问,但是效率比 vector 低很多。向两端加入新元素时,如果这一端的分段数组未满,则可以直接加入,如果这一端的分段数组已满,只需要创建新的分段数组,并把该分段数组的地址加入到索引数组中即可,这样就不需要对已有的元素进行移动,因此在双端队列的两端加入新的元素都具有较高的效率
insert()
和erase()
时间是On,查找On,其余O1。
vector:快速的随机储存,快速的在最后插入删除元素
需要高效随机储存,需要高效的在末尾插入删除元素,不需要高效的在其他地方插入和删除元素
list:快速在任意位置插入删除元素,快速访问头尾元素
需要大量插入删除操作,不关心随机储存的场景
deque:比较快速的随机存储(比vector的慢),快速的在头尾插入删除元素
需要随机储存,需要高效的在头尾插入删除元素的场景
deque可以看作是vector和list的折中方案
关联容器
底层都是红黑树
set
元素有序,无重复元素,插入删除操作的效率比序列容器高,因为对于关联容器来说,不需要做内存的拷贝和内存的移动。
增删改查Ologn
multiset
multiset和set相同,只不过它允许重复元素,也就是说multiset可包括多个数值相同的元素。
map
map由红黑树实现,其元素都是键值对,每个元素的键是排序的准则,每个键只能出现一次,不允许重复
增删改查Ologn
multimap
multimap和map相同,但允许重复元素,也就是说multimap可包含多个键值(key)相同的元素。
智能指针
将基本类型指针封装为类对象指针,并在析构函数里编写delete语句删除指针指向的内存空间。
auto_ptr<string> ps (new string ("I reigned lonely as a cloud.”);
auto_ptr<string> vocation; vocaticn = ps;
如果 ps 和 vocation 是常规指针,则两个指针将指向同一个 string 对象。这是不能接受的,因为程序将试图删除同一个对象两次,一次是 ps 过期时,另一次是 vocation 过期时。要避免这种问题,方法有多种:
- 定义陚值运算符,使之执行深复制。这样两个指针将指向不同的对象,其中的一个对象是另一个对象的副本,缺点是浪费空间,所以智能指针都未采用此方案。
- 建立所有权(ownership)概念。对于特定的对象,只能有一个智能指针可拥有,这样只有拥有对象的智能指针的析构函数会删除该对象。然后让赋值操作转让所有权。这就是用于 auto_ptr 和 unique_ptr 的策略,但 unique_ptr 的策略更严格。
- 创建智能更高的指针,跟踪引用特定对象的智能指针数。这称为引用计数。例如,赋值时,计数将加 1,而指针过期时,计数将减 1,当减为 0 时才调用 delete。这是 shared_ptr 采用的策略。
所有的智能指针类都有一个explicit构造函数,以指针作为参数。因此不能自动将指针转换为智能指针对象,必须显式调用:
shared_ptr<double> pd;
double *p_reg = new double;
pd = p_reg; // not allowed (implicit conversion)
pd = shared_ptr<double>(p_reg); // allowed (explicit conversion)
shared_ptr<double> pshared = p_reg; // not allowed (implicit conversion)
shared_ptr<double> pshared(p_reg); // allowed (explicit conversion)
对全部三种智能指针都应避免的一点:
string vacation("I wandered lonely as a cloud.");
shared_ptr<string> pvac(&vacation); // No
pvac过期时,程序将把delete运算符用于非堆内存,这是错误的。
auto_ptr
在 auto_ptr 对象销毁时,他所管理的对象也会自动被 delete 掉。
auto_ptr 采用 copy 语义来转移指针资源,转移指针资源的所有权的同时将原指针置为 NULL,拷贝后原对象变得无效,再次访问原对象时会导致程序崩溃。
unique_ptr
由 C++11 引入,旨在替代不安全的 auto_ptr。
unique_ptr 则禁止了拷贝语义,但提供了移动语义,即可以使用std::move() 进行控制权限的转移,如下代码所示:
它持有对对象的独有权——两个 unique_ptr 不能指向一个对象,即 unique_ptr 不共享它所管理的对象。
内存资源所有权可以转移到另一个 unique_ptr,并且原始 unique_ptr 不再拥有此资源。实际使用中,建议将对象限制为由一个所有者所有,因为多个所有权会使程序逻辑变得复杂。因此,当需要智能指针用于存 C++ 对象时,可使用 unique_ptr,构造 unique_ptr 时,可使用 make_unique 函数。
//智能指针的创建
unique_ptr<int> u_i; //创建空智能指针
u_i.reset(new int(3)); //绑定动态对象
unique_ptr<int> u_i2(new int(4));//创建时指定动态对象
unique_ptr<T,D> u(d); //创建空 unique_ptr,执行类型为 T 的对象,用类型为 D 的对象 d 来替代默认的删除器 delete
//所有权的变化
int *p_i = u_i2.release(); //释放所有权
unique_ptr<string> u_s(new string("abc"));
unique_ptr<string> u_s2 = std::move(u_s); //所有权转移(通过移动语义),u_s所有权转移后,变成“空指针”
u_s2.reset(u_s.release()); //所有权转移
u_s2=nullptr;//显式销毁所指对象,同时智能指针变为空指针。与u_s2.reset()等价
当程序试图将一个 unique_ptr 赋值给另一个时,如果源 unique_ptr 是个临时右值,编译器允许这么做;如果源 unique_ptr 将存在一段时间,编译器将禁止这么做,比如:
unique_ptr<string> pu1(new string ("hello world"));
unique_ptr<string> pu2;
pu2 = pu1; // #1 not allowed
unique_ptr<string> pu3;
pu3 = unique_ptr<string>(new string ("You")); // #2 allowed
其中#1留下悬挂的unique_ptr(pu1),这可能导致危害。而#2不会留下悬挂的unique_ptr,因为它调用 unique_ptr 的构造函数,该构造函数创建的临时对象在其所有权让给 pu3 后就会被销毁。这种随情况而已的行为表明,unique_ptr 优于允许两种赋值的auto_ptr 。
使用move后,原来的指针仍转让所有权变成空指针,可以对其重新赋值。
shared_ptr
shared_ptr 是为了解决 auto_ptr 在对象所有权上的局限性(auto_ptr 是独占的),在使用引用计数的机制上提供了可以共享所有权的智能指针,当然这需要额外的开销:
- shared_ptr 对象除了包括一个所拥有对象的指针外,还必须包括一个引用计数代理对象的指针;
- 时间上的开销主要在初始化和拷贝操作上, * 和 -> 操作符重载的开销跟 auto_ptr 是一样;
- 开销并不是我们不使用 shared_ptr 的理由,,永远不要进行不成熟的优化,直到性能分析器告诉你这一点。
weak_ptr
weak_ptr 只对 shared_ptr 进行引用,而不改变其引用计数,当被观察的 shared_ptr 失效后,相应的 weak_ptr 也相应失效。
weak_ptr<T> w; //创建空 weak_ptr,可以指向类型为 T 的对象。
weak_ptr<T> w(sp); //与 shared_ptr 指向相同的对象,shared_ptr 引用计数不变。T必须能转换为 sp 指向的类型。
w=p; //p 可以是 shared_ptr 或 weak_ptr,赋值后 w 与 p 共享对象。
w.reset(); //将 w 置空。
w.use_count(); //返回与 w 共享对象的 shared_ptr 的数量。
w.expired(); //若 w.use_count() 为 0,返回 true,否则返回 false。
w.lock(); //如果 expired() 为 true,返回一个空 shared_ptr,否则返回非空 shared_ptr。
示例:
shared_ptr<int> sp(new int(10));
assert(sp.use_count() == 1);
weak_ptr<int> wp(sp); //从shared_ptr创建weak_ptr
assert(wp.use_count() == 1);
if (!wp.expired()) //判断weak_ptr观察的对象是否失效
{
shared_ptr<int> sp2 = wp.lock();//获得一个shared_ptr
*sp2 = 100;
assert(wp.use_count() == 2);
}
assert(wp.use_count() == 1);
cout << "int:" << *sp << endl; //输出 int: 100
内存泄漏
内存泄漏是指应用程序分配某段内存后,失去了对该段内存的控制,因而造成了内存的浪费。
RAII
资源获取即初始化,是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。智能指针即RAII最具代表的实现
RTTI
即通过运行时类型识别,程序能够使用基类的指针或引用来检查着这些指针或引用所指的对象的实际派生类型。
排序
归并
- 如果给的数组只有一个元素的话,直接返回(也就是递归到最底层的一个情况)
- 把整个数组分为尽可能相等的两个部分(分)
- 对于两个被分开的两个部分进行整个归并排序(治)
- 把两个被分开且排好序的数组拼接在一起
void merge(int arr[], int l, int m, int r)
{
int i, j, k;
int n1 = m - l + 1;
int n2 = r - m;
vector<int> L(n1);
vector<int> R(n2);
for (i = 0; i < n1; i++)
L[i] = arr[l + i];
for (j = 0; j < n2; j++)
R[j] = arr[m + 1 + j];
i = 0;
j = 0;
k = l;
while (i < n1 && j < n2){
if (L[i] <= R[j]){
arr[k] = L[i];
i++;
}
else{
arr[k] = R[j];
j++;
}
k++;
}
while (i < n1){
arr[k] = L[i];
i++;
k++;
}
while (j < n2){
arr[k] = R[j];
j++;
k++;
}
}
void mergeSort(int arr[], int l, int r)
{
if (l < r)
{
int m = l + (r - l) / 2;
mergeSort(arr, l, m);
mergeSort(arr, m + 1, r);
merge(arr, l, m, r);
}
}
堆排
- 最大堆中的最大元素值出现在根结点(堆顶)
- 堆中每个父节点的元素值都大于等于其孩子结点
作者:力扣(LeetCode)
建立堆函数:
void heapify(int arr[], int n, int i)
{
int largest = i; // 将最大元素设置为堆顶元素
int l = 2*i + 1; // left = 2*i + 1
int r = 2*i + 2; // right = 2*i + 2
// 如果 left 比 root 大的话
if (l < n && arr[l] > arr[largest])
largest = l;
// I如果 right 比 root 大的话
if (r < n && arr[r] > arr[largest])
largest = r;
if (largest != i)
{
swap(arr[i], arr[largest]);
// 递归地定义子堆
heapify(arr, n, largest);
}
}
堆排序的方法如下,把最大堆堆顶的最大数取出,将剩余的堆继续调整为最大堆,再次将堆顶的最大数取出,这个过程持续到剩余数只有一个时结束。
堆排序函数:
void heapSort(int arr[], int n)
{
// 建立堆
for (int i = n / 2 - 1; i >= 0; i--)
heapify(arr, n, i);
// 一个个从堆顶取出元素
for (int i=n-1; i>=0; i--)
{
swap(arr[0], arr[i]);
heapify(arr, i, 0);
}
}
快排
void quickSort(int a[],int left,int right)
{
int i=left;
int j=right;
int temp=a[left];
if(left>=right)
return;
while(i!=j)
{
while(i<j&&a[j]>=temp)
j--;
if(j>i)
a[i]=a[j];//a[i]已经赋值给temp,所以直接将a[j]赋值给a[i],赋值完之后a[j],有空位
while(i<j&&a[i]<=temp)
i++;
if(i<j)
a[j]=a[i];
}
a[i]=temp;//把基准插入,此时i与j已经相等R[low..pivotpos-1].keys≤R[pivotpos].key≤R[pivotpos+1..high].keys
quickSort(a,left,i-1);/*递归左边*/
quickSort(a,i+1,right);/*递归右边*/
}
哈希
内联
顶点着色器vec4的第四个参数