C++面试

C++面试语法总结

static

  1. static能够限制全局变量的作用域。在某一cpp文件中,我能通过extern的方式来访问其他cpp文件的全局变量。而static就断绝了这种可能性。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    /*A.cpp*/
    int a = 1;
    extern const int b = 1;
    //const在cpp文件中默认是当前文件可见,若需要扩展可见性需要加上extern关键字
    static int c = 1;

    /*B.cpp*/
    extern int a;
    extern const int b;
    extern int c;

    int main(){
    std::cout<<a<<std::endl;//Success.
    std::cout<<b<<std::endl;//Success.
    std::cout<<c<<std::endl;//Link error.
    }
  2. static表示数据的唯一性。在C++类里的static成员以及static成员函数,这些成员他们的归属不是对象,而是这个类本身,因此我们可以通过static成员来记录当前实例对象的数目。

    对于static成员的初始化,在类外进行。

  3. 局部变量的static能够为函数加上状态。

extern

  1. 正如在static中所说的,通过extern声明可以访问其他cpp文件中的全局变量。通常在头文件中声明,在源文件中定义。比方说在一个项目里,我想定义一些可以供其他代码使用的常量。我可以通过宏的方式,在一个单独的头文件里进行宏的定义,在需要使用到某一变量时便引入该头文件。但这种使用一个便要引入全部的方式可能并不是太理想。我们可以换种其他的方式,在一个cpp文件里面定义所有的常量,然后每次需要使用的时候便通过extern引入该变量。使代码更简洁。

  2. extern “C”。C++为函数实现了重载,重载的实现方式是一种叫做name mangling的处理。将函数的形参以及初始函数名结合进行编码,形成一个该函数唯一标识的函数名。比方说重载函数,因为形参不同,通过name mangling处理后,函数名便变得唯一了。

    正因为C++对于重载采取的特殊处理,当C++与C协同工作时可能会出现问题。比方说我想使用某个C的函数,我在文件进行声明,但C++编译器会对该声明进行name mangling处理,处理后在与该C函数所在obj文件链接时会出现链接错误。(名字不符了,有点被打得妈都不认识的意思。hhh)这个时候我们就需要在这段代码前加上extern “C”,用来告诉编译器,别对我进行name mangling处理。

volatile

​ 在知乎上面看到个回答觉得特别好,volatile的意思就是非CPU改内存

​ 在程序执行的时候往往会进行某种程度的优化,比方说

1
2
int a = *ptr;
int b = *ptr;

​ 正常来讲代码需要两次访问ptr指向的内存,编译器可能就会在此基础上做出优化,在第一次访问后将该内容放在寄存器里,后面再访问时,直接访问寄存器就行,节省一次访存时间。

​ 但是这种优化的正确性在于我对没有非CPU部分改内存的假设。假若有个IO设备,修改了该内存,而程序却对此一无所知。volatile就是告诉编译器对该变量别做优化,每次访问从内存里读。

多态与虚函数

​ 我理解的多态就是一种接口,多种方法。多态分为编译期多态运行期多态

  1. 编译期多态。谈起编译期多态,我们首先提到的便是重载。相同的函数名,因为参数的不同有着不同的方法。重载的实现是通过采用一种叫做name mangling的处理方式。因为对函数调用的绑定是在编译期决议的,因此是一种编译期多态。

    另外一种用得比较多的编译期多态便是函数模板。一个简单的函数模板,因为模板参数的不同,在编译期实例化出不同的模板实例。这也是所谓的一种接口,多种方法。

  2. 运行期多态。也是我们谈起多态时的默认含义,它是通过基类的指针或引用来调用虚函数接口,实际调用的函数取决于指针(或引用)实际指向的对象。

    虚函数的实现建议阅读《深度探索C++对象模型》,以及陈皓博客

    https://blog.csdn.net/haoel/article/details/1948051/

四种类型转换

​ 在介绍C++的四种类型转换时,我们先来看看C语言的显式转换。TYPE b = (TYPE) a,这种C风格的转换存在着许多缺点。因为它可以在任意类型之间进行转换,比如你可以把一个指向const对象的指针转换成指向非 const对象的指针,把一个指向基类对象的指针转换成指向一个派生类对象的指针。这两种转换差别巨大,但是传统C语言风格的类型转换没有区分。还有就是C语言针对每一种转换的格式都一样,不容易查找。

  • static_cast,主要用于以下场合:

    • 用于类层次结构中,父类和子类之间指针和引用的转换;进行上行转换,把子类对象的指针/引用转换为父类指针/引用,这种转换是安全的;进行下行转换,把父类对象的指针/引用转换成子类指针/引用,这种转换是不安全的,需要编写程序时来确认;
    • 用于基本数据类型之间的转换,例如把int转char,int转enum等,需要编写程序时来确认安全性;
    • 把void指针转换成目标类型的指针(这是极其不安全的;
  • const_cast,用于移除类型的const,volatile,__unaligned属性。

    1
    2
    const char* pc;
    char *p = const_cast<char*>(pc);
  • dynamic_cast, 转换仅适用于指针h或引用。

    ​ 相比static_cast,dynamic_cast会在运行时检查类型转换是否合法,具有一定的安全性。由于运行时的检查,所以会额外消耗一些性能。dynamic_cast使用场景与static相似,在类层次结构中使用时,上行转换和static_cast没有区别,都是安全的;下行转换时,dynamic_cast会检查转换的类型,相比static_cast更安全。

    ​ 在转换可能发生的前提下,dynamic_cast会尝试转换,若指针转换失败,则返回空指针,若引用转换失败,则抛出异常。

    1. 继承中的转换

      • 上行转换:在继承关系中,dynamic_cast由子类向父类转换与static_cast和隐式转换一样,都是非常安全的。

      • 下行转换:

        1
        2
        3
        4
        5
        6
        class A{virtual void f(){}};
        class B:public A{};
        void main(){
        A* pa = new B;
        B* pb = dynamic_cast<B*>(pa);
        }

        A中定义了一个虚函数,这是不可缺少的。由于运行时类型检查需要运行时类型信息,而这个信息存在类的虚函数表中,只有定义了虚函数的类才有虚函数表。

    2. void*的转换

      一些情况下,我们需要将指针转换为void,然后再合适的时候重新将void转换为目标类型指针。

      因为在多重继承里,存在着指针调整的情况,调用dynamic_cast<void*>(ptr),可以通过查表的方式,确定ptr实际对象的初始地址。

      1
      2
      3
      4
      5
      6
      class A { virtual void f(){} };
      int main()
      {
      A *pA = new A;
      void *pV = dynamic_cast<void *>(pA);
      }菱形继承中的上行转换
    3. 首先,定义一组菱形继承的类:

    1
    2
    3
    4
    class A { virtual void f() {}; };
    class B :public A { void f() {}; };
    class C :public A { void f() {}; };
    class D :public B, public C { void f() {}; };

B继承A,C继承A。

D继承B和C。

考虑这样的情况:D对象指针能否安全的转换为A类型指针?

直觉来说是可以的,因为从子类向父类转化,无论如何都是安全的。

1
2
3
4
5
6
7
8
9
10
class A { virtual void f() {}; };
class B :public A { void f() {}; };
class C :public A { void f() {}; };
class D :public B, public C { virtual void f() {}; };

void main()
{
D *pD = new D;
A *pA = dynamic_cast<A *>(pD); // pA = NULL
}

但实际上,如果尝试这样的转换,只能得到一个空指针。因为B和C都继承了A,并且都实现了虚函数f(),导致在进行转换时,无法选择一条转换路径。

一种可行的方法是,自行指定一条转换路径:

1
2
3
4
5
6
7
8
9
10
11
class A { virtual void f() {}; };
class B :public A { void f() {}; };
class C :public A { void f() {}; };
class D :public B, public C { void f() {}; };

void main()
{
D *pD = new D;
B *pB = dynamic_cast<B *>(pD);
A *pA = dynamic_cast<A *>(pB);
}
  1. reinterpret_cast

    可以转换任意类型的指针,在编译器完成,最好别使用。

常见问题

  1. 为什么析构函数不能抛出异常?
  • 如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。(抛出点意味着后面的内容不会执行。)
  • 通常异常发生时,c++的机制会调用已经构造对象的析构函数来释放资源,此时若析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃的问题。

析构函数无法保证没有异常情况怎么处理?

​ 把异常封闭在析构函数的内部,不让异常抛出函数之外。

1
2
3
4
5
6
7
~Destructor{
try{
do_something();
}catch(){
//可以什么都不做,只是确保异常不要逃出析构函数。
}
}
  1. 构造函数和析构函数中调用虚函数吗?(Effective C++ Item9)

​ 最好不要在构造函数和析构函数中调用虚函数,不见得一定会出现错误,但可能不符合你的预期。

  • 在我们构造一个子类对象时,我们先执行的是父类的构造函数,然后执行子类的构造函数。倘若我们在父类构造函数里面调用虚函数。我们可以猜想到编译器可能会采取的两种处理方式:

    • Plan A,调用虚函数的基类版本,这样失去了运行时调用的正确版本的意义。
    • Plan B,调用虚函数的正确版本,倘若正确版本是子类函数,但子类这个时候还不存在,函数调用会导致未知行为。

    实际上编译器采用的是Plan A, 这种方式可以避免严重错误。但不可避免的是给使用者造成不符预期的困惑。

  • 同样的道理,析构函数的调用顺序是先子类,再父类。同样存在在父类中调用所谓“正确版本”的虚函数时而子类不存在的问题。编译器采用的同样是调用虚函数的基类版本的方式。

    为了演示这只是构造函数和析构函数的特殊性,让我们来看下面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A{
public:
A(){func();}
virtual void func(){std::cout<<"A::func";}
void f(){func();}
};

class B:public A{
public:
B():A(){}
void func() override{std::cout<<"B::func";}
};

int main(){
A* a = new B;
a->f();//result : A::func. B::func.
}

​ 很明显在构造函数里面和普通函数分别调用虚函数行为不一样。

  1. 引用和指针的区别

    • 引用在创建时必须初始化,引用到一个有效对象;而指针在定义时不必初始化,可以在定义后的任何地方重新赋值。

    • 指针可以是NULL,引用不行

    • 引用貌似一个对象的小名,一旦初始化指向一个对象,就不能将其他对象重新赋值给该引用,这样引用和原对象的值都会被更改。

    • 引用的创建和销毁不会调用类的拷贝构造函数和析构函数。

  1. 内存对齐的原则

    • 结构体的总大小,必须要是其内部最大成员的整数倍,不足的要补齐。

      1
      2
      3
      4
      5
      6
      7
      typedef struct one {
      char a;=====> 1 -> 4
      int b;======> 4
      double c;===> 8. //max
      char d;=====> 1 -> 8 //补齐到8的整数倍
      } ONE;
      //结构体one总大小: 4+4+8 = 16
  • 结构体或联合的数据成员,第一个数据成员要放在offset==0的地方,如果遇上子成员,要根据子成员的类型存放在对应的整数倍上。

    1
    2
    3
    4
    5
    6
    7
    typedef struct two {
    char array[2];==> 2 -> 4
    int b;==========> 4
    double c;=======> 8 //max
    float d;========> 4 -> 8 //原则1
    } TWO;
    //结构体two总大小: 4+4+8+8 = 24
  • 如果结构体作为成员,则要找到这个结构体中的最大元素,然后从这个最大成员的整数倍地址开始存储。

    1
    2
    3
    4
    5
    6
    7
    8
    struct three {
    char a; ====> 1 -> 4
    int b;======> 4
    double c;===> 8
    short d;====> 2 -> 8 //原则3 ,下面是个结构体,其中最大成员为8,则需要从8的整数倍地址存放,所以变量d补齐到8
    TWO e; ==> 24 (max 8)
    };
    //结构体two总大小: 4+4+8+8+24 = 48