c/c++面试常见问题(二)
C/C++面试常见问题归纳(二)
1、智能指针
为了解决 动态申请堆内存 而没有回收导致的内存泄漏问题。
智能指针模板:auto_ptr、unique_ptr、share_ptr,weak_pre(本文不作讨论),定义了类似于指针的对象。在智能指针过期时让他的析构函数删除指向的内存。智能指针模板位于命名空间std中。
用法:
auto_ptr<string>ps (new string);
unique_ptr<string> ps (new string(str));
shared_ptr<string> ps (new string(str));
# new string 是new返回的指针,指向新分配的内存块。它是构造函数auto_ptr<string>的实参。
所有的智能指针都有一个explicit构造函数(详见下文),该构造函数将指针作为参数。因此不需要自动将指针转换为智能指针对象。
shared_ptr<double> pd;
double *p_reg = new double;
pd = p_reg; //not allowed(implicit conversion) 不允许隐式转换
pd = share_ptr<double>(p_reg) //allowed(explicit conversion) 允许显示转换
share_ptr<double> pshared = p_reg; //not allowed(implicit conversion)
share_ptr<double> pshared(p_reg); //allowed(explicit conversion)
在详细介绍三种智能指针之前,先说说对以上三种智能指针都应该避免一点:
string str("Hello world!");
shared_ptr<string> pvac(&str); // not allowed!!!
# pvac 过期时程序将delete运算符用于非堆内存,这是错误的。这里涉及类对象的初始化方式,详见下文。
智能指针的引入
先来看下面的赋值语句:
auto_ptr<string> ps("Hello world!");
auto_ptr<string> vo;
vo = ps;
如果ps和vo是常规指针,将两个指针指向同一个string对象,这是不能接受的,因为程序将试图两次删除同一个对象——一次是ps过期,一次是vo过期。
要避免这种情况,方法有多种。
a. 定义赋值运算符,使之执行深复制。这样两个指针将指向不同的对象,其中一个对象是另一个对象的副本。
b. 建立所有权概念。对于特定的对象,只有一个指针可以拥有它,这样只有拥有对象的智能指针的析构函数会删除该对象。然后让赋值操作转让所有权。这就是用于auto_ptr和unique_ptr的策略。
c. 创建智能更高的指针,跟踪引用特定对象的智能指针数。这称为引用计数,赋值时,计数+1, 指针过期时计数-1.仅当最后一个指针过期时才调用delete。这是share_ptr的策略。
auto_ptr
当别的auto_ptr B指向当前auto_ptr A指向的对象时,发生所有权剥夺,原auto_ptr A失效,再次使用auto_ptr A将导致异常(潜在的程序崩溃)。不能与new[]一起使用。
unique_ptr
a. 不允许其他unique_ptr指针指向已经存在的unique_ptr指向的对象,编译阶段会报错。比auto_ptr更安全。可以与new[]一起使用。
b. 程序试图将一个unique_ptr赋给另一个时,如果源unique_ptr是个临时右值,编译器允许这样做;如果源unique_ptr将长期存在,则不允许赋值。
shared_ptr
shared_ptr:使用引用计数,不剥夺原来智能指针的所有权,引用计数增加到2,降低到0时释放分配的内存。
有关智能指针的注意事项
a. 使用new分配内存是才能使用智能指针。
b. new[]分配内存时只能使用unique_ptr。
如何选择智能指针?
a. 如果程序要使用多个指向同一个对象的指针,应该选择shared_ptr。例如很过STL算法都支持赋值和复制操作,这些操作可以用于shared_ptr。
b. 不需要多个指向同一个对象的指针,使用unique_ptr。
c. auto_ptr在C++11中被摒弃。
至此,关于智能指针的介绍就完了。
2、explicit构造函数
前面在介绍智能指针时提到:所有的智能指针都有一个explicit构造函数,该构造函数将指针作为参数。那么什么是explicit构造函数?它又有什么作用呢?
用explicit修饰构造函数的作用是禁止隐式转换或复制初始化。
那什么是隐式转换和复制初始化呢?这要从类的构造函数和初始化过程说起。
构造函数和初始化过程
在c++中,变量的初始化方式有两种:直接初始化(用()运算符,如 int i(1); )和复制初始化(用=运算符,如 int i = 1;)。在代码编译和运行过程中,这两种初始化是有区别的。下面是一个简单的例子:
// 实例 1
#include<iostream>
using namespace std;
class A
{
public:
A(){ cout << "f1" << endl;} // 无参数构造函数,记为f1
A(int temp){ cout << "f2" << endl; } // 单参数构造函数,记为f2
A(const A& temp){ cout << "f3" << endl;} // 复制构造函数,记为f3
private:
int a;
};
int main()
{
A a1; // 调用f1
A a2(1); // 调用f2
A a3(a2); // 调用f3
cout << "------" << endl;
A a4 = 1; // 隐式转换
cout << "------" << endl;
A a5 = a2; // 拷贝初始化
return 0;
}
运行结果如下:
f1
f2
f3
------
f2
------
f3
A a4 = 1和A a5 = a2这两句代码是复制初始化。但是其中的A a4 = 1这句代码令人困惑,为什么一个数字可以初始化一个类对象呢?其实这个地方发生了隐式转换。
隐式转换
通过以上代码的运行结果可知:
A a4 = 1; => A a4(1);
A a5 = a2; => A a5(a2);
虽然上面的代码是可以正常运行的,但是像A a4 = 1这样的代码的可读性非常的差,因此,为了避免出现这种情况,我们可以使用explicit关键字来修饰构造函数,从而禁止隐式转换和复制初始化。
explicit关键字
将上面代码做如下修改:
// 实例2
#include<iostream>
using namespace std;
class A
{
public:
A(){ cout << "f1" << endl;} // 无参数构造函数,记为f1
explicit A(int temp){ cout << "f2" << endl; } // 单参数构造函数,记为f2
explicit A(const A& temp){ cout << "f3" << endl;} // 复制构造函数,记为f3
private:
int a;
};
int main()
{
A a1; // 调用f1
A a2(1); // 调用f2
A a3(a2); // 调用f3
cout << "------" << endl;
A a4 = 1; // 隐式转换
cout << "------" << endl;
A a5 = a2; // 拷贝初始化
return 0;
}
explicit关键字禁止了隐式转换和复制初始化,编译不能通过。
通过使用explicit关键字,可以强制性的要求类的使用者按照规范的方式编写代码,提高代码的可读性,并降低发生错误的风险。
注意:
a. 复制构造函数又叫拷贝构造函数,复制初始化又叫拷贝初始化;
b. 拷贝构造函数和拷贝初始化不要混淆。拷贝初始化过程要调用拷贝构造函数,但拷贝构造函数也可以用在直接初始化过程。
c. explicit关键字只对单形参的构造函数起作用,对于多形参的构造函数,不会出现隐式转换的问题。 《C++ Primer》中有指出“可以用单个实参来调用的构造函数定义了从形参类型到该类型的一个隐式转换。”
d. 隐式转换不仅会出现在初始化过程中,另一个很常见的情况是以类类型为形参的函数的参数传递过程。
3、类对象的初始化过程
在C++中,类的对象建立分为两种,一种是静态建立,如A a;另一种是动态建立,如A* ptr=new A;这两种方式是有区别的。
静态建立一个类对象,是由编译器为对象在栈空间中分配内存,是通过直接移动栈顶指针,挪出适当的空间,然后在这片内存空间上调用构造函数形成一个栈对象。使用这种方法,直接调用类的构造函数。
动态建立类对象,是使用new运算符将对象建立在堆空间中。这个过程分为两步,第一步是执行operator new()函数,在堆空间中搜索合适的内存并进行分配;第二步是调用构造函数构造对象,初始化这片内存空间。这种方法,间接调用类的构造函数。
类的初始化方式有以下两种:
1、默认初始化
如果定义变量时没有指定初值,则变量被默认初始化,此时变量被赋予了“默认值”。
int i;
A a;
2、直接初始化
如果不使用等号,则执行的是直接初始化。
int a(0);
string str1("hello");
string str2(10, 'c');//这种情况拷贝初始化不能完成
A a(pa);
3、拷贝初始化
如果使用一个等号初始化一个变量,实际上执行的是拷贝初始化。编译器会把等号右侧的初始值拷贝到新创建的对象中去。
int a = 0;
int a = {0};
string str1 = "hello";
A pa; A a = pa;
4、值初始化
值初始化仅限于容器类。
vector<int> v1(10);//v1有10个元素,每个的值都是0
vector<int> v2{10};//v2有1个元素,该元素的值是10
vector<int> v3(10, 1);//v3有10个元素,每个的值都是1
vector<int> v4{10, 1};//v4有2个元素,值分别是10和1
拷贝构造函数
以下4种情况会调用拷贝构造函数:
a. 第一种情况: A aa = a;
b. 第二种情况: A aa(a);
c. 第三种情况: extern fun(A aa); // fun(a)函数调用
d. A fun(){...}; A a = fun(); //函数返回值的时候
初始化列表、构造函数与=赋值之间的区别
1、初始化列表
class foo
{
public:
foo(string s, int i):name(s), id(i){} ; // 初始化列表
private:
string name ;int id ;
};
2、使用初始化列表的原因
初始化类的成员有两种方式,一是使用初始化列表,二是在构造函数体内进行赋值操作。
主要是性能问题:
对于内置类型,如int, float等,使用初始化类表和在构造函数体内初始化差别不是很大,但是对于类类型来说,最好使用初始化列表,为什么呢?
由下面的测试可知,使用初始化列表少了一次调用默认构造函数的过程,这对于数据密集型的类来说,是非常高效的。
3、必须使用初始化列表的情况
a. 常量成员。常量只能初始化不能赋值,必须放在初始化列表里面。
b. 引用类型。引用必须在定义的时候初始化,并且不能重新赋值,所以也要写在初始化列表里面。
c. 没有默认构造函数的类类型。因为使用初始化列表可以不必调用默认构造函数来初始化,而是直接调用拷贝构造函数初始化
class Test1
{
public:
Test1(int a):i(a){}
int i;
};
class Test2
{
public:
Test1 test1 ;
Test2(Test1 &t1)
{
test1 = t1 ;
}
};
以上代码无法通过编译,因为Test2的构造函数中test1 = t1这一行实际上分成两步执行:
1、 调用Test1的默认构造函数来初始化test1
由于Test1没有默认的构造函数,所以1 无法执行,故而编译错误。正确的代码如下,使用初始化列表代替赋值操作.
2、初始化Test2
class Test2
{
public:
Test1 test1 ;
Test2(int x):test1(x){}
}
成员变量的初始化顺序
成员是按照他们在类中出现的顺序进行初始化的,而不是按照他们在初始化列表出现的顺序初始化的。如下:
class foo
{
public:
int i ;int j ;
foo(int x):i(x), j(i){}; // ok, 先初始化i,后初始化j
};
再看下面代码:
class foo
{
public:
int i ;int j ;
foo(int x):j(x), i(j){} // i值未定义
};
这里i的值是未定义的因为虽然j在初始化列表里面出现在i前面,但是i先于j定义,所以先初始化i,而i由j初始化,此时j尚未初始化,所以导致i的值未定义。一个好的习惯是,按照成员定义的顺序进行初始化。