zoukankan      html  css  js  c++  java
  • C++陷阱系列:让面试官倒掉的题

    http://blog.chinaunix.net/uid-22754909-id-3969535.html

    今天和几位同仁一起探讨了一下C++的一些基础知识,在座的同仁都是行家了,有的多次当过C++技术面试官。不过我出的题过于刁钻: 不是看起来太难,而是看起来极其容易,但是其实非常难! 结果一圈下来,4道题,平均答对半题。于是只能安慰大家,这几道题,答不对是正常的。
        "你真的清楚构造函数,拷贝构造函数,operator=,析构函数都做了什么吗? 它们什么时候被调用?",这些问题可不是面向初菜的问题,对于老鸟而言,甚至对于许多自诩为老手的人而言,倒在这上面也是很正常的。因为这个问题的答案不但考察我们对于C++语言的理解,而且答案是和编译器的实现有关的!
    【第一题】以下代码,main函数中G.i的打印结果是什么? 写在一张纸上,再看答案。我不是在挑战大家的知识,我是在挑战很多人的常识。

    点击(此处)折叠或打开

    1. #include<iostream>
    2. using namespace std;
    3. class G
    4. {
    5. public:
    6.         static int i;
    7.         G() {cout<<"ctor"<<endl;i+=1;}
    8.         G(const G& rg){cout<<"copy ctor"<<endl;i+=2;}
    9.         G& operator=(const G& rg){cout<<__FUNCTION__<<endl;i+=3;return *this;}
    10. };
    11. int G::i=0;
    12. G Create()
    13. {
    14.         cout<<__FUNCTION__<<" starts"<<endl;
    15.         G obj;
    16.         cout<<__FUNCTION__<<" ends"<<endl;
    17.         return obj;
    18. }
    19. int main(int argc, char* argv[])
    20. {
    21.         G g1=Create();
    22.         cout<<"G.i="<<G::i<<endl;
    23.         return 0;
    24. }

        "3,2,1,公布答案"。G.i是多少? 回答4及其以上的统统枪毙。回答3及其以下的留下继续讨论。注意,这里根本就没有调用到operator=,因为operator=被调用的前提是一个对象已经存在,我们再次给它赋值,调用的才是operator=。
       那么答案到底是多少呢? VC编译器,用2008或者2012,Debug版都是3,Release版都是1。用GCC4.7,Debug/Release都是1。
       为什么? 因为G g1=Create();这句话,可能会触发C++编译器的一个实现特性,叫做NRVO,命名返回值优化。也就是G函数中的obj并没有被创建在G的调用栈中,而是调用Create()函数的main的栈当中,因此obj不再是一个函数的返回变量,而是用g1给Create()返回的变量命名。
       VC的Debug版没有触发NRVO,因此会多调用一个拷贝构造函数,结果和Release版不一样----能说出这个的C++一定是中级以上水平了。
       这就带了一个问题,如果用VC编程的话,HouseKeep/计数的信息如果在ctor/copy ctor里面,那么不能保证调试版和发布版的行为一致。这个坑太大了。但是GCC没有这个问题!瞬间对理查德-斯托曼无比敬仰。

    【第二题】以下程序的运行结果是什么:

    点击(此处)折叠或打开

    1. #include <vector>
    2. #include <iostream>
    3. using namespace std;
    4. struct Noisy {
    5.     Noisy() {std::cout << "constructed "; }
    6.     Noisy(const Noisy&) { std::cout << "copied "; }
    7.     ~Noisy() {std::cout << "destructed "; }
    8. };
    9.  
    10. std::vector<Noisy> f()
    11. {
    12.     std::vector<Noisy> v = std::vector<Noisy>(2); // copy elision from temporary to v
    13.     return v; // NRVO from v to the nameless temporary that is returned
    14. }
    15.  
    16. void fn_by_val(std::vector<Noisy> arg) // copied
    17. {
    18.     std::cout << "arg.size() = " << arg.size() << ' ';
    19. }
    20.  
    21. void main()
    22. {
    23.     std::vector<Noisy> v = f(); // copy elision from returned temporary to v
    24.     cout<<"------------------before"<<endl;
    25.     fn_by_val(f());// and from temporary to the argument of fn_by_val()
    26.     cout<<"------------------after"<<endl;
    27. }

        第一轮没有被枪毙的同学注意了: 这道题目的答案仍然是和编译器有关的,而且和版本还有关系。
    (2.1) VC2008 Debug版的运行结果

    点击(此处)折叠或打开

    1. constructed
    2. copied
    3. copied
    4. destructed
    5. copied
    6. copied
    7. destructed
    8. destructed
    9. ------------------before
    10. constructed
    11. copied
    12. copied
    13. destructed
    14. copied
    15. copied
    16. destructed
    17. destructed
    18. arg.size() = 2
    19. destructed
    20. destructed
    21. ------------------after
    22. destructed
    23. destructed
    24. Press any key to continue . . .

        看到了吗,在"------------before"之前,有一个奇怪的ctor, copy ctor, copy ctor, dtor的调用序列? 这是VC2008当中std::vector<Noisy>(2)做的事情: 先调用一个默认构造函数构造Noisy临时对象,然后把临时对象拷贝给vector的两个程序,再把临时对象析构掉。太傻了吧!Release版的结果稍微好一点,返回的vector不再被拷贝了,就如同第一题所说的:
    (2.2) VC2008 Release版的运行结果

    点击(此处)折叠或打开

    1. constructed
    2. copied
    3. copied
    4. destructed
    5. ------------------before
    6. constructed
    7. copied
    8. copied
    9. destructed
    10. arg.size() = 2
    11. destructed
    12. destructed
    13. ------------------after
    14. destructed
    15. destructed
    16. Press any key to continue . . .

       换个编译器VC2012编译出来的,就聪明多了(Debug/Release运行结果相同):

    点击(此处)折叠或打开

    1. constructed
    2. constructed
    3. ------------------before
    4. constructed
    5. constructed
    6. arg.size() = 2
    7. destructed
    8. destructed
    9. ------------------after
    10. destructed
    11. destructed
    12. Press any key to continue . . .

        调用了两次ctorl来构造这个vector。性能提高多了。慢点,还有一点不同,因为函数fn_by_val的参数是传值而不是传引用,所以编译器知道在这个函数里面vector没有被修改,因此直接把传值优化成了传const&! VC2012的Debug/Release一致!终于赶上GCC了,不容易。
        问题:到底什么时候一个拷贝构造的操作可以被优化掉呢? C++标准还是有定义的,这个网页说的很清楚(http://en.cppreference.com/w/cpp/language/copy_elision)。其中的Notes一段话非常重要,我贴到这里:
        Notes
        Copy elision is the only allowed form of optimization that can change the observable side-effects. Because some compilers do not perform copy elision in every situation where it is allowed, programs that rely on the side-effects of copy/move constructors and destructors are not portable.
        Even when copy elision takes place and the copy-/move-constructor is not called, it must be present and accessible, otherwise the program is ill-formed.
       也就是说,编译器即使知道ctor/copy ctor/move ctor/dtor有副作用,也会考虑消除拷贝。当然,其他的编译器优化是不能消除副作用的。其他的Copy elision的情况有举例如下。
    (2.3)临时变量不需要被copy:

    点击(此处)折叠或打开

    1. struct My {
    2.     My() {std::cout << "constructed "; }
    3.     My(const My&) { std::cout << "copied "; }
    4.     ~My() {std::cout << "destructed "; }
    5. };
    6. void f(My m){}
    7. void main()
    8. {
    9.     f(My());
    10. }

         运行结果是:

    点击(此处)折叠或打开

    1. constructed
    2. destructed
    3. Press any key to continue . . .

        看起来,临时变量My()被优化成了一个const My&并传递了进去,当作了f的参数。
    (2.4)再看一个throw的例子:

    点击(此处)折叠或打开

    1. struct My {
    2.     My() {std::cout << "constructed "; }
    3.     My(const My&) { std::cout << "copied "; }
    4.     ~My() {std::cout << "destructed "; }
    5. };
    6. void fm(){throw My();}
    7. void main()
    8. {
    9.     try{
    10.         cout<<"before throw"<<endl;
    11.         fm();
    12.         cout<<"after throw"<<endl;
    13.     }catch(My& m)
    14.     {}
    15. }

        这里的throw My()语句构造的My对象,优化后是构造在try的栈上面而非fm的栈上面,因此没有copy ctor的调用。
    【第三题】以下程序的运行结果是什么?

    点击(此处)折叠或打开

    1. using namespace std;
    2. struct C4
    3. {
    4.     void f(){throw 1;}
    5.     ~C4(){throw 2;}
    6. };
    7. int main(size_t argc, char* argv[])
    8. {
    9.     try
    10.     {
    11.         try
    12.         {
    13.             C4 obj;
    14.             obj.f();
    15.         }catch(int i)
    16.         {
    17.             cout<<i<<endl;
    18.         }
    19.     }catch(int i)
    20.     {
    21.         cout<<i<<endl;
    22.     }
    23.     return 0;
    24. }

        到底是打印1还是打印2还是两个都打印?不要翻书了,这个程序运行起来,什么都不打印,直接崩溃了。用VC2008/VC2012/GCC4.7的Debug/Release都验证过了。原因呢? 和C++编译器的异常传递链条的"实现"有关,展开来解释能有几十页。能答对这道题并说出原因的面试者应该是高级以上水平,可以直接录用,别的都不用看了。
    -----------------------------------------------------------------------------------------------------
        以上几个题目真的会成为面试题吗? 基本不会,面试官能答上来的也寥寥。来个测试,
        填空: 用VC2008/VC2012/GCC4.7编译下面的代码Release版:
          那么在main函数中,My的4个函数分别被调用了多少次?
            My::My()调用了___次
            My::My(const My&)调用了___次
            My& My::operator(const My&)调用了___次
            My::~My()调用了___次

    点击(此处)折叠或打开

    1. #include<iostream>
    2. using namespace std;
    3. class My{
    4. public:
    5.     My() {cout<<"ctor"<<endl;}
    6.     My(const My&){cout<<"copy ctor"<<endl;}
    7.     My& operator=(const My&){
    8.         cout<<"operator="<<endl;
    9.         return *this;
    10.     }
    11.     ~My(){cout<<"dtor"<<endl;}
    12. };
    13. My f1(){
    14.     My obj;
    15.     return obj;
    16. }
    17. My f2(){return My();}
    18. int main(void){
    19.     My obj1;
    20.     My obj2=obj1;
    21.     My obj3=f1();
    22.     My obj4=f2();
    23.     return 0;
    24. }

        答案是3,1,0,4。你答对了吗?
    【第四题】下面这个指针的声明,const的意义是(A)指针指向的内容不能变,还是(B)指针本身不能变

    点击(此处)折叠或打开

    1. char const* p="abc";

        非常不幸。一群人都选了(B)。用编译器调试,可以发现,p的声明被编译器改成了const char*。网上有很多人说,const修饰谁就看const离谁近,例如char* const q就是说明q本身不能变,const char* r就说明r指向的内容char*不能变。但是char const* p呢? 这个const到底修饰char还是*p? 实际上所谓"离谁近就修饰谁"这个说法不准确,只有const直接跟一个变量名,中间没有其他任何符号(除了空格)的时候,const才是修饰变量名本身的。
       OK,再看下面这两种声明,const修饰谁?

    点击(此处)折叠或打开

    1. const (char)* s="abc";
    2. (char) const *t="abc";

        不纠结,上面两行在VC/GCC下面都是编译不过的。
        好了,有了前面4道题的讨论基础,做个小测验:构造函数,用初始化列表和不用初始化列表有什么区别? 写出以下代码的输出:

    点击(此处)折叠或打开

    1. class My
    2. {
    3. public:
    4.     int i;
    5.     My(){i=22;}
    6.     virtual void f(){printf("f:%d ",i);}
    7.     virtual void g(){printf("g:%d ",++i);}
    8.     virtual void h(){printf("h ");}
    9. };
    10. typedef void (__thiscall *pMy)(My*);
    11. typedef pMy* VTable;
    12. int main(int argc, char* argv[])
    13. {
    14.     My pf;
    15.     VTable pVtable=*(VTable*)(&pf);
    16.     pVtable[0](&pf);
    17.     pVtable[1](&pf);
    18.     pVtable[2](&pf);
    19.     return 0;
    20. }

        考察的要点:初始化列表使用copy ctor,而不用初始化列表,就相当于ctor + operator=。我相信你已经答对了。

     上回出了几道有挑战的题,当然那些不会真的做面试题的,让一大半人都挂的题目是没有出的必要的。C++是一个语言标准,不是一个实现标准,语言标准只规定了源代码长什么样合法,没有规定看到想到的和编译出来的东西就一定一样。例如,一个类有virtual关键字修饰的函数,那么就会有这个类就会有虚函数表吗? 不一定啊,因为C++标准压根就没有规定要如何实现虚函数!所谓的虚函数表只是一种流行的,实现虚函数的方式而已。
        C++是马,而某个具体的C++编译器实现是"白马"。白马非马也!有了上一篇文章的基础,我们继续讨论和构造/析构/赋值相关的话题。
    【第一题】用VC2008/VC2012/GCC4.7编译下面的代码Release版,输出多少?

    点击(此处)折叠或打开

    1. #include<iostream>
    2. using namespace std;
    3. static int i=0;
    4. class My{
    5. public:
    6.     My() {i+=1;}
    7.     My(const My&){i+=2;}
    8.     My& operator=(const My&){
    9.         i+=3;
    10.         return *this;
    11.     }
    12. };
    13. class Derived: public My
    14. {
    15. public:
    16.     Derived(){}
    17.     Derived(const Derived&d){}
    18. };
    19. int main(int argc, char* argv[])
    20. {
    21.     Derived d;
    22.     Derived d2(d);
    23.     cout<<i<<endl;
    24.     return 0;
    25. }


        这题的关键是Derived d2(d)这句话,继承类的拷贝构造函数,会调用基类的哪个构造函数呢? 没有显示指定初始化列表,那就是调用基类的默认构造函数,因此本题的答案是2。
    【第二题】用VC2008/VC2012/GCC4.7编译下面的代码Release版,输出多少?

    点击(此处)折叠或打开

    1. #include<iostream>
    2. using namespace std;
    3. static int i=0;
    4. class My{
    5. public:
    6.       My() {f();}
    7.     virtual void f(){i+=1;}
    8. };
    9. class Derived: public My
    10. {
    11. public:
    12.     Derived(){}
    13.     virtual void f(){i+=2;}
    14. };
    15. int main(void)
    16. {
    17.     My* pD=new Derived();
    18.     cout<<i<<endl;
    19.     delete pD;
    20.     return 0;
    21. }

        这道题的关键是,基类构造函数里面调用了一个虚函数f,那么实际是调的基类的f还是继承累的f呢?<>这本书的条款"Nevel call virtual functions during construction or destruction"有很好的说明,但是书上举例还是不够充分,解释的也不算清楚。因为:
        C++的"类"和"对象"只是语言级的概念,C++标准根本就没有规定编译的结果里面也存在对象,这样就能给编译器和优化器以无穷的空间----反过来说,我们不能假设对象真的有物理存在,因为构造函数有可能被内联,甚至release版连对象都优化得没有了,"多态"这个概念也是可以被编译器优化掉的。因此ctor/dtor要调用类内部的虚函数而根本把所谓多态置之脑后。
        所以,C++在ctor/dtor当中遇到虚函数调用的时候,直接当成非虚函数调用类内部的版本。这道题调用的是My::f(),输出是1。如果允许在基类构造期间调用继承类的函数,那么该函数需要访问继承类的成员例如指针,可此时继承类还没有构造,指针错误,崩溃了。
    【第三题】用VC2008/VC2012/GCC4.7编译下面的代码Release版,输出多少?【第二题】用VC2008/VC2012/GCC4.7编译下面的代码Release版,输出多少?【第二题】用VC2008/VC2012/GCC4.7编译下面的代码Release版,输出多少?【第三题】以下程序的运行结果是什么:

    点击(此处)折叠或打开

    1. class My
    2. {
    3.     My* pSelf;
    4. public:
    5.     My(){pSelf=this;}
    6.     ~My(){delete pSelf;}
    7. };
    8. int main(void)
    9. {
    10.     My m;
    11.     return 0;
    12. }

        析构函数无限递归,堆栈溢出崩溃。
    【第四题】以下代码有什么问题?
    用VC2008/VC2012/GCC4.7编译下面的代码,会有什么问题?用VC2008/VC2012/GCC4.7编译下面的代码,会有什么问题?用VC2008/VC2012/GCC4.7编译下面的代码,会有什么问题?用VC2008/VC2012/GCC4.7编译下面的代码,会有什么问题?

    点击(此处)折叠或打开

    1. #include<iostream>
    2. using namespace std;
    3. class My{
    4. public:
    5.     virtual void f()=0;
    6.     void haha(){f();}
    7.     virtual ~My(){
    8.      cout<<__FUNCTION__<<endl;
    9.      f();
    10.     }
    11. };
    12. class You:public My{
    13. public:
    14.     void f(){cout<<__FUNCTION__<<endl;}
    15.     ~You(){
    16.      cout<<__FUNCTION__<<endl;
    17.      haha();
    18.     }
    19. };
    20. int main(){
    21.     My *p=new You;
    22.     delete p;
    23.     return 0;
    24. }

        不要着急说,调用一个haha()调用一个不存在的虚函数导致空指针错误。因为上面的代码根本编译不过。因为编译器让析构函数~My()调用本累的f(),而本类的f()是纯虚的,没有实现体,因此提示undefined reference to 'My::f()'。把上面的代码改一改,就能编过了:

    点击(此处)折叠或打开

    1. #include<iostream>
    2. using namespace std;
    3. class My
    4. {
    5. public:
    6.      virtual void f() = 0;
    7.      void haha() { f(); }
    8.      virtual ~My(){
    9.          cout<<__FUNCTION__<<endl;
    10.          haha();
    11.      }
    12. };
    13. class You: public My
    14. {
    15. public:
    16.      void f(){cout<<__FUNCTION__<<endl;}
    17.      ~You(){
    18.          cout<<__FUNCTION__<<endl;
    19.          haha();
    20.      }
    21. };
    22. int main(int argc, char* argv[])
    23. {
    24.      My* pm=new You();
    25.      delete pm;
    26.      return 0;
    27. }

        此时~My调用了一个非纯虚的函数haha,没有问题,而haha里面去调用f。运行到delete pm的时候,~My()->haha调用了My::f(),这是虚函数调用,指向一个纯虚(空指针),因此崩溃(pure virtual function call)。如果我把My* pm=new You()改成You* py=new You()会让这个错误错误消失吗? 不会,因为析构函数先~You析构继承类的部分,然后进入~My。这个~My调用的时候,继承类的部分已经不存在了,因此此时虚函数的调用路径回到了基类,程序还是崩溃了。
    -----------------------------------------------------------------
        虚拟机语言如C#/Java对象生命周期是GC全局管理,因此不存在这样的陷阱,多态的基类引用在ctor里面调用虚函数,是调进继承类。一下两段代码都是输出两个"Derived"。

    点击(此处)折叠或打开

    1. class Base
    2.     {
    3.         public Base() { f(); }
    4.         public virtual void f() { Console.WriteLine("Base"); }
    5.     }
    6.     class Derived : Base
    7.     {
    8.         public Derived() { f(); }
    9.         public override void f() { Console.WriteLine("Derived"); }
    10.     }
    11.     [STAThread]
    12.     static void Main(string[] args)
    13.     {
    14.         Base pb = new Derived();
    15.     }

    点击(此处)折叠或打开

    1. public class JavaApplication1 {
    2.     /**
    3.      * @param args the command line arguments
    4.      */
    5.     static public class Base
    6.     {
    7.         public Base() { f(); }
    8.         public void f() { System.out.println("Base"); }
    9.     }
    10.     static public class Derived extends Base
    11.     {
    12.         public Derived() { f(); }
    13.         public void f() { System.out.println("Derived"); }
    14.     }
    15.     public static void main(String[] args) {
    16.         // TODO code application logic here
    17.         JavaApplication1.Base pb = new JavaApplication1.Derived();
    18.     }
    19. }
  • 相关阅读:
    省考失败总结
    Oracle基本介绍及用户的管理2
    Linux 阿里云CentOS7.6 安装 redis6.2.1 及使用客户端工具连接
    阿里云centOS7.6安装配置MySQL8.0
    ORA-01078: failure in processing system parameters LRM-00109: could not open parameter file 解决过程
    Vue SSM搭建一个简单的Demo前后端分离含增删改查(CRUD)、分页、批量功能
    Mybatis (ParameterType) 如何传递多个不同类型的参数
    eclipse的一些常用快捷键
    IntelliJ IDEA常用快捷键总结
    安装vue错误详情解决办法
  • 原文地址:https://www.cnblogs.com/virusolf/p/4905731.html
Copyright © 2011-2022 走看看