面经:C++面试精品问答题总结(一)

2023-10-19 10:55:47 浏览数 (1)

前言

这里收集市面上所有的关于算法和开发岗最容易遇到的关于C 方面的问题,问题信息来自互联网以及牛客网的C 面试题目汇总。答题总结的顺序大体是按照问题出现的频率进行排序的,也有自己被面试问到的问题,越在前面的问题再面试中越容易被问到,作为笔记。当然,这些C 概念适合所有人,并非是准备面试或者正在面试的童鞋,如果想对C 多了解一些或者想避免一些C 常见错误的,可以建议看一看本系列文章的内容。

不论是算法岗还是开发岗,对于C 的要求还是比较高的,我们需要对C 的使用有着比较深入的理解。当然有一些知识不论是C 亦或是其他语言我们都需要明白一些编程语言的基本原理(例如封装、多态等)。还有很多计算机系统或者操作系统等其他我们需要知道的知识。

1 static关键字的作用

static这个关键字的用法其实有很多,不论是普通的变量,还是成员变量、成员函数等,在不同的场景下用法也不同。一般有三个完全不同的含义:用在全局变量,表明这个变量在每个编译单元有独自的实例:

代码语言:javascript复制
// foo.h
static int a = 123;
// foo.cpp
#include "foo.h"
int foo_func() { return a  ; }
// bar.cpp
#include "foo.h"
int bar_func() { return a  ; }

如果你分别编译foo.cpp和bar.cpp,再把它们链接在一起,全局变量a会有两份,那两个函数会操纵不一样的a实例。

用在函数里的局部变量,表明它的生存周期其实是全局变量,但仅在函数内可见:int get_global_id()

代码语言:javascript复制
{
    static int seed = 0;
    return seed  ;
}

每次访问这个函数的时候,会获得不同的int值。那个=0的操作仅在第一次访问时执行,其实是初始化而不是赋值。

用在类成员,表明成员或者方法是类的,而不是对象实例的。

代码语言:javascript复制
struct Foo
{
    int a = 0;
    static int aaa = 0;
    static int bbb() { return 123456; }
};

每个Foo实例会只含有一个int a。bbb方法通过Foo::bbb()调用。

上述的描述来源于:https://www.zhihu.com/question/29307292/answer/68695290

2 C 中四种cast转换(或者说是显式转换)

类型转换这个行为一旦被忽略就是发生不可描述的错误,小则一个小Bug,重则一个大型的车祸现象,我们平时一定要对转型的行为进行重点关注,每次转型的时候必须要明白这个转型是否正确,当然平时我们如果使用比较好的IDE(例如CLion)进行编写代码的时候,其静态代码分析工具会帮你找到转型可能会发生的问题,并提示你该行为可能造成的后果。

下面要介绍的四种cast转换类型都是显式转化类型,这时的类型转化是显式的,是我们提前知道的。

static_cast

static_cast这个指针比较常用,用于在编译器阶段直接告诉编译器,你的类型转化是在已知的情况下进行的,不需要编译器为了这个向你提出警告了。

代码语言:javascript复制
/*static_cast 告诉编译器 这样的类型转换是已知的,不需要警告*/
union U { int a; double b; } u;
void* x = &u;                        // x 指向u指针
double* y = static_cast<double*>(x); // 此时y的值是u.b
char* z = static_cast<char*>(x);     // z的值是u

const_cast

const_cast用于移除类型的const、volatile和__unaligned属性。常量指针被转换成非常量指针,并且仍然指向原来的对象;常量引用被转换成非常量引用,并且仍然引用原来的对象。

代码语言:javascript复制
const char *pc;
char *p = const_cast<char*>(pc);

dynamic_cast

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

reinterpret_cast

非常激进的指针类型转换,在编译期完成,可以转换任何类型的指针,所以极不安全。非极端情况不要使用。

代码语言:javascript复制
int *ip;
char *pc = reinterpret_cast<char*>(ip);

四种cast在Pytorch-1.0源码中的出现频率为 static_cast > reinterpret_cast > const_cast > dynamic_cast。

最终还是建议大家看《C Primer》,在这本书的145页较为详细地介绍了显式类型转换的知识,但是这里也提到,强烈建议程序员避免使用强制类型转换,对于这个我们还是能谨慎就谨慎些。

3 C 中指针和引用的区别

这个在知乎上有比较好的回答:

C primer中对对象的定义:对象是指一块能存储数据并具有某种类型的内存空间一个对象a,它有值和地址&a,运行程序时,计算机会为该对象分配存储空间,来存储该对象的值,我们通过该对象的地址,来访问存储空间中的值。

指针p也是对象,它同样有地址&p和存储的值p,只不过,p存储的数据类型是数据的地址。如果我们要以p中存储的数据为地址,来访问对象的值,则要在p前加解引用操作符”“,即p。

对象有常量(const)和变量之分,既然指针本身是对象,那么指针所存储的地址也有常量和变量之分,常量指针是指,指针这个对象所存储的地址是不可以改变的,而指向常量的指针的意思是,不能通过该指针来改变这个指针所指向的对象。

我们可以把引用理解成变量的别名。定义一个引用的时候,程序把该引用和它的初始值绑定在一起,而不是拷贝它。计算机必须在声明r的同时就要对它初始化,并且,r一经声明,就不可以再和其它对象绑定在一起了。

实际上,你也可以把引用看做是通过一个常量指针来实现的,它只能绑定到初始化它的对象上。

关于指针和引用的对比,可以参看<<more effective C >>中的第一条条款,引用的一个优点是它一定不为空,因此相对于指针,它不用检查它所指对象是否为空,这增加了效率比如下面的代码

代码语言:javascript复制
int a,b,*p,&r=a;    //正确
r=3;                //正确:等价于a=3
int &rr;            //出错:引用必须初始化
p=&a;               //正确:p中存储a的地址,即p指向a
*p=4;               //正确:p中存的是a的地址,对a所对应的存储空间存入值4
p=&b                //正确:p可以多次赋值,p存储b的地址

相关的链接:https://www.zhihu.com/question/37608201/answer/72766337

4 堆和栈的区别

这个问题很经典,网上也有一篇被转过无数次的文章,这里搬运一下:

一个由C/C 编译的程序占用的内存分为以下几个部分:

  • 栈区(stack):由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。其 操作方式类似于数据结构中的栈。
  • 堆区(heap):一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回 收 。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表,呵呵。
  • 全局区(静态区)(static):全局变量和静态变量的存储是放在一块的,初始化的 全局变量和静态变量在一块区域, 未初始化的全局变量和未初始化的静态变量在相邻的另 一块区域。 – 程序结束后由系统释放。
  • 文字常量区:常量字符串就是放在这里的。 程序结束后由系统释放
  • 程序代码区:存放函数体的二进制代码。
代码语言:javascript复制
#include "stdio.h"

int a = 0;//全局初始化区 

char *p1;//   全局未初始化区    
void main(void)    
{    
  int   b;//   栈    
  char   s[]   =   "abc";//   栈    
  char   *p2;//   栈    
  char   *p3   =   "123456";//   123456/0在常量区,p3在栈上。    
  static   int   c   =0;//   全局(静态)初始化区    
  p1   =   (char   *)malloc(10);    
  p2   =   (char   *)malloc(20);    
  //分配得来得10和20字节的区域就在堆区。    
  strcpy(p1,   "123456");//   123456/0放在常量区,编译器可能会将它与p3所指向的"123456" 优化成一个地方。       
}

堆和栈的理论知识

申请方式

  • stack: 由系统自动分配。例如,声明在函数中一个局部变量int b; 系统自动在栈中为b开辟空间
  • heap: 需要程序员自己申请,并指明大小,在c中malloc函数,如p1=(char *)malloc(10);在C 中用new运算符,如p2 = new char[10]; 但是注意p1、p2本身是在栈中的。

申请后系统的响应

  • 栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢 出。
  • 堆:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时, 会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表 中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的 首地址处记录本次分配的大小,这样,代码中的delete语句才能正确的释放本内存空间。 另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部 分重新放入空闲链表中。

申请大小的限制

  • 栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意 思是栈顶的地址和栈的最大容量是系统预先规定好的,在WINDOWS下,栈的大小是2M(也有 的说是1M,总之是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将 提示overflow。因此,能从栈获得的空间较小。
  • 堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储 的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小 受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。

申请效率的比较

  • 栈由系统自动分配,速度较快。但程序员是无法控制的。
  • 堆是由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便. 另外,在WINDOWS下,最好的方式是用VirtualAlloc分配内存,他不是在堆,也不是在栈是 直接在进程的地址空间中保留一块内存,虽然用起来最不方便。但是速度快,也最灵活。

堆和栈中的存储内容

  • 栈:在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可 执行语句)的地址,然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈 的,然后是函数中的局部变量。注意静态变量是不入栈的。 当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地 址,也就是主函数中的下一条指令,程序由该点继续运行。
  • 堆:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容由程序员安排。

存取效率的比较

代码语言:javascript复制
char  s1[] =  "aaaaaaaaaaaaaaa";     
char  *s2  =  "bbbbbbbbbbbbbbbbb";      

aaaaaaaaaaa是在运行时刻赋值的; 而bbbbbbbbbbb是在编译时就确定的; 但是,在以后的存取中,在栈上的数组比指针所指向的字符串(例如堆)快。

举个例子:

代码语言:javascript复制
#include "stdio.h"   

void main(void)    
{    
  char   a   =   1;    
  char   c[]   =   "1234567890";    
  char   *p   ="1234567890";    
  a   =   c[1];    
  a   =   p[1];    
  return;    
}    

// 对应的汇编代码    
10:   a   =   c[1];    
00401067   8A   4D   F1   mov   cl,byte   ptr   [ebp-0Fh]    
0040106A   88   4D   FC   mov   byte   ptr   [ebp-4],cl    
11:   a   =   p[1];    
0040106D   8B   55   EC   mov   edx,dword   ptr   [ebp-14h]    
00401070   8A   42   01   mov   al,byte   ptr   [edx 1]    
00401073   88   45   FC   mov   byte   ptr   [ebp-4],al 

第一种在读取时直接就把字符串中的元素读到寄存器cl中,而第二种则要先把指针值读到edx中,再根据edx读取字符,显然慢了。

小结:堆和栈的区别可以用如下的比喻来看出: 使用栈就象我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自 由度小。 使用堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由 度大。

来源:https://blog.csdn.net/hairetz/article/details/4141043

5 什么是虚函数,作用是什么

虚函数的作用核心是在运行期实现多态。

首先强调一个概念:定义一个函数为虚函数,不代表函数为不被实现的函数。

定义他为虚函数是为了允许用基类的指针来调用子类的这个函数。定义一个函数为纯虚函数,才代表函数没有被实现。定义纯虚函数是为了实现一个接口,起到一个规范的作用,规范继承这个类的程序员必须实现这个函数。

1、简介假设我们有下面的类层次:

代码语言:javascript复制
class A  
{  
public:  
    virtual void foo()  
    {  
        cout<<"A::foo() is called"<<endl;  
    }  
};  
class B:public A  
{  
public:  
    void foo()  
    {  
        cout<<"B::foo() is called"<<endl;  
    }  
};  
int main(void)  
{  
    A *a = new B();  
    a->foo();   // 在这里,a虽然是指向A的指针,但是被调用的函数(foo)却是B的!  
    return 0;  
}  

这个例子是虚函数的一个典型应用,通过这个例子,也许你就对虚函数有了一些概念。它虚就虚在所谓“推迟联编”或者“动态联编”上,一个类函数的调用并不是在编译时刻被确定的,而是在运行时刻被确定的。由于编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,所以被成为“虚”函数。

虚函数只能借助于指针或者引用来达到多态的效果。

C 纯虚函数

定义

纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加“=0” virtual void funtion1()=0

引入原因

  • 1、为了方便使用多态特性,我们常常需要在基类中定义虚拟函数。
  • 2、在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。

为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数(方法:virtual ReturnType Function()= 0;),则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。纯虚函数最显著的特征是:它们必须在继承类中重新声明函数(不要后面的=0,否则该派生类也不能实例化),而且它们在抽象类中往往没有定义。定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。纯虚函数的意义,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。

抽象类的介绍抽象类是一种特殊的类,它是为了抽象和设计的目的为建立的,它处于继承层次结构的较上层

  • (1)抽象类的定义: 称带有纯虚函数的类为抽象类。
  • (2)抽象类的作用:抽象类的主要作用是将有关的操作作为结果接口组织在一个继承层次结构中,由它来为派生类提供一个公共的根,派生类将具体实现在其基类中作为接口的操作。所以派生类实际上刻画了一组子类的操作接口的通用语义,这些语义也传给子类,子类可以具体实现这些语义,也可以再将这些语义传给自己的子类。
  • (3)使用抽象类时注意:
    • 抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。
    • 抽象类是不能定义对象的。

总结:

  • 1、纯虚函数声明如下: virtual void funtion1()=0; 纯虚函数一定没有定义,纯虚函数用来规范派生类的行为,即接口。包含纯虚函数的类是抽象类,抽象类不能定义实例,但可以声明指向实现该抽象类的具体类的指针或引用。
  • 2、虚函数声明如下:virtual ReturnType FunctionName(Parameter);虚函数必须实现,如果不实现,编译器将报错,错误提示为:error LNK****: unresolved external symbol “public: virtual void __thiscall ClassName::virtualFunctionName(void)”
  • 3、对于虚函数来说,父类和子类都有各自的版本。由多态方式调用的时候动态绑定。
  • 4、实现了纯虚函数的子类,该纯虚函数在子类中就编程了虚函数,子类的子类即孙子类可以覆盖该虚函数,由多态方式调用的时候动态绑定。
  • 5、虚函数是C 中用于实现多态(polymorphism)的机制。核心理念就是通过基类访问派生类定义的函数。
  • 6、在有动态分配堆上内存的时候,析构函数必须是虚函数,但没有必要是纯虚的。
  • 7、友元不是成员函数,只有成员函数才可以是虚拟的,因此友元不能是虚拟函数。但可以通过让友元函数调用虚拟成员函数来解决友元的虚拟问题。
  • 8、析构函数应当是虚函数,将调用相应对象类型的析构函数,因此,如果指针指向的是子类对象,将调用子类的析构函数,然后自动调用基类的析构函数。有纯虚函数的类是抽象类,不能生成对象,只能派生。他派生的类的纯虚函数没有被改写,那么,它的派生类还是个抽象类。定义纯虚函数就是为了让基类不可实例化化因为实例化这样的抽象数据结构本身并没有意义。或者给出实现也没有意义。

实际上我个人认为纯虚函数的引入,是出于两个目的

  • 1、为了安全,因为避免任何需要明确但是因为不小心而导致的未知的结果,提醒子类去做应做的实现。
  • 2、为了效率,不是程序执行的效率,而是为了编码的效率。

上述关于虚函数的描述来源于 https://www.zhihu.com/question/23971699/answer/69592611

6 内存泄漏和内存溢出的区别

内存泄漏-memory Leak是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存 的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

很典型的,内存泄漏会导致可用的内存越来越少,最终我们发现没有内存可以使用了。在C 中可以理解为我们使用new分配的内存用完必须释放,在可分配内存远大于泄漏内存时影响不是很大,但是如果反过来的话影响就大大的了。

内存溢出就是内存越界,可以理解为我们使用了我们本不应该使用的内存,或者我们使用的内存的量远远大于我们本可以使用的内存量。有一种很常见的情况是我们调用的数组下标越界了,这个数组使用了本不应该使用的内存地址。另外还有调用栈溢出(stackoverflow),这种情况可以看成是栈内存不足的一种体现,当然内存溢出同时也包含缓冲区溢出(缓冲区也可以理解为我们开辟的数组,但是这个数组使用了它本不应该使用的区域,而这个区域我们可能放着其他一些重要的信息)。

7 c 中的smart pointer-智能指针

为什么要提到这些智能指针,其实说白了这些指针的出现都是因为内存管理一直比较难处理,我们稍微一不小会就会导致内存泄漏、悬挂指针或者访问直接越界等等。毕竟C 是个可以折腾的语言,没有C#后者Java的垃圾回收机制那么好用的功能。

一般来说,为了管理内存等资源,我们一般会采取RAII机制(Resource Acquisition Is Initialization),即资源获取即初始化机制,也就是在类的构造函数里申请资源,然后使用,最终在析构函数中释放资源。

但是如果我们使用new和delete去分配和回收空间的时候,难免会忘记在new之后delete掉之前已经分配的内存,这样就会造成内存泄漏(上一点中的介绍)。为了缓解这个问题,就出现了智能指针,这些智能指针可以方便的管理一个对象的生命期。

智能指针有很多,其中最开始使用的是auto_ptr指针,但是这个指针因为有缺陷已经被废弃。所以在新的C 标准中我们建议使用unique_ptrshared_ptrweak_ptr以及intrusive_ptr,这几个指针都是比较常用的,都是轻量级对象,速度与原始的指针相差无几,都是异常安全的,而且对于所指向的类型T只有一个要求,类型T的析构函数不能抛出异常(但是在实际工程的时候,在嵌入式等cpu比较弱的平台使用这些智能指针需要好好考虑一下,另外如果你不懂得如何使用这些智能指针,那就别使用它们)。

scoped_ptr

其实还有一个叫做scoped_ptr的智能指针,只不过在实际项目中见的比较少。这里简单说说,它是比较简单的一种智能指针,正如其名字所述,scoped_ptr所指向的对象在作用域之外会自动得到析构,scoped_ptr是non-copyable的,也就是说你不能去尝试复制一个scoped_ptr的内容到另外一个scoped_ptr中,这也是为了防止错误的多次析构同一个指针所指向的对象,也就是说scoped_ptr的所有权很严格,不能转让,一旦scoped_ptr获取了对象的管理权,我们就无法再从它那里取回来。

unique_ptr

unique_ptr指针一般用于取代曾经的auto_ptr,其基本功能与scoped_ptr相当,同样可以在作用域内管理指针,也不允许拷贝构造和赋值。但是unique_ptr要比scoped_ptr有更多的功能:可以像原始指针一样进行比较,也可以像shared_ptr定制删除器,也可以安全地放入标准容器。我们完全可以用unique_ptr去代替scoped_ptr

shared_ptr

这个指针是很常见的智能指针,有时候我们提到智能指针其实就是说这个指针。基本上很多大型项目都会用到这个指针(例如Pytorch、TVM)。同时这个指针也在C 11标准里头,其实现的是引用计数型的智能指针,可以被自由地拷贝和赋值,随便共享,当没有代码使用它(引用计数为0)的时候才会删除被包装的动态分配的对象。看到这里可以发现,这个思想已经被很多对象使用过了,很多大型项目中的类会自动添加引用机制,或者python语言中某个对象在没有计数为0的时候也会进行垃圾回收。

weak_ptr

weak_ptr通常不会单独使用,是为了配合shared_ptr而引入的一种智能指针,它不具有普通指针的行为,没有重载operator*和->。它的大作用在于协助shared_ptr工作,像旁观者那样观测资源的使用情况。也就是说这个指针通常用于从shared_ptr或者另一个weak_ptr指针对象中构造,获得资源的观测权。但是其没有共享指针,它的构造函数不会引起指针引用计数的增加。同样,weak_ptr析构时也不会导致引用计数减少,它只是一个观察者。

intrusive_ptr

其名字和自身实现的功能其实并没有强关系,我们不要因为名字而以为它一定要修改代理对象的内部数据。这个指针也是一种引用计数型的智能指针,但是与shared_ptr不同,它比较适合现存代码已经有了引用计数机制管理的对象,也就是它可以包装已有对象从而得到与shared_ptr类似的智能指针。

在Pytorch的源码中,上述指针使用的频率为shared_ptr > intrusive_ptr > unique_ptr >> weak_ptr

这里只对这些指针进行了基本的功能描述,如果想要好好学会怎么使用这些指针可能需要大量的篇幅才能说清楚,这里就不进行描述了,大家可以自行查阅相关的书籍,这里推荐一本《Boost程序库完全开发指南》,当做文档看就可以。

8 数组和指针的区别

同样也是很经典的问题,在我们平常的认知中,数组头相当于一个不可变动的指针,但具体是怎么样的,又需要我们去好好琢磨一下:

数组:连续存储的N个相同类型的变量,用变量类型和数组长度来区分数组类型; 指针:某个变量的内存地址,用变量类型来区分指针类型。

举个例子,对数组取地址时,数组名不会被解释为其地址。等等,数组名难道不被解释为数组的地址吗?不完全如此:数组名被解释为其第一个元素的地址,而对数组名应用地址运算符(即&)时,得到的是整个数组的地址:

代码语言:javascript复制
int test[5] = {1,2,3,4,5};  // 声明一个长度为20字节的数组(int型变量大小为4字节)
cout << test << endl;       // 显示&test[0]
cout << &test << endl;      // 显示整个数组的地址

可以看到打印结果两个地址是相同的,但是实际上这两个地址代表的信息还是不一样的。

代码语言:javascript复制
0x7ffd0538aa70
0x7ffd0538aa70

从数字上说,这两个地址相同;但从概念上说,&test[0](即test)是一个4字节内存块的地址,而&test是一个20字节内存块的地址。因此,表达式test 1将地址值加4,而表达式&test 1将地址值加20。换句话说,test是一个int指针(int*),而&test是一个指向包含5个元素的test数组的指针(int(*) [5])。

那我们可能会问,前面有关&test的类型描述是如何来的呢?我们可以这样声明和初始化这种指针:

代码语言:javascript复制
int (*pas) [5] = &test; //pas指向一个有5个int元素的数组

如果省略括号,优先级规则将使pas先与[5]结合,导致pas是一个包含5个int型指针的数组,因此括号是必不可少的。其次,如果要描述变量的类型,可将声明中的变量名删除。因此,pas的类型为int(*) [5]。另外,由于pas被设置为test,因此*pastest等价,所以(*pas) [0]为test数组的第一个元素。

那么此时int型变量的大小为4字节时,test是一个4字节内存块的地址,而&test是一个20字节内存块的地址。虽然这两个内存块的起始位置相同,但是大小不同。

我们打印一下地址变动来观察一下:

代码语言:javascript复制
cout << test   1 << endl;
cout << &test   1 << endl;

可以发现打印的结果为:

代码语言:javascript复制
0x7ffd0538aa74  // 与之前0x7ffd0538aa70多了4个字节
0x7ffd0538aa84  // 与之前0x7ffd0538aa70多了20个字节(16进制表示法)

但是我们要注意一点,数组名并不是常量指针,首先我们要明白:

  • 数组的类型是type[size],和常量指针类型type* const不同
  • sizeof(数组名)等于数组所有元素的大小,而不是sizeof(指针);对数组取地址,得到的指针进行加减,增减字节数是sizeof(数组);你可以用字符串字面量初始化一个字符数组,但是不能用常量指针来初始化一个字符数组。

我们sizeof一下之前的test数组:

代码语言:javascript复制
cout << sizeof(test) << endl;

可以发现这个输出的长度为20,但是如果我们在平常的时候使用这个test的时候,这个test会被隐式转化为首元素指针右值,上述那个test 1的操作,这个时候这个数组名称已经转化为了首元素的指针右值,然后加上一,自然就是这个元素(4个单位长度)再加上一个单位长度的地址。

而我们在这个数组名称右面使用[]进行取址操作的时候,那么数组会被隐式转换为首元素指针右值,然后对这个值进行解引用。也就是下面这个操作:

代码语言:javascript复制
cout << test[1] << endl;
cout << *(test   1) << endl;   // 作为对比

可以发现上面这两个的输出是一致的,地址的偏移单位都是4个字节。

最后放一段代码:

代码语言:javascript复制
typedef char(*AP)[5];
AP foo(char* p) {
    for (int i = 0; i < 3; i  ) {
        p[strlen(p)] = 'A';
    }
    return (AP)p 1;
}
int main() {
    char s[] = "FROGSEALLIONLAMB";
    puts(foo(s)[1]   2);
}

大家觉得这段代码应该输出什么呢(ONALAMB)?

这里推荐一本讲解指针比较好的书籍《C和指针》,更多关于指针的详细操作和解释可以查看这本书的相关部分。

9 关于赋值操作与自我赋值

赋值操作相关的问题在《剑指offer》和《efficient C 》中都出现过,具体在《剑指offer的》面试题1和《efficient C 》中的条款10和11中。我们如果要写赋值代码需要注意几点:

  • 赋值操作的返回值应该是一个引用,也就是operator=返回一个reference to *this,这样可以保证连续赋值。
  • 传入赋值函数的参数应该声明为常量引用,并且加上const关键字。
  • 赋值操作中自身内存空间的问题,在赋值新的内存空间之后之前的空间是否已经释放,否则会造成空间泄漏。
  • 要留意自我赋值的操作,如果发生了自我赋值,直接返回原本的指针即可。

《剑指offer》中的正确的赋值运算符的代码:

代码语言:javascript复制
CMyString& CMyString::operator=(const CMyString& str)
{
    if(this != &str)   // 如果不等于自己
    {
        CMyString strTemp(str);

        char* pTemp = strTemp.m_pdata;
        strTemp.m_pdata = m_pdata;
        m_pdata = pTemp;
    }
    return *this;
}

10 代码的质量与完整性

这个总结不涉及到任何需要动脑子的东西,我们只需要记住即可,但是最主要的还是在实际编写代码的过程中积累经验,久而久之养成正确的习惯为好。我们在平时的过程中要注意一下几个地方:

  • 编程的命名,这个老生常谈,驼峰型还是其他什么型,切记不要单个字母(int a)或者中文来命名(int ceshi)
  • 代码缩进,规范,看的清楚,注释该有的地方有,不该有的地方别写
  • 代码考虑的地方是否全面,比如你的函数的基本功嫩、输入边界值还有非法输入是否能够正确处理
  • 处理错误的方法,采用什么样的形式,返回值还是异常还是log之类的,总之方便调试即可

以上这些希望我们都可以在平时的时候注意,多学习别人的代码,慢慢积累一些经验。

关于更多代码的规范,可以参考谷歌的C 编程规范,这也是世界上最好的C 编程规范之一。

未完待续

因为每个问题的内容都比较多,所以这里不会一下列举完,每个问题都值得我们去思考和品位,这些总结会一直更新。

0 人点赞