0
点赞
收藏
分享

微信扫一扫

面试-C++

产品喵dandan米娜 2022-04-06 阅读 47

C++运算符优先级表

常见的运算符优先级错误:

  • vector<int> *a, a是一个vector的指针, 取其中的元素时, 由于下标[]的优先级高于*取地址符, 所以需要将*a括起来, 比如(*a)[1];

随机数生成

#include <cstdlib>
#include <ctime>

srand((unsigned int)time(NULL));
rand();//[0,INT_MAX]

exit退出

exit(0)是进程非正常退出
exit(1)是进程正常退出

return编译错误

除非是返回void的函数, 否则函数最后的一条一定要是return语句, 否则会编译错误

传址与引用

引用可以防止复制, 传址主要用于类对象

new与delete

new出来的内存是动态内存, 存在堆上, 使用完后必须有delete来释放, 否则由堆分配来的内存就永远不会被释放

函数的默认参数

如果我们为某个参数提供了默认值, 那么这个参数右侧的所有参数也必须有默认值

默认值只能够指定一次, 可以在函数声明的地方, 也可以在函数定义的地方, 但是不能两个地方都指定

inline与宏

inline必须与函数的定义体放在一起

如果是在类内定义, 那么可以不用加inline, 因为编译器默认其是inline的

但如果只是在类内声明, 并需要定义为inline , 那么需要在类外定义时在前面加上inline

重载

两个重载函数, 参数类型不能一模一样, 哪怕返回值不同也不行, 因为编译器不根据返回类型来区分不同的重载函数

如果函数具备多种实现方式, 那么使用重载. 如果提供相同的服务, 只是数据类型不同, 那么可以用模板

函数指针

普通函数指针

对函数

vector<int> seq_a(int a)
vector<int> seq_b(int b)

那么可以用函数指针p来获取其地址

vector<int> (*p)(int) = seq_a

也可以用函数指针数据pa来获取两个函数的地址

vector<int> (*p[])(int) = { seq_a, seq_b }

使用p时, 就是p(100)这样. 使用pa时, 就是p[0](100)这样

也可以使用typedef简化, 比如

typedef vector<int> (*fp)(int)
fp p = seq_a;

也可以在定义函数指针p时就赋初值0, 表示其不指向任何函数

vector<int> (*p)(int) = 0;

同时要注意, vector<int> (*p)(int) = seq_a在文件中(包括头文件)会被认为是一个定义而不是声明, 如果需要使其被认为是一个声明, 可以在其之前加上extern关键字

类普通成员函数的指针

基本上要注意的有两点:

  1. 函数指针赋值要使用 &
  2. 使用 .* (实例对象)或者 ->*(实例对象指针)调用类成员函数指针所指向的函数
  3. 对定义的类成员函数的指针变量, 以及内成员函数, 在类内类外时都要加上作用域符号::
void (ClassA::*p)(int) = &ClassA::func_a;//声明类成员函数指针变量p, 可以在类内或者类外定义

ClassA  a;     //定义一个类ClassA的实例a
ClassA* b = &a;//定义一个类ClassA的指针b, 使其指向a
a.func_a(100); //实例a调用func_a
b->func_a(100);//类指针b调用func_a
(a.*p)(100);   //通过实例a的变量p调用func_a
(b->*p)(100);  //通过类指针b的变量p调用func_a

类虚函数的指针

类虚函数的指针与普通成员函数指针不同, 后者获得的是内存的真实地址, 由于其地址在编译时期是未知的, 所以前者获得的是虚函数表的偏移位置. 但是二者的获取和使用方式是一致的

类静态成员函数的指针

其区别在于无论类内类外, 都不加作用域符号::, 同时也不需要依赖于对象或者对象的指针来调用

void (*p)(int) = &ClassA::func_a;
p(100);

头文件中的声明与定义

在调用一个函数前, 需要先找到其声明, 而不必取得其定义. 如果多个.cpp都调用同一个函数, 声明好几次明显不方便, 所以一般将声明都放在了头文件中.

声明可以有多个, 但是定义只能有一份. 由于include操作实际上是把.h文件插入到了.cpp文件的前面, 所以如果把定义放进.h文件中, 明显就会出现多个定义了, 这是不允许的.

但是有两个例外

  1. inline函数, 编译器在调用时必须取得其定义用以扩展, 所以其也必须放在头文件中
  2. const的对象, 因为别的文件在用到这个变量时, 会直接将其替换为其值, 这个变量一出文件只是以值的形式存在, 而不是以定义的形式存在. 虽然每个文件都可以自己const一个同名对象, 且值可以不同, 但在大部分时候他们的值也一样的时候, 将其放到头文件中是一个好的办法

include时的""与<>的不同

<>被认为是某些标准的或者项目专属的头文件, 其搜索路径为

  1. -I指定的目录
  2. CPLUS_INCLUDE_PATH环境变量
  3. /usr/include, /usr/local/include, /usr/lib/gcc/x86_64-linux-gnu/11/include/

""的头文件, 此文件会被认为是一个用户提供的头文件, 会先在程序代码文件的同目录下先寻找, 再去<>默认的磁盘目录中寻找

泛型指针iterator

泛型指针是一组对象, 其有一堆内置运算符(++, *, == !=)等

对const对象, 有const_iterator来对应

模板

模板可以定义多个

template <typename T1, typename T2> ...

模板类的继承

template <typename T>
class Parent{
public:
    Parent(T p){
        this->p = p;
    }

    T p;
};

class ChildA : public Parent<int> { //继承时子类不是模板类, 需要指定父类的类型
public:
    ChildA(int a, int b) {
        this->a = a;
        this->b = b;
    }

    int a, b;
};

template <typename T>
class ChildB : public Parent<T> { //继承时子类是模板类, 就用模板来指定父类的类型
public:
    ChildA(T a, T b) {
        this->a = a;
        this->b = b;
    }

    T a, b;
};

至于友元函数, 如果是定义在类内部则没问题, 如果定义在外部, 由于编译器会在确认类的类型时再次生成函数声明和定义, 这就会导致友元类找不到这个友元函数的实现, 此时需要将先将该类做一个声明, 然后再声明该友元函数, 最后再定义该类. 详细可见C++类模板,你看我就够了

另外, 模板这种类似宏的功能对多文件工程有一点限制, 其实现必须和声明放在同一个文件中, 对.h和.cpp分开的工程这样做就不行.

函数对象

特性介绍

函数对象就是所有能当函数使用的对象, 其都是每一个类的实例对象, 其对函数调用运算符()做了重载操作. ()是目数不限的运算符, 因此重载为成员函数时, 有多少个参数都可以.

其优势在于将调用运算符()成为inline的, 从而消除通过函数指针来调用函数的额外代价. 这个inline是由于class主体内定义的成员函数都会自动被视为inline函数.

但是实际试了一下, GCC编译器下函数指针的速度甚至是要比函数对象快一些的, Clang编译器下函数指针则远远不如, 综合结果来看, 还是更推荐使用函数对象. 同时对自定义的函数对象, 显式加上inline和不加的效果几乎一样.

当然除了性能, 同时由于其是类的实例对象, 它可以拥有自己的状态(成员变量或者函数等), 这也是比函数指针更好的地方.

性能测试

性能测试效果如图

例子

一个常见的利用自定义函数对象对vector<int>排序的例子, 这里sort用两种方式都可以

class Less {
public:
    inline bool operator()(const int& a, const int& b){
        return a < b;
    }
};

static bool less_func(const int& a, const int& b){
    return a < b;
}

sort(vec.begin(), vec.end(), Less());//传入的是一个函数对象, 即重载了()的类实例
sort(vec.begin(), vec.end(), less_func);//传入的实际上是一个函数指针

还有一个常用的例子, 比如STL 中有实现累加功能的算法accumulate, 该模板的功能是对[first, last)中的每个迭代器iter执行val = op(val, *iter), 返回最终的 val

template <typename InIt, typename T, typename Pred>
T accumulate(InIt first, InIt last, T val, Pred op);

比如计算一个vector中元素的立方和

class Pow {
public:
    Pow(int pow): pow(pow) { }

    inline int operator()(const int& sum, const int& a){
        int res = a;
        for (int i = 0; i < pow - 1; i++)
            res *= a;
        return sum + res;
    }

private:
    int pow;
};
accumulate(vec.begin(), vec.end(), 0, Pow(3));

函数对象适配器

less<Type>, Plus<Type>等都是二元函数对象, 但是有一些函数只适用一元函数对象, 没有现成的一元函数对象怎么办?

那么可以通过函数对象适配器将二元函数对象变为一元函数对象, 具体方法就是将二元函数对象的某一个参数绑定为一个固定值. 这个用法经常在比如for_each, find_if中用到. 比如

auto iter = find_if(vec.begin(), vec.end(), bind2nd(less<int>(), 5));// 找到第一个满足 x less 5的x
auto iter = find_if(vec.begin(), vec.end(), bind1st(less<int>(), 5));// 找到第一个满足 5 less x的x
auto iter = find_if(vec.begin(), vec.end(), not2(bind2nd(less<int>(), 5)));// 找到第一个非满足 x less 5的x
auto iter = find_if(vec.begin(), vec.end(), not2(bind1st(less<int>(), 5)));// 找到第一个非满足 5 less x的x

对于foreach这些, 往往需要自己定义二元或者一元函数对象传入, 可以参考 C++ STL 之 函数对象适配器

#include <functional>
struct OperTow : public binary_function<int, int, void> {
    void operator()(int a, int b) const {
        // ...
    }
};
struct OperOne : public unary_function<int, void> {
    void operator()(int a) const {
        // ...
    }
};

STL迭代适配器

STL提供了输入迭代器、输出迭代器、前向迭代器、双向迭代器以及随机访问迭代器这5中基础迭代器, 但是很多场景中还需要借助这几个基础迭代器实现更高级的功能, 迭代适配器将这些基础迭代器的方法进行整合, 修改后提供了一些新的功能, 使用它需要#include <iterator>

反向迭代适配器

常用来对容器进行反向遍历,即从容器中存储的最后一个元素开始,一直遍历到第一个元素

vector<int> myvector{1,2,3,4,5};
reverse_iterator<vector<int>::iterator> re_iter = myvector.rbegin();// *re_iter:5
re_iter++;//*re_iter: 4

插入迭代器

  • insert_iterator: 在容器的指定位置之前插入新元素,前提是该容器必须提供有 insert() 成员方法。
  • back_insert_iterator: 在指定容器的尾部插入新元素,但前提必须是提供有 push_back() 成员方法的容器(包括 vector、deque 和 list)。
  • front_insert_iterator: 在指定容器的头部插入新元素,但前提必须是提供有 push_front() 成员方法的容器(包括 list、deque 和 forward_list)。

一个简单的使用back_insert_iterator的例子

#include <iterator>
vector<int> vec;
back_insert_iterator<vector<int>> back_iter = back_inserter(vec);
back_insert_iterator<vector<int>> back_iter(vec);//这句与上句效果相同
back_iter = 1;//向vec的尾部插入1
back_iter = 2;//向vec的尾部插入2

通常用到copy函数里, 实现复制效果

list<int> lst;//拷贝目的地
std::vector<int> vec{1, 2, 3, 4, 5, 6, 7, 8, 9};//拷贝来源
copy(vec.cbegin(), vec.cend(), back_inserter(lst));

构造函数与析构函数

构造函数时, 如果类似下面的情况, 由于C++兼容C, 所以会被认为是定义了一个函数a, 要注意避免

class A{
public:
    A() {}
}

A a();

如果A类在构造函数中有new操作, 那么当A b = a时, a中的成员会被依次复制, 这就会导致一个问题, 修改b成员也会影响a中涉及new操作的这个成员, 所以折中情况就需要单独定义拷贝时候的构造器

class A{
public:
    A() {
        a = new int[10];
    }
    A(class& b) {
        b.a = new int[10];
    }
private:
    int *a;
}

const

成员函数的参数列表后的const表示这个成员函数不会改变类对象的内容(但是这只是说明, 并不强制), 而如果返回值是类成员的引用或指向它的指针等, 则应该在函数前加上const防止修改.

mutable

简单来说, mutable 是用来修饰一个const实例的部分可变的数据成员的. 比如用在mutex的对象上.

有的时候, 我们拿到的就是一个const的对象, 同时对这个对象又进行了函数调用, 并且需要改变这个对象的成员, 且这个改变是必须且不会对结果有影响的, 比如计数器, 这就会造成错误. 那么就需要在该成员标为mutable, 这样这种操作就是可以的了.

class A {
public:
    void next() const
    {
        cout << "cnt:" << ++cnt << endl;
    }

private:
    mutable int cnt = 0; //没有这个multable, 则cnt无法被修改
};

int main(int argc, char** argv)
{
    A a;
    a.next();
    return 0;
}

extern

声明extern关键字的全局变量和函数可以使得它们能够跨文件被访问

一般把所有的全局变量和全局函数的实现都放在一个*.cpp文件里面, 然后用一个同名的*.h文件包含所有的函数和变量的声明

与extern对应的关键字是static, 被它修饰的全局变量和函数只能在本模块中使用

C++中const修饰的全局常量具有跟static相同的特性, 即它们只能作用于本编译模块中, 且static修饰的是全局变量, 但是const可以与extern连用来声明该常量可以作用于其他编译模块中, 如extern const char g_str[]

extern “C”

包含C语言头文件(假设为cExample.h)时, 需进行下列处理

extern "C"
{
#include "cExample.h" //C++中使用C的函数和变量
}

static

static 表示静态的变量, 分配内存的时, 存储在静态区, 不存储在栈上面

编译器会在程序的main()函数执行之前插入一段代码, 用来初始化全局变量. 当然, 静态变量也包含在内. 该过程被称为静态初始化

类中的静态成员只能声明, 其值需要在class之外定义. 由于所有该类的对象该成员的值相同, 这也是为了避免重复

静态类成员在外部调用时, 不与对象绑定, 而是直接加上类域即可, 比如A::static_member_a = 1

静态类成员函数在外部调用时, 方式与静态类成员相同, 即A::static_member_func(1), 但是要注意, 其内部需要不访问任何非静态内部成员时才可以被这样定义. 同时, 如果该函数的定义在外部, 其前就不必再重复加上static了

打造一个类迭代器

class ClassA_Iterator {
public:
    ClassA_Iterator(int index) : index_(index){ }

    bool operator==(const ClassA_Iterator& it) const { return it.index_ == index_; }    //重载==操作符
    bool operator!=(const ClassA_Iterator& it) const { return !(it.index_ == index_); } //重载!=操作符
    int  operator*() const { return index_; }                                           //重载取值操作符
    //重载前置++操作符, 特别地, 返回值是一个引用
    ClassA_Iterator& operator++() {
        ++index_;
        return *this;
    }
    //重载后置++操作符, 括号内要额外加上类型, 编译器会自动产生一个参数
    ClassA_Iterator operator++(int) {
        ClassA_Iterator tmp = *this;
        ++index_;
        return tmp;
    }
private:
    int index_;
};

//类定义
class ClassA {
public:
    typedef ClassA_Iterator iterator; //重命名, 即嵌套类型

    ClassA_Iterator begin() const {
        return ClassA_Iterator(begin_);
    }

    ClassA_Iterator end() const {
        return ClassA_Iterator(end_);
    }

private:
    int begin_ = 0;
    int end_   = 10;
};

int main(int argc, char** argv)
{
    ClassA a;

    ClassA::iterator it = a.begin(); //用类域指引编译器寻找iterator的内部定义
    while (it != a.end()) {
        auto it_1 = it++;
        auto it_2 = ++it;
        cout << "(" << *it_1 << ")";
        cout << "(" << *it_2 << ")";
    }
    cout << endl;
    //输出(0)(2)(2)(4)(4)(6)(6)(8)(8)(10)

    return 0;
}

friend

friend可以访问类的私有成员, 定义时, 可以出现在class定义的任何位置上

指定时, 可以指定类或者是类中的某个函数, 前者无需先定义该类, 后者则需要先定义这个函数才行

class A{
public:
    void func();
};

class B{
public:
    friend class A;
    friend void A::func();
}

面向对象三大特性

封装可以隐藏实现细节,使得代码模块化. 类可以把自己的数据和方法只让可信的类或者对象操作, 对不可信的进行信息隐藏

继承可以让我们将一群相关的类组织起来, 并得以分享他们的数据和操作

多态则让我们可以用一种与类型无关的方式来操作对象. 具体是通过函数前加上virtual来实现的. 当然多态这种特性也可以通过编程技巧来获得, 只是维护会很麻烦

动态绑定与静态绑定

静态绑定是在编译时就确定应该调用哪个函数

动态绑定则是在运行时才根据具体的对象来决定调用哪个函数

public, protected, private的区别

基类中的private,public,protected的访问范围:

  1. public: 可以被该类中的函数、子类的函数、其友元函数访问, 也可以由该类的对象访问
  2. protected: 可以被该类中的函数、子类的函数、以及其友元函数访问, 但不能被该类的对象访问
  3. private: 只能由该类中的函数、其友元函数访问, 不能被任何其他访问, 该类的对象也不能访问

继承也有这三种继承方式

  1. 使用public继承, 父类中的方法属性不发生改变;
  2. 使用protected继承, 父类的protected和public方法在子类中变为protected, private方法不变;
  3. 使用private继承, 父类的所有方法在子类中变为private;

struct成员默认是public,而class成员默认是private.

typeid运算符

typeid可以查询多态化的类指针或者引用具体指向的对象的类型, 需要#include <typeinfo>

#include <typeinfo>
const char* name = typeid(*this).name(); //获取类名
if (typeid(*a) == typeid(ClassA))        //比较是否是同一个类
if (typeid(*a) == typeid(*b))            //比较是否是同一个类

static_cast, dynamic_cast, const_cast, reinterpret_cast

相比于C语言中()强制转换符, C++提供了四种类型转换运算符, 帮助强调转换的风险, 以及帮助文本检索工具比如grep等查找

static_cast

强制转换, 不一定都成功, 同时风险需要程序员自己控制

double scores = 95.5;
int n = (int)scores;//C写法
int n = static_cast<int>(scores);//C++写法

dynamic_cast

如果一个A类的指针a想调用A的子类B独有的一个函数, 肯定是不成功的, 那么就需要将A类指针a转换成B类指针b.

如果操作者如果不确定是否可以转换, 那么可以通过dynamic_cast来转换, 后者只有当转换成功才会执行转换. 转换时根据需要转换成的类型, 向上或者向下搜索转换.

ClassB  b;
ClassA* p_a = &b;

if (ClassB* p_b = dynamic_cast<ClassB*>(p_a)) {
    p_b->print_class_name(); // print_class_name在ClassA中是一个虚函数
}

const_cast

const_cast 比较好理解, 它用来去掉表达式的const修饰或volatile修饰

const int n = 100;
int *p = const_cast<int*>(&n);

reinterpret_cast 关键字

reinterpret 是“重新解释”的意思, 顾名思义, reinterpret_cast 这种转换仅仅是对二进制位的重新解释, 不会借助已有的转换规则对数据进行调整, 非常简单粗暴, 所以风险很高

class A{
public:
    A(int a = 0, int b = 0): m_a(a), m_b(b){}
private:
    int m_a;
    int m_b;
};

int *p = reinterpret_cast<int*>(new A(25, 96));//*p为25

异常处理

常见的处理方式如下代码

class CException { // 一个自定义的异常类
public:
    CException(string s)
        : msg(s)
    {
    }

    friend ostream& operator<<(ostream& out, CException& e) //重载了ostream友元的<<操作符
    {
        out << e.msg;
        return out;
    }

    string msg;
};

void throw_exception(int n)
{
    try {
        if (n == 1)
            throw -1;
        else if (n == 2)
            throw -1.0;
        else
            throw CException("n != 1 && n != 2");
    } catch (int e) {
        cout << "n == 1" << endl;
    } catch (double e) {
        throw CException("n == 2"); //抛出另一个异常
    } catch (...) {                 //能够捕获任何异常的catch语句
        throw;                      //异常CException("n != 1 && n != 2")再原封不动地抛出
    }
}

int main(int argc, char** argv)
{
    try {
        throw_exception(1);  //异常在函数内被处理
        throw_exception(2);  //异常未在函数被完全处理, 再次向上一层catch抛出一个异常
        throw_exception(-1); //由于已经有异常CException("n == 2")再抛出了, 所以这行不会被执行
    } catch (CException e) {
        cout << e << endl;
    }

    return 0;
}

除此之外, #include <stdexcept>中有C++标准库定义的一些类代表异常

  1. bad_typeid: 使用typeid运算符时, 如果其操作数是一个多态类的指针, 而该指针的值为 NULL, 则会拋出此异常
  2. bad_cast: 在用dynamic_cast进行从多态基类对象(或引用)到派生类的引用的强制类型转换时, 如果转换是不安全的, 则会拋出此异常
  3. bad_alloc: 在用new运算符进行动态内存分配时, 如果没有足够的内存, 则会引发此异常
  4. out_of_range: 用vectorstringat成员函数根据下标访问元素时, 如果下标越界, 则会拋出此异常

虚函数与纯虚函数

在基类与子类(甚至是子类的子类)中可以同时通过在函数前加上virtual来表明某个函数是虚函数. 子类为了覆盖时可以不加, 但基类必须加

假设类A有子类B, B又有一个子类C, 那么类C的虚函数的定义会先从自身找起, 如果没有定义再按顺序找B和A中的实现. 同理类B的虚函数也是先找自身的实现, 如果自身实现缺省则会找A中的实现. 虚函数总归是能找到一个实现的.

纯虚函数也要有实现, 但不一定是在基类A, 也不一定在子类B, 而是在子类C中. 此时子类C的实现是能够找到的, 但是类A的实例和类B的实例都是无法创建的, 因为其缺少该函数的实现.

纯虚函数的定义就是在虚函数后加“=0”, 比如如virtual void func()=0

构造函数不能是虚函数, 析构函数可以是虚函数且推荐最好设置为虚函数

当基类中的某个成员方法, 由子类提供个性化实现, 但基类也可以提供缺省时的备选方案的时候, 该方法应该设计为虚函数

当基类中的某个成员方法, 必须由子类提供个性化实现的时候, 应该设计为纯虚函数’

虚表

虚函数是通过一个或多个虚函数表来实现的, 这张表解决了继承、覆盖的问题, 保证其真实反应实际的函数.

每个基类都有自己的虚函数表, 继承自几个基类就有几个虚函数表. 虚函数表的顺序与继承的顺序相同. 派生类自己的继承自其他基类的虚函数和自己的虚函数等按顺序放在第一个虚函数表的后面. 派生类的虚函数会在虚函数表中覆盖基类虚函数的位置.

虚函数表存在于对象地址的最前面, 通过对对象取地址, 可以得到第一张虚表的地址. 如果有多个虚表, 则将指针偏移一次后再转成long*类型即可获得第二张虚表的地址.

当用父类的指针来操作一个子类的时候, 这张虚函数表就可以指明实际该父类指针所应该调用的函数.

class Base1 {
public:
    virtual void A() { cout << "Base1::A" << endl; }
    virtual void B() { cout << "Base1::B" << endl; }
};

class Base2 {
public:
    virtual void C() { cout << "Base1::C" << endl; }
    virtual void D() { cout << "Base1::D" << endl; }
    void         E() { cout << "Base1::E" << endl; }

    int a = 10;
};

class Derive : public Base1
    , public Base2 {
public:
    virtual void A() { cout << "Derive::A" << endl; } //继承的第一个类的虚函数放入第一个虚表
    virtual void C() { cout << "Derive::C" << endl; } //继承的第二个类的虚函数放入第二个虚表
    virtual void F() { cout << "Derive::F" << endl; } //非继承的虚函数加入第一个虚表的末尾
    void         E() { cout << "Derive::E" << endl; } //非虚函数E不加入虚表

    int a = 100;
};

typedef void (*func)();

int main(int argc, const char* argv[])
{
    Derive d;

    //只有两个虚表, 所以有16字节. 如果还有成员变量等, 则向8字节(64位系统)对齐
    cout << "Derive对象所占的内存大小为:" << sizeof(d) << endl;

    long* vptr = (long*)&d; //虚表指针, long在32位系统中为4字节, 在64位系统中为8字节

    long* vptr1 = (long*)(*vptr); //获取第一个虚表指针
    func  D_11  = (func)vptr1[0]; // 覆盖的继承的第一个类的虚函数按序放入第一个虚表
    D_11();                       // Derive::A
    func D_12 = (func)vptr1[1];   // 非覆盖的继承的第一个类的虚函数按序放入第一个虚表
    D_12();                       // Base1::B
    func D_13 = (func)vptr1[2];   // 接着将非继承的虚函数C放入第一个虚表
    D_13();                       // Derive::C
    func D_14 = (func)vptr1[3];   // 接着将非继承的虚函数F放入第一个虚表
    D_14();                       // Derive::F

    long* vptr2 = (long*)(*(vptr + 1)); //获取第二个虚表指针
    func  D_21  = (func)vptr2[0];       // 覆盖的继承的第二个类的虚函数按序放入第二个虚表
    D_21();                             // Derive::C

    Base1* b1 = &d;
    b1->A(); //转换为Base1指针后, 根据第一个虚表找到A函数的指针然后执行
    Base2* b2 = &d;
    b2->C(); //转换为Base1指针后, 根据第一个虚表找到C函数的指针然后执行

    cout << d.a << endl;   // 100
    cout << b2->a << endl; // 10
    d.E();                 // Derive::E
    b2->E();               // Base1::E

    return 0;
}

虚函数表和虚函数指针的创建时间

虚函数表是可以在编译时就决定的, 所以在实例化对象的时候, 编译器会在构造函数中插入一段代码, 这段代码用来给虚函数指针赋值, 所以在memset之前, 这个函数在编译时就像普通函数一样被解决了与执行代码间的关系, 就算后面再修改虚函数指针的值, func函数仍然会被执行. 但是对于类的多态性, 此时就无法实现了.

所以对于多态, 一定采用的是动态绑定, 此时只有程序运行时才能确定将要调用的函数. 所以要实现多态特性, 一定是用指针或者引用的形式.

class A {
public:
    A()
    {
        memset(this, 0, sizeof(long));
    }
    ~A() { }

    virtual void func() { cout << "func" << endl; }

    int i = 0; // 4字节向8字节对齐, 所以对象的占用16字节, 其中先是8字节的虚表指针, 再是int类型成员占用的8字节
};

int main(int argc, const char* argv[])
{
    A a;
    cout << "Derive对象所占的内存大小为:" << sizeof(a) << endl;
    a.func();

    A* b = new A; //使用动态绑定
    b->func();    //在执行时才会发现虚表指针被修改, 所以会导致程序崩溃

    return 0;
}

lambda

lambda其实就是一个函数对象, 避免额外写一个函数对象, 在需要的地方将功能闭包, 从而避免代码膨胀和功能分散

主体结构如下, specs有返回类型, 说明符、异常、属性等, 每个组件均可选. 返回类型可推导时可以不用说明, 参数列表为空时也也可以省去.

[ 捕获列表 ] ( 参数列表 ) specs(optional) { 主体 }

一个实例如下

vector<int> v = { 1, 2, 3, 4, 5 };
int even_count = 0;
for_each(v.begin(), v.end(), [&even_count](int val) {if (val & 1) {++even_count;} });
cout << "The number of even is " << even_count << endl;//3

关于捕获列表

  • [] 不捕获任何变量
  • [&] 捕获外部作用域中所有变量, 并作为引用在函数体中使用(按引用捕获)
  • [=] 捕获外部作用域中所有变量, 并作为副本在函数体中使用(按值捕获)
  • [=, &foo] 按值捕获外部作用域中所有变量, 并按引用捕获 foo 变量
  • [bar] 按值捕获 bar 变量, 同时不捕获其他变量
  • [this] 捕获当前类中的 this 指针, 让lambda表达式拥有和当前类成员函数同样的访问权限. 如果已经使用了 & 或者 =, 就默认添加此选项. 捕获 this 的目的是可以在 lamda 中使用当前类的成员函数和成员变量。
class A {
public:
    int  i_ = 0;
    void func(int x, int y)
    {
        auto x1 = [] { return i_; };                   // error, 没有捕获this指针, 不能访问成员
        auto x2 = [=] { return i_ + x + y; };          // OK, 捕获所有外部变量(默认添加this), 采用传值形式
        auto x3 = [&] { return i_ + x + y; };          // OK, 捕获所有外部变量(默认添加this), 采用引用形式
        auto x4 = [this] { return i_; };               // OK, 捕获this指针
        auto x7 = [this] { return i_++; };             // OK, 捕获this指针, 并修改成员的值
        auto x5 = [this] { return i_ + x + y; };       // error, 没有捕获x、y
        auto x6 = [this, x, y] { return i_ + x + y; }; // OK, 捕获this指针、x、y
    }
};

int main(int argc, const char* argv[])
{
    int  a = 0, b = 1;
    auto f1 = [] { return a; };              // error, 没有捕获外部变量
    auto f2 = [&] { return a++; };           // OK, 捕获所有外部变量, 并对a执行自加运算
    auto f3 = [=] { return a; };             // OK, 捕获所有外部变量, 并返回a
    auto f4 = [=] { return a++; };           // error, a是以复制方式捕获的, 无法修改
    auto f5 = [a] { return a + b; };         // error, 没有捕获变量b
    auto f6 = [a, &b] { return a + (b++); }; // OK, 捕获a和b的引用, 并对b做自加运算
    auto f7 = [=, &b] { return a + (b++); }; // OK, 捕获所有外部变量和b的引用,并对b做自加运算
}

关于延迟调用, 如果是按值捕获时, 需要注意这个问题, 解决办法就是改为按引用捕获即可

int  c = 0;
auto f = [=] { return c; };    // 按值捕获外部变量
auto e = [&] { return c; };    // 按值捕获外部变量
c += 1;                        // a被修改了
std::cout << f() << std::endl; // 输出0
std::cout << e() << std::endl; // 输出1

函数声明与定义

以下3中声明方式和一种定义方式组合都是合法的

int add(int a, int b);   // 函数声明
int add(int c, int d);   // 函数声明
int add(int , int );     // 函数声明
int add(int a, int b)    // 函数定义
{
    return a+b;
}

默认函数控制"=default"和"=delete"

C++ 的类有四类特殊成员函数, 它们分别是:默认构造函数、析构函数、拷贝构造函数以及拷贝赋值运算符.
这些类的特殊成员函数负责创建、初始化、销毁, 或者拷贝类的对象.

如果程序员没有显式地为一个类定义某个特殊成员函数, 而又需要用到该特殊成员函数时, 则编译器会隐式的为这个类生成一个默认的特殊成员函数.

C++11 标准引入了一个新特性: "=default"函数. 程序员只需在函数声明后加上“=default;”, 就可将该函数声明为 "=default"函数, 编译器将为显式声明的 “=default"函数自动生成函数体. 但是”=default"函数特性仅适用于类的特殊成员函数, 且该特殊成员函数没有默认参数. 使用它比用户自己定义的默认构造函数获得更高的代码效率. 其在类内或者类外定义都可以.

为了能够让程序员显式的禁用某个函数, C++11标准引入了一个新特性: "=delete"函数. 程序员只需在函数声明后上“=delete;”, 就可将该函数禁用.

shared_ptr智能指针

智能指针和普通指针的用法是相似的,不同之处在于,智能指针可以在适当时机自动释放分配的内存

和 unique_ptr、weak_ptr 不同之处在于,多个 shared_ptr 智能指针可以共同使用同一块堆内存。并且,由于该类型智能指针在实现上采用的是引用计数机制,即便有一个 shared_ptr 指针放弃了堆内存的“使用权”(引用计数减 1),也不会影响其他指向同一堆内存的 shared_ptr 指针(只有引用计数为 0 时,堆内存才会被自动释放)。

shared_ptr, unique_ptr和weak_ptr都被包含在<memory>头文件中

std::shared_ptr<int> p1;                //不传入任何实参, 即创建一个空智能指针
std::shared_ptr<int> p2(nullptr);       //传入空指针 nullptr
std::shared_ptr<int> p4(p3);            //调用拷贝构造函数
std::shared_ptr<int> p5(std::move(p4)); //调用移动构造函数, move将p4强制转换为右值, 此时少一次拷贝

//指定 default_delete 作为释放规则
std::shared_ptr<int> p6(new int[10], std::default_delete<int[]>());
//自定义释放规则
void deleteInt(int*p) {
    delete []p;
}
std::shared_ptr<int> p7(new int[10], deleteInt);

//尽量不要用裸指针创建 shared_ptr,以免出现分组不同导致错误
// 错误
 int* p = new int;
 shared_ptr<int> sptr1( p);   // count 1
 shared_ptr<int> sptr2( p );  // count 1

// 正确
shared_ptr<int> sptr1( new int );  // count 1
shared_ptr<int> sptr2 = sptr1;     // count 2

关于智能指针的几种使用细节, 可以参考C++ 11 智能指针详解

unique_lock与lock_guard

std::unique_lockstd::lock_guard都能实现自动加锁与解锁功能, 但是std::unique_lock要比std::lock_guard更灵活, 但是更灵活的代价是占用空间相对更大一点且相对更慢一点.

有一篇文章对其有很好的解释C++11 std::unique_lock与std::lock_guard区别及多线程应用实例

单例模式

其意图是保证一个类仅有一个实例, 并提供一个访问它的全局访问点, 该实例被所有程序模块共享. 它应该有以下特点

  1. 构造函数是private的, 以防止外界创建该单例
  2. 有一个private的静态指针变量指向自身的唯一实例
  3. 能够有一个public的静态方法获取该实例

关于几种单例模式, 这篇文章举出的几个例子很好C++ 单例模式

而关于实现的各方面细节, 这篇文章得解释也非常精彩面试中的Singleton

最好的单例实现

因为C++11新的原因规范, 编译器保证了函数内静态变量的线程安全性. 在此之前, 编译器通过一个标志位来判断是否被初始化, 那么在多线程环境下, 他们可能同时进入标志位的if判定语句块中, 从而进行了两次初始化

template <typename T>
class Singleton {
public:
    static T& get_instance()
    {
        static T instance;
        return instance;
    }

protected:
    //保证单例不会通过其他途径被实例化, 同时又能够被子类继承和使用
    Singleton()  = default;
    ~Singleton() = default;

private:
    //将默认的特殊成员函数(拷贝构造函数和拷贝赋值运算符)设置为禁用, 防止其被拷贝, 在声明后添加=delete;即可
    Singleton(const T&) = delete;
    //返回值不设为void而设为Singleton&的好处就是能够避免在返回数据时调用拷贝构造函数,还能够达到连续赋值的目的
    //参数中用const T&的好处就是避免在传参时调用拷贝构造函数, 还能够同时接收const类型和非const类型的实参
    Singleton& operator=(const T&) = delete;
};

class SingletonInstance : public Singleton<int> {
public:
    void operator=(const int x)
    {
        get_instance() = x;
    }
};

double& g_instance_double = Singleton<double>::get_instance();

int main(int argc, const char* argv[])
{
    cout << g_instance_double << endl;

    SingletonInstance instance;
    instance = 1;
    cout << instance.get_instance() << endl;

    return 0;
}

带锁的多线程安全的单例实现

#include <iostream>
#include <memory> //unique_ptr
#include <mutex>  //lock_guard,mutex

template <typename T>
class Singleton {
public:
    static T& get_instance()
    {
        if (!_instance) {
            std::lock_guard<std::mutex> lock(_mutex);
            if (!_instance) {
                _instance.reset(new T);
            }
        }
        return *_instance;
    }

protected:
    Singleton()  = default;
    ~Singleton() = default;

private:
    Singleton(const T&) = delete;
    Singleton& operator=(const T&) = delete;

private:
    // unique_ptr实现了独享所有权的语义. 它只可以使用new来分配内存
    // unique_ptr不可拷贝和赋值, 在某一时刻,只能有一个unique_ptr指向特定的对象
    // unique_ptr只能移动, 这意味着内存资源所有权将转移到另一unique_ptr, 并且原始unique_ptr不再拥有此资源
    static std::unique_ptr<T> _instance;
    static std::mutex         _mutex;
};

// 类中的静态成员只能声明, 其值需要在class之外定义
template <typename T>
std::unique_ptr<T> Singleton<T>::_instance;
template <typename T>
std::mutex Singleton<T>::_mutex;

double& g_instance_double = Singleton<double>::get_instance();

int main(int argc, const char* argv[])
{
    std::cout << g_instance_double << std::endl;
    return 0;
}

注意, 这里没有使用T* _instance的定义, 因为这样的话初始化的过程会如下.

static T& get_instance()
{
    if (!_instance) {
        std::lock_guard<std::mutex> lock(_mutex);
        if (!_instance) {
            _instance = new T;
        }
    }
    return *_instance;
}

这会存在多线程下的安全问题, 因为这里的new操作, 会被转化为3步

  1. 申请一块内存
  2. 调用构造函数
  3. 将该内存地址赋给instance

那么不同的编译器表现可能不同, 它可能执行顺序是132 假设线程A刚好执行完13步, 还没有执行2, 即调用构造函数. 线程B此时判断其不为空就返回了该变量并调用其函数, 此时就会出现错误

左值右值与右值引用

左值一般是可寻址的变量, 右值一般是不可寻址的字面常量或者是在表达式求值过程中创建的可寻址的无名临时对象

int x = 5;//x也是一个左值
int &l = x;//l就是一个左值, 这种引用叫左值引用
int &&r = 5 * 5;//5 * 5就是一个右值, 这种引用叫右值引用, 等待5 * 5的内存位置有了名称后, r即变成一个左值
r = 100;//右值引用可以对右值进行修改

使用std::move可以强制将左值引用转为右值引用. 而对于右值引用, 程序可以调用移动构造函数进行对象的构造,减少了原来调用拷贝构造函数的时候很大的开销.

volatile

遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。而且读取的数据立刻被保存, 从而可以提供对特殊地址的稳定访问

void main()
{
    int i = 10;
    // volatile int i = 10; // 使用volatile则不会出现这个问题
    int a = i;
    printf("i = %d", a);//10
    __asm {
        mov dword ptr [ebp-4], 20h // 改变内存中 i 的值, 但是又不让编译器知道
    }
    int b = i;
    printf("i = %d", b);//10
}

关于多线程下的volatile, 当两个线程都要用到某一个变量且该变量的值会被改变时, 应该用volatile声明, 该关键字的作用是防止优化编译器把变量从内存装入CPU寄存器中.

如果变量被装入寄存器, 那么两个线程有可能一个使用内存中的变量, 一个使用寄存器中的变量, 这会造成程序的错误执行. volatile的意思是让编译器每次操作该变量时一定要从内存中真正取出, 而不是使用已经存在寄存器中的值

深拷贝与浅拷贝

对下面的结构, 拷贝时, 浅拷贝代表直接复制x和p的值, 当然这会造成拷贝出来的对象与被拷贝的对象之间仍然会有粘连, 导致释放被拷贝对象后, 再释放拷贝出来的对象时, 出现了重复释放的问题

深拷贝则是直接复制x, 对于p单独new一块堆内存, 这样就不会出现重复释放的问题

struct X
{
  int x;
  int* p;
};

Unicode字符集

最初的Unicode字符集用16位, 后来用32位(实际是31位, 首位为0). Linux下可以用echo $LANG查看. UTF-8以字节为单位进行编码, UTF-16则以双字节无符号整数编码. 后者会牵扯大端或者小端模式的问题, 前者则没有. X86和ARM平台都是小端, 其意味着会将数据的低位字节反而在放在内存起始地址. UTF-32则由于本身就是32位无符号整数来存储的, 不涉及该问题. 但该方式造成存储的浪费和传输的低效. Windows上用的方案是UTF-16, Linux系统大多用UTF-8, 但新版本的Linux(如Centos 7)也开始用UTF-32了.

union

union Data
{
   int i;
   float f;
   char  str[20];
};

int main( )
{
   union Data data;

   data.i = 10;
   data.f = 220.5;
   strcpy( data.str, "C Programming");

   printf( "data.i : %d\n", data.i);//1917853763
   printf( "data.f : %f\n", data.f);//4122360580327794860452759994368.000000
   printf( "data.str : %s\n", data.str);//C Programming

   return 0;
}

内存对齐

结构体内存对齐

32位系统下,int占4byte,char占一个byte,那么将它们放到一个结构体中应该占5byte, 但是实际上,通过运行程序得到的结果是8 byte

#pragma pack(push) //保存对齐状态
#pragma pack(1)  //设定为1字节对齐
struct test
{
char m1;
double m4;
int m3;
};
#pragma pack(pop)  

字符串分隔

单个分隔符可以用stringstream配合getline指定分隔符实现. 多个分隔符可以用strtok, 速度更快一点可以用strsep, 线程安全可以用strtok_r

指针占用内存

指针类型占用的内存大小是固定的(无论该指针指向哪种数据类型). 在32位系统中为4字节, 在64位系统中为8字节.

NULL与nullptr

C中的NULL是0, C++中的NULL是(void *)0, 但在C++中推荐使用nullptr来表示空指针

#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif

namespace

using namespace可以直接在语句块里用, 范围也限定在语句块内

生命周期

在使用c_str()时注意获取返回的指针时使用const, 并最好拷贝一下, 否则容易因为其生命周期结束而导致该指针失效.

举报

相关推荐

C++面试

C++相关面试

C++算法面试

C++ this 指针 面试

C++面试---小米

[总结] C++面试

c++面试一

C++面试汇总

0 条评论