C++学习笔记——从面试题出发学习C++

C++学习笔记——从面试题出发学习C++

  • C++学习笔记——从面试题出发学习C++
    • 1. 成员函数的重写、重载和隐藏的区别?
    • 2. 构造函数可以是虚函数吗?内联函数可以是虚函数吗?析构函数为什么一定要是虚函数?
    • 3. 解释左值/右值、左值/右值引用、std::move、移动语义、完美转发等相关的概念?
      • 3.1 左值/右值的概念
      • 3.2 左值引用/右值引用的概念
      • 3.3 std::move的作用
      • 3.3 移动语义的概念
      • 3.4 完美转发的概念
    • 4. decltype、volatile、explicit、override、mutable关键字的作用?
      • 4.1 decltype
      • 4.2 volatile
      • 4.3 explicit
      • 4.4 override
      • 4.5 mutable
    • 5. 构造函数相关的default和delete关键字的作用?
    • 6 . extern C的作用?
    • 7. 解释动态多态和静态多态的区别?
    • 8. 菱形继承有什么问题,如何解决?
    • 9. 段错误有哪些类型?
    • 10. 如何定义一个只能在堆上(栈上)生成对象的类?
    • 11. delete this 合法吗?
    • 12. 不同类型智能指针的区别?
      • 12.1 auto_ptr
      • 12.2 shared_ptr
      • 12.3 weak_ptr
      • 12.4 unique_ptr
    • 13. 不同强制类型转换运算符的区别?
      • 13.1 static_cast
      • 13.2 dynamic_cast
      • 13.3 reinterpret_cast
      • 13.4 const_cast
    • 14. 如何重载操作符?重载操作符的返回值?流运算符为什么不能通过成员函数重载?
    • 16. 如何理解函数指针、类成员函数指针?

C++学习笔记——从面试题出发学习C++

C++博大精深,在学习过程中我也有看过《Effective C++》、《Efficient C++》、《C++ Prime》这样一些C++的经典大作,但是个人感觉是由于语法太多,很难抓住重点,在工作中如果不很经常用到某个语法,即使在书籍上有看过也会很快忘记。而刷面试题是一个很好的查漏补缺的方式,本博客将以面试题为切入点,将面试题中涉及的语法展开学习以彻底搞懂,进而达到在平常的工作中能够灵活运用目的,下面就逐个开始语法的学习:

1. 成员函数的重写、重载和隐藏的区别?

这里我们直接给出三种不同概念的定义:
重载指的是同一作用域内(例如同为某一个类的成员函数),函数名相同,入参不同的情况;
隐藏指的是不同作用域内(例如两个函数分别位于父类和子类中),函数名相同,入参不同则直接构成隐藏,入参相同非虚函数,否则为重写;
重写特指两个函数分别位于父类和子类中,函数名相同,入参相同且为虚函数的情况(注意和隐藏做区别);

我们要知道:
重载是静态多态的表达形式,重写是动态多态的表达形式。

除此之外,下面这种情况注意隐藏重写输出的区别:
如下是隐藏,调用的是Base类中的func函数

#include

using namespace std;

class Base
{
public:
    void fun(int i){ cout << "Base::fun(int) : " << i << endl;}
};

class Derived : public Base
{
public:
    void fun(int i){ cout << "Derived::fun(int) : " << i << endl;}
};
int main()
{
    Base b;
    Base * pb = new Derived();
    pb->fun(3);//Base::fun(int)

    system("pause");
    return 0;
}

如下是重写,调用的是Derived类中的func函数:

#include

using namespace std;

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

class Derived : public Base
{
public:
    virtual void fun(int i){ cout << "Derived::fun(int) : " << i << endl;}
};
int main()
{
    Base b;
    Base * pb = new Derived();
    pb->fun(3);//Derived::fun(int)

    system("pause");
    return 0;
}

2. 构造函数可以是虚函数吗?内联函数可以是虚函数吗?析构函数为什么一定要是虚函数?

首先我们要了解虚函数的基本实现原理,虚函数的基本结构是虚表:
(1)虚表是一个指针数组,每个元素对应一个虚函数的函数指针;普通函数的调用并不需要经过虚表,所以虚表的元素并不包括普通函数的函数指针;
(2)虚表内的虚函数的函数指针的赋值发生在编译器的编译阶段,也就是说在代码的编译阶段,虚表就已经完成构造
(3)虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表(父类和子类属于不同的类)。为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。从下图我们可以理解类对象、虚表和虚函数的区别:
C++学习笔记——从面试题出发学习C++_第1张图片

其中,类B继承类A,类C继承类B。类A有两个虚函数A::vfunc1()和A::vfunc2(),类B重写了B::vfunc1(),类C重写了C::vfunc2()。当我们使用指针调用虚函数,且满足指针向上转型条件时就可以触发动态绑定,如下代码:

int main() 
{
    B bObject;
    A *p = & bObject;
    p->vfunc1(); // 最终调用的时B::vfunc1()这个函数
}

了解了虚函数的基本实现原理后我们来回答下上面相关的问题:
(1)构造函数不可以是虚函数,因为每个对象中的虚函数的实现依赖虚函数表指针_vptr指向的虚函数表来确定,在执行构造函数前对象尚未完成创建,虚函数表指针_vptr还不存在,也就无法通过虚函数表确定构造函数的具体实现。
(2)虚函数可以是内联函数,内联是可以修饰虚函数的,但是当虚函数表现多态性的时候不能内联。 内联是在发生在编译期间,编译器会自主选择内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联。
(3)将可能会被继承的父类的析构函数设置为虚函数,可以保证当我们创建一个子类,然后使用基类指针指向该子类对象,释放基类指针时可以释放掉子类的空间,防止内存泄漏。而对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存

3. 解释左值/右值、左值/右值引用、std::move、移动语义、完美转发等相关的概念?

3.1 左值/右值的概念

左值是可寻址的变量,有持久性(例如变量名、返回引用的函数调用、前置自增自减、解引用等);
右值是不可寻址的常量,或在表达式求值过程中创建的无名临时对象,短暂性的(例如字面值、返回非引用类型的函数调用、后置自增自减、算数表达式等);
左值和右值主要的区别之一是左值可以被修改,而右值不能。

3.2 左值引用/右值引用的概念

左值引用:引用一个对象,其通常用在函数传参或者返回值来避免对象拷贝;
右值引用:就是必须绑定到右值的引用,通过 && 获得右值引用,其主要作用是实现移动语义完美转发(两个概念见下文)

这里补充很重要的一点,常量左值引用也可以引用右值,通常用于入参或者类拷贝构造函数中,更全面的关系如下表所示:
C++学习笔记——从面试题出发学习C++_第2张图片

3.3 std::move的作用

std::move的作用是将一个左值变成右值,换一个角度讲就是可以将一个右值引用指向左值。如下:

int main()
{
  int a = 1;
  int &&b = std::move(a);

  std::cout << "a = " << a << std::endl;
  std::cout << "b = " << b << std::endl;

  return 0;
}

输出结果如下:

a = 1
b = 1

更具体地,std::move的定义如下:

template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&& move(_Tp&& __t) noexcept { 
    return static_cast<typename std::remove_reference<_Tp>::type&&>(__t);
}

其中remove_reference的含义是获得去掉引用的参数类型,从上面定义可以看出,std::move()并不是什么黑魔法,而只是进行了简单的类型转换:
(1)如果传递的是左值,则推导为左值引用,然后由static_cast转换为右值引用
(2)如果传递的是右值,则推导为右值引用,然后由static_cast转换为右值引用
使用std::move之后,就意味着两点:
(1)原对象不再被使用,如果对其使用会造成不可预知的后果(对int等基础类型进行move()操作,不会改变其原值)
(2)所有权转移,资源的所有权被转移给新的对象

3.3 移动语义的概念

移动语义是通过移动构造函数移动赋值构造函数实现的,其主要目的是为了避免资源的重新分配。如下面分别定义拷贝构造函数、赋值构造函数、移动构造函数、移动赋值构造函数四种构造函数:

class BigObj {
public:
    explicit BigObj(size_t length)
        : length_(length), data_(new int[length]) {
    }

    // 析构函数
    ~BigObj() {
     if (data_ != NULL) {
       delete[] data_;
        length_ = 0;
     }
    }

    // 拷贝构造函数
    BigObj(const BigObj& other)
     : length_(other.length_), data(new int[other.length_]) {
   std::copy(other.mData, other.mData + mLength, mData);
    }

    // 赋值构造函数
    BigObj& operator=(const BigObj& other) {
   if (this != &other;) {
      delete[] data_;  
      length_ = other.length_;
        data_ = new int[length_];
        std::copy(other.data_, other.data_ + length_, data_);
   }
   return *this;
    }

    // 移动构造函数
    BigObj(BigObj&& other) : data_(nullptr), length_(0) {
        data_ = other.data_;
        length_ = other.length_;

        other.data_ = nullptr;
        other.length_ = 0;
    }

    // 移动赋值构造函数
    BigObj& operator=(BigObj&& other) {  
      if (this != &other;) {
          delete[] data_;

          data_ = other.data_;
          length_ = other.length_;

          other.data_ = nullptr;
          other.length_ = 0;
       }
       return *this;
    }

private:
    size_t length_;
    int* data_;
};

在移动构造函数和移动赋值构造函数中,没有分配任何新资源,也没有复制其它资源,仅仅是将other的资源进行了移动,占为己用,而other中的内存被移动到新成员后,other中原有的内容则消失,因此需要进行重置。当data_大到百万个元素时,如果使用原来拷贝构造函数的话,就需要将该数百万元素逐个进行复制,性能可想而知。而如果使用该移动构造函数,因为不涉及到新资源的创建,不仅可以节省很多资源,而且性能也有很大的提升

那么我们如何触发移动构造函数和移动赋值构造函数呢?也就是触发移动语义呢?可以有如下几种场景:
场景一

int main() {
  std::vector<BigObj> v;
  v.push_back(BigObj(10));
  v.push_back(BigObj(20));
  
  return 0;
}

上述代码中,两个push_back()调用都将解析为push_back(T&&),push_back(T&&)使用BigObj的移动构造函数将资源从参数移动到vector的内部BigObj对象中。而在C++11之前,上述代码则生成参数的拷贝,然后调用BigObj的拷贝构造函数。

如果参数是左值,则将调用push_back(T&):

int main() {
  std::vector<BigObj> v;
  BigObj obj(10);
  v.push_back(obj); // 此处调用push_back(T&)
  
  return 0;
}

如果希望调用push_back(T&&),则需要使用std::move函数:

int main() {
  std::vector<BigObj> v;
  BigObj obj(10);
  v.push_back(std::move(obj)); // 此处调用push_back(T&&)
  
  return 0;
}

这里需要注意的是,因为调用了std::move,因此下文要避免对obj对象做进一步操作,否则可能会导致内存越界等问题

场景二

BigObj fun() {
  return BigObj();
}
BigObj obj = fun(); // C++11以前
BigObj &&obj = fun(); // C++11

上述代码中,在C++11之前,我们只能通过编译器优化(N)RVO的方式来提升性能,如果不满足编译器的优化条件,则只能通过拷贝等方式进行操作。自C++11引入右值引用后,对于不满足(N)RVO条件,也可以通过移动语义避免拷贝,进而达到优化的目的。

3.4 完美转发的概念

完美转发的定义指的是函数模板可以将自己的参数“完美”地转发给内部调用的其他函数。这里注意,首先一定是模板函数,其次完美指的是不仅能准确转发参数的值,还能保证转发参数的左、右值属性不变。这个为什么重要呢?因为在很多场景中是否完美转发,直接决定了该参数的传递过程使用的是调用拷贝构造函数还是调用移动构造函数,结合上面移动语义的概念我们应该就很好理解这个对于性能的提升是非常重要的。

首先我们定义一个没有完美转发的functoin函数:

template<typename T>
void function(T&& t) {
    vfun(t);
}

这里首先需要解释下T&&的含义,C++ 11规定,在模板函数中使用右值引用语法定义的参数来说,它表示“万能引用”,满足"引用折叠规则"(T& & –> T&,T&& & –> T&,T& && –>T&,T&& && –> T&&),因此左值参数最终转换后仍为左值,右值参数最终转成右值。尽管如此,因为形参t是有名字且可取地址的,因此其传递到内部后仍然是左值,仍然满足不了完美转发的定义。

因此在C++ 11中就引入了std::forward函数,std::forward的定义如下:

template <typename T>
T&& forward(typename std::remove_reference<T>::type& param)
{
    return static_cast<T&&>(param);
}

template <typename T>
T&& forward(typename std::remove_reference<T>::type&& param)
{
    return static_cast<T&&>(param);
}

第一个是左值引用模板函数,第二个是右值引用模板函数,其中remove_reference的含义是获得去掉引用的参数类型(左右值属性不变),根据“万能应用”的定义,因此左值参数最终转换后仍为左值,右值参数最终转成右值。因此如下实现就完成了完美转发:

template <typename T>
void function(T&& t) {
    vfun(forward<T>(t));
}

这里补充下,在C++ 11之前是否可以实现完美转发的效果呢?上面我们介绍了右值是通过常量左值引用传递的,因此通过重载函数模板是可以实现同样功能的,如下:

//接收右值参数
template <typename T>
void function(const T& t) {
    otherdef(t);
}
//接收左值参数
template <typename T>
void function(T& t) {
    otherdef(t);
}

但是相较之下这种实现方式会更麻烦些,因此我们通常还是使用std::forward函数实现完美转发。

4. decltype、volatile、explicit、override、mutable关键字的作用?

4.1 decltype

  1. decltype和auto都是用于推导变量类型,但用法不同,如下:
    auto varname = value;
    decltype(exp) varname = value;
    
    其中auto要求变量必须初始化,deltype不要求
  2. decltype要求推导的对象一定是有类型的,但对象可以使一个普通的变量、表达式或者其他任意复杂的形式
  3. decltype的经典用法是和priority_queue结合,当我们需要对一个自定义类进行堆排序时,如果不使用decltype的话写法如下:
    bool cmp (const T& lhs, const T& rhs)
    { return lhs > rhs; }
    
    priority_queue<T, vector<T>, bool (*) (const T& lhs, const T& rhs)> pq(cmp);
    
    如果换成deltype进行类型推导的话代码会显得更加简洁:
    bool cmp(const T lhs, const T rhs)
    { return rhs < lhs; }
    
    priority_queue<T, vector<T>, decltype(cmp)> pq(cmp)
    

4.2 volatile

  1. volatile的含义是让编译器每次操作变量时一定是内存中取出,而不是使用已经存在寄存器中的值,主要使用在(1)中断服务中修改供其他程序检测的变量;(2)多任务环境下各任务间共享的标志应该加volatile(注意不是多线程);(3)存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能由不同意义。
  2. volatile可以使修饰指针,用法和const类似;
  3. volatile并不能解决多线程中的问题,具体原因可以参考谈谈 C/C++ 中的 volatile

4.3 explicit

  1. explicit修饰单参构造函数,用于说明该构造函数不能进行隐式转换(无参或者多参构造函数无隐式转换),如下

    class People{
    public:
    	int age;
    	explicit People(int a){
    		age=a;
    	}
    };
    
    void foo(void){
    	People p1(10);        //方式一,正确
    	People* p_p2=new People(10);  //方式二,正确
    	People p3=10;         //方式三,这个会报错,如果去掉explicit的话就不会报错。
    
    }
    

    上述代码中如果没有explicit修饰的话方式三会正确运行,此时类成员age会被赋值为10,这就是C++对单参构造函数规定的一种隐式转换,这会导致一些很难被发现的bug,因此对于explicit关键字应该是能用则用。

4.4 override

  1. 如果父类在虚函数声明时使用了override关键字,那么该函数必须重载其子类中的同名函数,否则代码将无法通过编译。

  2. override存在的目的是为了避免在重载子类同名函数过程中意外创建新的虚函数的情况,如下所示:

    class Base {
    public:
        virtual void Show(int x); // 虚函数
    };
    
    class Derived : public Base {
    public:
        virtual void Sh0w(int x); // o 写成了 0,新的虚函数 
        virtual void Show(double x); // 参数列表不一样,新的虚函数 
        virtual void Show(int x) const; // const 属性不一样,新的虚函数 
    };
    

4.5 mutable

  1. mutable 只能用来修饰类的非静态和非常量数据成员,而被 mutable 修饰的数据成员,可以在 const 成员函数中修改。

  2. 在 lambda 表达式的设计中按值捕获的方式不允许程序员在 lambda 函数的函数体中修改捕获的变量。而以 mutable 修饰 lambda 函数,则可以打破这种限制,如下:

    int x{0};
    auto f1 = [=]() mutable {x = 42;};  // okay, 创建了一个函数类型的实例
    auto f2 = [=]()         {x = 42;};  // error, 不允许修改按值捕获的外部变量的值
    
  3. 在一个类中,应尽量或者不用mutable,大量使用mutable表示程序设计存在缺陷

5. 构造函数相关的default和delete关键字的作用?

要了解default和delete关键字的作用首先要知道C++对于默认构造函数的定义,在C++中,如果用户没有定义构造函数,那么编译器会自动生成一系列默认构造函数,包括拷贝构造,赋值构造,移动构造,移动赋值构造。delete的用处禁止默认构造函数的生成,如下:

myClass(const myClass&)=delete;//表示删除默认拷贝构造函数,即不能进行默认拷贝
myClass & operatir=(const myClass&)=delete;//表示删除默认拷贝构造函数,即不能进行默认拷贝

但是一旦用户定义了带参数的构造函数,那么编译器就不会再自动生成默认构造函数,此时其他构造函数都需要用户来定义。此时可以通过default关键字要求编译器来生成一个默认构造函数

myClass()=default;//表示默认存在构造函数

6 . extern C的作用?

extern “C”的作用主要是为了能够正确实现C++代码调用其他C语言代码。加上extern C后,会指示编译器这部分代码按C语言而不是C++的方式进行编译。C++和C语言在编译期一个典型的不同是,C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,例如函数 void fun(int, int) 编译后的可能是 _fun_int_in,而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,因此,如果不加 extern “C”,在链接阶段,链接器会从 moduleA 生成的目标文件 moduleA.obj 中找 _fun_int_int 这样的符号,显然这是不可能找到的

在C++出现之前,很多底层的库是C语言写的,extern “C”的存在就是为了更好的支持原来的C代码和C语言库。extern “C”的使用方法如下:

  1. 使用单一语句

    extern “C” double sqrt(double);
    
  2. 使用复合语句,相当于复合语句中的申明都加了extern “C”

    extern “C”
    {
          double sqrt(double);
          int min(int, int);
    }
    
  3. 包含头文件,相当于头文件中的申明都加了extern “C”

    extern “C”
    {
          #include <cmath>
    }
    
  4. 不可以将extern “C”添加在函数内部,如果函数有多个申明,可以都加extern “C”,也可以只出现在第一次申明中,后面的申明会接受第一个链接指示符的规则。

7. 解释动态多态和静态多态的区别?

动态多态的设计思想是:对于相关的对象类型,确定他们之间的一个共同功能集,然后在父类中,将这些共同的功能声明为多个公共的虚函数结构。各个子类重写这些虚函数,以完成具体的功能。用户代码通过指向父类指针来操作这些对象,对虚函数的调用会自动绑定到实际提供的子类对象上去。因此,我们可以总结出来动态多态的三个条件:

  1. 通过指针来调用函数;
  2. 指针向上转型(即定义一个父类指针指向子类对象);
  3. 调用的是虚函数;

静态多态的设计思想是:对于相关的对象类型,直接实现他们各自的定义,不需要共有基类,甚至可以没有任何关系。只需要各个具体类的实现中要求相同的接口声明。用户把操作这些对象的函数定义为模板,当需要操作什么类型的对象时,直接对模板制定该类型实参即可。

相较之下,动态多态更加灵活,适合更复杂的应用场景。静态多态是编译期实现的多态,效果更高,适用于对性能要求高的场景,如UI渲染等。

8. 菱形继承有什么问题,如何解决?

当Father 类和 Mother 类分别从 GrandParent 继承而来,GrandSon 从 Father 类和 Mother 类多继承而来,类似于这样的继承方式就会形成菱形结构。菱形结构主要问题是:

  1. 数据二义性,当GrandParent中存在类成员变量m是,GrandSon是无法直接调用的m,而必须通过域运算符(::)进行区分,例如

    GrandSon grandSon;
    std::cout << grandSon.Mother::m << std::endl;
    std::cout << grandSon.Father::m << std::endl;
    
  2. 空间浪费,GrandSon中会存在两份积累GrandParent的数据;

解决菱形继承的方法是虚继承,即

class Father : virtual public GrandParent {};
class Mother : virtual public GrandParent {};

使用虚继承的基类属于虚基类,可以看到,虚基类并不是在声明基类时声明的,而是在声明派生类时,指定继承方式声明的。程序运行时,只有最后的派生类执行对基类的构造函数调用,而忽略其他派生类对虚基类的构造函数调用。从而避免对基类数据成员重复初始化。因此,虚基类只会构造一次

9. 段错误有哪些类型?

所谓的段错误 就是指访问的内存超出了系统所给这个程序的内存空间,这里我们粗略的进行一下分类:

  1. 往受到系统保护的内存地址写数据;
  2. 内存越界(数组越界,变量类型不一致等);

我们还可以列举一些需要注意的经常导致段错误的场景:

  1. 定义了指针后记得初始化,在使用的时候记得判断是否为NULL;
  2. 在使用数组的时候是否被初始化,数组下标是否越界,数组元素是否存在等;
  3. 在变量处理的时候变量的格式控制是否合理等;

定位段错误的工具通常时GDB,具体定位方式就不在此展开了。

10. 如何定义一个只能在堆上(栈上)生成对象的类?

解答这个问题我们首先要知道,生成类对象的方式一共就两种:第一种是静态建立,即通过构造函数构建栈或者静态对象;第二种是动态建立,即通过new运算符对象构建堆对象。其中new运算符是先执行operator new()函数在堆空间中搜索合适的内存,第二步是调用构造函数使用该内存进行初始化。因此new运算符其实也是间接调用了类的构造函数

对象只能在堆上建立的类,最佳的实现方式如下:

class  A  
{  
protected :  
    A(){}  
    ~A(){}  
public :  
    static  A* create()  
    {  
        return   new  A();  
    }  
    void  destory()  
    {  
        delete   this ;  
    }  
}; 

对于这种实现方式,有几点需要解释:

  1. 对象只能在堆上建立最直接的想法是将构造函数设置私有,但是上文也提到new运算符其实也是间接调用了构造函数,因此该方法不可取。
  2. 当对象在栈上建立时,编译器会析构函数来确定如何释放内存,当析构函数时私有时,编译器就无法调用析构函数来释放内存,也就不会在栈空间上为对象分配内存,因此将析构函数设置为私有即可得到一个只能在堆上建立的类
  3. 考虑到类的可继承性,因此我们可以将析构函数设置为保护变量,这样这个类还是可继承的,且只能在堆上初始化。

对象只能在栈上建立的类,其实只需要将operator new()设置为私有即可:

class  A  
{  
private :  
    void * operator  new ( size_t  t){}      // 注意函数的第一个参数和返回值都是固定的   
    void  operator  delete ( void * ptr){}  // 重载了new就需要重载delete   
public :  
    A(){}  
    ~A(){}  
}; 

11. delete this 合法吗?

是合法的,但是需要注意如下几点:

  1. this指向的对象必须是new出来的,不能是new[] 、placement new、栈、全局或者其他方式分配的对象,只能是简单的new出来的;
  2. delete this 一旦被调用相当于该对象不复存在,对象下的其他成员函数或者成员对象不得再被调用,也不得以任何形式操作该对象,包括比较、打印、类型转换;
  3. 不能在析构函数中调用delete this,因为delete this会出发析构函数进而造成无限递归。

为了更好理解以上几点,这里我们对delete关键字功能进一步拓展,delete的过程分为如下两步:

p->~Object();
p->operator delete(p);

其中
第一步是调用p指向的Object对象的析构函数,这一步通常由用户自己定义,在析构函数中并不会对当前对象进行内存释放
第二步是调用p对象的内存释放语句,如果用户没有实现该方法,将调用系统内存释放原语operator delete§左释放该对象内存的动作。指示这个对象消亡前最后的动作。通常用户是不需要override这个函数的,如果需要,一定要在最后调用系统的operate delete操作释放该对象所占用的内存。下面这段代码就很好地说明了这个问题:

class x {
public :
        x(){
        }
        ~x() {
                printf("~x()/n");
                //delete p; //这里若进行此操作则会陷入嵌套
        }
        void operator delete(void * ptr) {
                printf("x::delete()/n");
        }
};

void main() {
        x* p=new x;
        delete p; //依次调用p的~x()和operator delete
        delete p; //不会报错,因为"operator delete" override了系统函数,没有进行::operator delete(this)操作。
        delete p; //同理依然不会报错
}

12. 不同类型智能指针的区别?

这是一个老生常谈的问题了,这里我们再总结下:c++中一共有四个只能指针:auto_ptr,shared_ptr,weak_ptr,unique_ptr,其中后三个是c++ 11支持的,auto_ptr已经被c++ 11弃用。

12.1 auto_ptr

  1. c++98的方案**,c++11已经弃用**;
  2. auto_ptr采用所有权模式,因此两个auto_ptr不能同时拥有一个对象。如下做法是错误的,会造成多次析构:
    int*p=new int(0);
    auto_ptr<int>ap1(p);
    auto_ptr<int>ap2(p);
    
  3. auto_ptr的析构函数中删除指针用得是delete,而不是delete[],因此auto_ptr不能管理数组指针
  4. auto_ptr被剥夺所有权时,再次使用会报错:,如下做法是错误的:
    int*p=new int(0);
    auto_ptr<int>ap1(p);
    auto_ptr<int>ap2=ap1;
    cout<<*ap1;//错误,此时ap1只剩一个null指针在手了
    

12.2 shared_ptr

  1. shared_ptr是用来解决所有权共享的问题,多个shared_ptr可以指向相同对象,对象的资源会在最后一个指针销毁时释放;
  2. shared拥有成员函数:use_count(返回引用计数个数 ),unique(返回是否独占所有权),swap(交换所拥有的对象),reset(放弃对象所有权,会引起原有对象计数减少),get(返回对象指针);
  3. 可以通过make_shared构造shared_ptr;

12.3 weak_ptr

  1. weak_ptr是用来解决shared_ptr相互引用时的死锁问题,如果两个shared_ptr相互引用,即那么这两个指针的引用计数永远不会变为零,资源永远不会被释放。

  2. weak_ptr解决上述问题的原理是weak_ptr只可以从另一个shared_ptr或者weak_ptr对象构造,且weak_ptr的构造和析构不会引起引用计数的增加或者减少

  3. weak_ptr不可以直接访问对象的方法,要将weak_ptr转化为shared_ptr。如下是weak_ptr应用的典型场景:

    /*修改,将share_ptr互相引用换成weak_ptr,避免死锁问题*/
    class B;
    class A {
      public:
      weak_ptr<B> pb_;
      ~A() {
        cout<<"A delete\n";
      }
    };
    class B {
      public:
      weak_ptr<A> pa_;
      ~B() {
        cout<<"B delete\n";
      }
    };
    void fun() {
      shared_ptr<B> pb(new B());
      shared_ptr<A> pa(new A());
      pb->pa_ = pa;
      pa->pb_ = pb;
      cout<<pb.use_count()<<endl;
      cout<<pa.use_count()<<endl;
    }
    int main() {
      fun();
      return 0;
    }
    

12.4 unique_ptr

  1. unique_ptr实现了独有所有权的语义。unique_ptr是仅能移动的类型,拷贝是不被允许的。当unique_ptr被移动时,资源的所有权也从源指针转移给目标指针,而源指针将被置空。如下是一些基本操作:

    int main() 
    {
        // 创建一个unique_ptr实例
        unique_ptr<int> pInt(new int(5));
        unique_ptr<int> pInt2(pInt);    // 报错
        unique_ptr<int> pInt3 = pInt;   // 报错
        unique_ptr<int> pInt4 = std::move(pInt);    // 转移所有权
        //cout << *pInt << endl; // 报错,pInt为空
        cout << *pInt4 << endl;
        unique_ptr<int> pInt5(std::move(pInt4));    // 转移所有权
    }
    
  2. 可以通过make_unique构造unique_ptr;

13. 不同强制类型转换运算符的区别?

这也是C++中非常基本的一个知识点,主要有四种强制转换类型运算符:const_cast,reinterpret_cast,static_cast,dynamic_cast

13.1 static_cast

static_cast执行非动态转换,没有运行时类型检查来保证转换的安全性,通常有如下几种用法:

  1. 用于类层次结构中父类和子类之间指针或引用的转换,进行上行转换(把子类指针转换成父类指针)是安全的;进行下行转换(把父类指针转换成子类指针)时,由于没有动态类型检查,所以是不安全的。
  2. 用于基本数据类型之间的转换,如把int转换成char,把int转换成enum。这种转换的安全性也要开发人员来保证。
  3. 把void指针转换成目标类型的指针,这种转换是不安全的。
  4. 把任何类型的表达式转换成void类型

13.2 dynamic_cast

dynamic_cast是唯一一个在运行时进行类型检测的转换,可以使用它来检验多个动态对象,该转换一般用于含有虚函数的基类和派生类之间。dynamic_cast使用的注意事项如下:

  1. dynamic_cast转换符只能用于指针或者引用
  2. dynamic_cast转换符只能用于含有虚函数的类
  3. dynamic_cast转换操作符在执行类型转换时首先将检查能否成功转换,如果能成功转换则转换之,如果转换失败,如果是指针则反回一个0值,如果是转换的是引用,则抛出一个bad_cast异常,所以在使用dynamic_cast转换之间应使用if语句对其转换成功与否进行测试。

13.3 reinterpret_cast

reinterpret_cast可以把一个指针转换成一个整数,也可以把一个整数转换成一个指针(先把一个指针转换成一个整数,再把该整数转换成原类型的指针),使用该操作符的危险性较高,一般不应使用该操作符。

13.4 const_cast

const_cast用来修改类型的const/volatile属性,这种类型的转换主要是用来操作所传对象的 const 属性,可以加上 const 属性,也可以去掉 const 属性。

14. 如何重载操作符?重载操作符的返回值?流运算符为什么不能通过成员函数重载?

如下是最经典的复数运算的重载操作:

#include 
using namespace std;
class Complex {
public:
  Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) { }
    //运算符+重载成员函数
  Complex operator + (const Complex &c2) const;
    //运算符-重载成员函数
  Complex operator - (const Complex &c2) const;
    void display() const;   //输出复数
private:
    double real;    //复数实部
    double imag;    //复数虚部
};
例 复数类加减法运算重载为成员函数
Complex Complex::operator+(const Complex &c2) const{
  //创建一个临时无名对象作为返回值 
  return Complex(real+c2.real, imag+c2.imag); 
}
 
Complex Complex::operator-(const Complex &c2) const{
 //创建一个临时无名对象作为返回值
    return Complex(real-c2.real, imag-c2.imag); 
}

具体说来:

  1. 重载前缀一元运算符:
    如果为非静态成员函数,声明如下:

    return-type operator op ();
    

    如果为非成员函数,声明如下:

    return-type operator op ( class-type );
    
  2. 重载后缀一元运算符(递增、递减):
    当为递增或递减运算符的后缀形式指定重载运算符时,其参数的类型必须是 int;指定任何其他类型都将产生错误。

  3. 重载二元运算符:
    如果为非静态成员函数,声明如下

    return-type operator op( class-type )
    

    如果为非成员函数(全局函数、友元函数),声明如下:

    return-type operator op(class-type, class-type)
    

    关于如果要重载 B 为类成员函数,使之能够实现表达式 class-type1 OP class-type2,其中 class-type1为A 类对象,则 OP 应被重载为 A 类的成员函数,形参类型应该是 class-type2所属的类型。经重载后,表达式 class-type1 OP class-type2相当于 class-type1.operator OP(class-type2)

重载操作符可以以void,对象的值或者引用的形式进行返回,注意这里的对象指向的是调用对象本身(对于全局的二元运算符函数就是左侧实参,否则就是*this),以下几种情况需要返回调用对象的引用:

  1. 等号连续赋值
  2. +=,-=,*=,/=
  3. <<,>>

原因是:

  1. 允许进行连续赋值;
  2. 防止返回对象的时候调用拷贝构造函数和析构函数导致不必要的开销,降低赋值运算符的效率;
  3. 和所有内置类型和标准程序库提供的类型遵循相同的协议;

流运算符为什么不能通过成员函数重载的原因是因为通过类的成员函数重载必须是运算符的第一个是自己,对流运算的重载要求第一个参数是流对象,因此我们通常使用友元来解决问题,例如如果我们实现:

ostream & operator<<(ostream &output)
{
  return output;
}

这样我们只能使用data<

#include 
using namespace std;

class Person {
    public:
        Person(string name, int age) : name(name), age(age) {}

        // 重载输出运算符
        friend ostream &operator<<(ostream &out, const Person &p) {
            out << p.name << " " << p.age;
            return out;
        }

    private:
        string name;
        int age;
};

int main() {
    Person p("John", 25);
    cout << p << endl;
    return 0;
}

16. 如何理解函数指针、类成员函数指针?

函数指针和函数名的区别在于,他们虽然都指向了函数在内存的入口地址,但函数指针本身是个指针变量,对他做&取地址的话会拿到这个变量本身的地址去,而对函数名做&取址,得到的还是函数的入口地址。

  1. 函数指针的定义方式如下:

    int test(int a)
    {
        return a;
    }
    int main(int argc, const char * argv[])
    {
        
        int (*fp)(int a);
        fp = test;
        cout<<fp(2)<<endl;
        return 0;
    }
    

    函数指针所指向的函数一定要报纸函数的返回值类型、函数参数个数和类型一致,我们还可以使用typedef来可以简化函数指针的定义:

    int test(int a)
    {
        return a;
    }
     
    int main(int argc, const char * argv[])
    {
        
        typedef int (*fp)(int a);
        fp f = test;
        cout<<f(2)<<endl;
        return 0;
    }
    
  2. 函数指针同样可以作为参数传递给函数,还可以构建函数指针数组,这些是我们实现回调函数的基础

    int test(int a)
    {
        return a-1;
    }
    int test2(int (*fun)(int),int b)
    {
        
        int c = fun(10)+b;
        return c;
    }
     
    int main(int argc, const char * argv[])
    {
        
        typedef int (*fp)(int a);
        fp f = test;
        cout<<test2(f, 1)<<endl; // 调用 test2 的时候,把test函数的地址作为参数传递给了 test2
        return 0;
    }
    

    构成函数指针数字的方式如下:

    void t1(){cout<<"test1"<<endl;}
    void t2(){cout<<"test2"<<endl;}
    void t3(){cout<<"test3"<<endl;}
     
    int main(int argc, const char * argv[])
    {
        
        typedef void (*fp)(void);
        fp b[] = {t1,t2,t3}; // b[] 为一个指向函数的指针数组
        b[0](); // 利用指向函数的指针数组进行下标操作就可以进行函数的间接调用了
        
        return 0;
    }
    

对于类成员函数指针,和函数指针的区别主要如下:

  1. 对于指向类成员函数的函数指针,引用时必须传入一个类对象的this指针,所以必须由类实体调用,即使用 . (实例对象)或者 ->*(实例对象指针)调用类成员函数指针所指向的函数*。

  2. 对于虚函数, 其地址在编译时期是未知的,所以对于虚成员函数取其地址(子类父类都是如此),所能获得的只是一个索引值,即其在虚函数表的偏移位置。

  3. 对于静态成员函数,和非静态成员函数的变脸的赋值方式是一样的,都是&ClassName::memberVariable形式,但是其声明方式不同:

    class A{
    public:
        
        //p1是一个指向非static成员函数的函数指针
        void (A::*p1)(void);
        
        //p2是一个指向static成员函数的函数指针
        void (*p2)(void);
        
        A(){
            p1 =&A::funa; //函数指针赋值一定要使用 &
            p2 =&A::funb;
        }
        
        void funa(void){
            puts("A");
        }
        
        static void funb(void){
            puts("B");
        }
    };
    

    类成员函数指针就不仅仅是类成员函数的内存起始地址,还需要能解决因为 C++ 的多重继承、虚继承而带来的类实例地址的调整问题,所以类成员函数指针在调用的时候一定要传入类实例对象。因此上面类的调用方法如下:

    int main()
    {    
    	// 非静态和静态类成员函数指针调用方式对比
    	A a;
    	void (A::*pa)(void);
    	pa = &A::funa;		
        (a.*pa)(); //打印 A
        
        A *b = &a;
        (b->*pa)(); //打印 A
    
        void (*pb)(void);
        pb = &A::funb;
        pb(); //打印 B
        
        return 0;
    }
    

    我们也可以使用在类中定义好的类成员变量调用:

    int main()
    {
        A a;
        // p是指向A中非static成员函数的函数指针
        void (A::*p)(void);
        
        (a.*a.p1)(); //打印 A,这个看起来或许有些奇怪
        
        // 使用.*(实例对象)或者->*(实例对象指针)调用类成员函数指针所指向的函数
        p = a.p1;
        (a.*p)(); //打印 A
        
        A *b = &a;
        (b->*p)(); //打印 A
    
    	p = a.p2; //error,尽管a.p2本身是个非static变量,但是a.p2是指向static函数的函数指针
    }
    

你可能感兴趣的:(c++,笔记)