面试之CPP基础知识-2


#struct和class的区别

相同点

  • 两者都拥有成员函数、公有和私有部分
  • 任何可以使用struct完成的工作,同样可以使用class完成

不同点

  • struct默认访问权限是public,class默认是private
  • struct默认继承权限是public,class默认是private

引申:C++和C的struct区别

  • C语言中:struct是用户自定义数据类型(UDT),只能是变量的集合体;C++中struct是抽象数据类型(ADT),是类的一种特例

  • 一个结构标记声明后,在C中必须在结构标记前加上struct,才能做结构类型名(除:typedef struct class{};);C++中结构体标记(结构体名)可以直接作为结构体类型名使用

#const和static的作用

static

  • 不考虑类的情况
    • 隐藏。所有不加static的全局变量和函数具有全局可见性,可以在其他文件中使用,加了之后只能在该文件中使用
    • 默认初始化为0,包括未初始化的全局静态变量与局部静态变量,都存在全局未初始化区
    • 静态变量在函数内定义,始终存在,且只进行一次初始化,具有记忆性,其作用范围与局部变量相同,函数退出后仍然存在,但不能使用
  • 考虑类的情况
    • static成员变量:不能在类声明中初始化,必须在类外初始化,(初始化时不需要标示为static,只有static才能在类外初始化);可以不创建对象直接使用,并且所有对象共享该变量。
    • static成员函数:不具有this指针,只能访问static修饰的变量和函数;不能被声明为const、volatile和虚函数
    • static修饰的成员可以被非static成员函数任意访问

const

修饰的东西不能被修改

  • 不考虑类的情况

    • const常量在定义时必须初始化,之后无法更改
      • 修饰变量,说明该变量不可以被改变;
      • 修饰指针,分为指针常量(pointer to const)和常量指针(const pointer);
      • 修饰引用,指向常量的引用(reference to const),用于形参类型,即避免了拷贝,又避免了函数对值的修改;
    • const也能隐藏变量(再加extern就不能了)(vscode编译多个文件
  • 考虑类的情况

    • const成员变量:最好通过构造函数初始化列表进行初始化,且初始化后不能修改。(不同类对象对其const数据成员的值可以不同,所以不建议在类中声明时初始化)
    • const成员函数:不可以改变非mutable数据的值。(const对象不可以调用非const成员函数;非const对象都可以调用;)

#顶层const和底层const

概念区分

  • 顶层const:指的是const修饰的变量本身是一个常量,无法修改,指的是指针,就是 * 号的右边
  • 底层const:指的是const修饰的变量所指向的对象是一个常量,指的是所指变量,就是 * 号的左边

举个例子

1
2
3
4
5
int a = 10;int* const b1 = &a;        //顶层const,b1本身是一个常量
const int* b2 = &a;       //底层const,b2本身可变,所指的对象是常量
const int b3 = 20; 		   //顶层const,b3是常量不可变
const int* const b4 = &a;  //前一个const为底层,后一个为顶层,b4不可变
const int& b5 = a;		   //用于声明引用变量,都是底层const

区分作用

#数组名和指针区别?

  • 二者均可通过增减偏移量来访问数组中的元素。

  • 数组名不是真正意义上的指针,可以理解为常指针,所以数组名没有自增、自减等操作。

  • 当数组名当做形参传递给调用函数后,就失去了原有特性,退化成一般指针,多了自增、自减操作,但sizeof运算符不能再得到原数组的大小了。

  • 区别在于sizeof和&

  • 假设一个数组int a[4];

  • sizeof(数组)得到16,sizeof(指针)得到8

  • 对数组取地址必须用指向长度为4的数组的指针接受,而不是指向指针的指针。

  • c中,数组名跟指针有区别吗?

#final和override关键字

override

override指定了子类的这个函数是重写父类的虚函数,如果你名字不小心打错了的话,编译器是不会编译通过的。

final

当不希望某个类被继承,或不希望某个虚函数被重写,可以在类名和虚函数后添加final关键字,添加final关键字后被继承或重写,编译器会报错。

#拷贝初始化和直接初始化

  • 当用于类的对象时,初始化的拷贝形式和直接形式有所不同:直接初始化直接调用与实参匹配的构造函数,拷贝初始化总是调用拷贝构造函数。拷贝初始化首先使用指定构造函数创建一个临时对象,然后用拷贝构造函数将那个临时对象拷贝到正在创建的对象。举例如下
1
2
3
4
string str1("I am a string");//语句1 直接初始化
string str2(str1);//语句2 直接初始化,str1是已经存在的对象,直接调用拷贝构造函数对str2进行初始化
string str3 = "I am a string";//语句3 拷贝初始化,先为字符串”I am a string“创建临时对象,再把临时对象作为参数,使用拷贝构造函数构造str3
string str4 = str1;//语句4 拷贝初始化,这里相当于隐式调用拷贝构造函数,而不是调用赋值运算符函数
  • 为了提高效率,允许编译器跳过创建临时对象这一步,直接调用构造函数构造要创建的对象,这样就完全等价于直接初始化了(语句1和语句3等价),但是需要辨别两种情况。
    • 当拷贝构造函数为private时:语句3和语句4在编译时会报错
    • 使用explicit修饰构造函数时:如果构造函数存在隐式转换,编译时会报错

#初始化和赋值的区别

AKA拷贝初始化和直接初始化

#extern"C"的用法

为了能够正确的在C++代码中调用C语言的代码:在程序中加上extern "C"后,相当于告诉编译器这部分代码是C语言写的,因此要按照C语言进行编译,而不是C++;

哪些情况下使用extern "C":

(1)C++代码中调用C语言代码;

(2)在C++中的头文件中使用;

(3)在多个人协同开发时,可能有人擅长C语言,而有人擅长C++;

总结出如下形式:

(1)C++调用C函数:

1
2
3
4
5
6
7
8
9
//xx.h
extern int add(){};
//xx.c
int add(){ }

//xx.cpp
extern "C" {
    #include "xx.h"
}

(2)C调用C++函数

1
2
3
4
5
6
7
8
9
//xx.h
extern "C"{
    int add();
}
//xx.cpp
int add(){    
}
//xx.c
extern int add();

#野指针和悬空指针

都是是指向无效内存区域(这里的无效指的是"不安全不可控")的指针,访问行为将会导致未定义行为。

  • 野指针
    野指针,指的是没有被初始化过的指针。为了防止出错,对于指针初始化时赋值为 nullptr或者及时初始化。

  • 悬空指针
    悬空指针,指针最初指向的内存已经被释放了的一种指针。释放内存后及时置空指针或者引入智能指针避免悬空指针的产生。

#C和C++的类型安全

什么是类型安全?

类型安全很大程度上可以等价于内存安全,类型安全的代码不会试图访问自己没被授权的内存区域。

(1)C的类型安全

C只在局部上下文中表现出类型安全,比如试图从一种结构体的指针转换成另一种结构体的指针时,编译器将会报告错误,除非使用显式类型转换。然而,C中相当多的操作是不安全的。以下是两个十分常见的例子:

  • printf格式输出

上述代码中,使用%d控制整型数字的输出,没有问题,但是改成%f时,明显输出错误,再改成%s时,运行直接报segmentation fault错误

  • malloc函数的返回值

malloc是C中进行内存分配的函数,它的返回类型是void*即空类型指针,一旦出现int* pInt=(int*)malloc(100*sizeof(char))就很可能带来一些问题,而这样的转换C并不会提示错误。

(2)C++的类型安全

  • 操作符new返回的指针类型严格与对象匹配,而不是void*

  • C中很多以void*为参数的函数可以改写为C++模板函数,而模板是支持类型检查的;

  • 引入const关键字代替#define constants 10,它是有类型、有作用域的,而#define constants 10只是简单的文本替换

  • 一些#define宏可被改写为inline函数,结合函数的重载,可在类型安全的前提下支持多种类型,当然改写为模板也能保证类型安全

  • C++提供了dynamic_cast关键字,使得转换过程更加安全,因为dynamic_cast比static_cast涉及更多具体的类型检查。

#重载、重写(覆盖)和隐藏的区别

(1)重载(overload)

重载是指在同一范围定义中的同名成员函数才存在重载关系。主要特点是函数名相同,参数类型和数目有所不同,无法重载仅按返回类型区分的函数。重载和函数成员是否是虚函数无关。举个例子:

1
2
3
4
5
6
class A{
    virtual int fun();
    void fun(int);
    void fun(double, double);
    static int fun(char);
}

(2)重写(覆盖)(override)

重写指的是在派生类中覆盖基类中的同名虚函数,且:

  • 与基类的虚函数有相同的参数个数
  • 与基类的虚函数有相同的参数类型
  • 与基类的虚函数有相同的返回值类型

重载与重写的区别:

  • 重写是父类和子类之间的垂直关系,重载是不同函数之间的水平关系
  • 重写要求参数列表相同,重载则要求参数列表不同,返回值不要求
  • 重写关系中,调用方法根据对象类型决定,重载根据调用时实参表与形参表的对应关系来选择函数体

(3)隐藏(hide)

隐藏指的是某些情况下,派生类中的函数屏蔽了基类中的同名函数,包括以下情况:

  • 两个函数参数相同,但是基类函数不是虚函数。**和重写的区别在于基类函数是否是虚函数。**举个例子:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//父类
class A{
public:
    void fun(int a){
		cout << "A中的fun函数" << endl;
	}
};
//子类
// 必须是public继承才能调用父类方法
// 使得继承的方法变成public
class B : public A{  
public:
    //隐藏父类的fun函数
    void fun(int a){
		cout << "B中的fun函数" << endl;
	}
};
int main(){
    B b;
    b.fun(2); //调用B中的fun函数
    b.A::fun(2); //调用A中fun函数
    return 0;
}
  • 两个函数参数不同,无论基类函数是不是虚函数,都会被隐藏。和重载的区别在于两个函数不在同一个类中。举个例子:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
//父类
class A{
public:
    virtual void fun(int a){
		cout << "A中的fun函数" << endl;
	}
};
//子类
class B : public A{
public:
    //隐藏父类的fun函数
   virtual void fun(char* a){
	   cout << "A中的fun函数" << endl;
   }
};
int main(){
    B b;
    b.fun(2); //报错,调用B中的fun函数,参数类型不对
    b.A::fun(2); //调用A中fun函数
    return 0;
}

补充:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 父类
class A {
public:
    virtual void fun(int a) { // 虚函数
        cout << "This is A fun " << a << endl;
    }  
    void add(int a, int b) {
        cout << "This is A add " << a + b << endl;
    }
};

// 子类
class B: public A {
public:
    void fun(int a) override {  // 覆盖
        cout << "this is B fun " << a << endl;
    }
    void add(int a) {   // 隐藏
        cout << "This is B add " << a + a << endl;
    }
};

int main() {
    // 基类指针指向派生类对象时,基类指针可以直接调用到派生类的覆盖函数,也可以通过 :: 调用到基类被覆盖的虚函数;
    // 而基类指针只能调用基类的被隐藏函数,**无法识别派生类中的隐藏函数。**

    A *p = new B();
    p->fun(1);      // 调用子类 fun 覆盖函数
    p->A::fun(1);   // 调用父类 fun
    p->add(1, 2);
    // p->add(1);      // 错误,识别的是 A 类中的 add 函数,参数不匹配
    // p->B::add(1);   // 错误,无法识别子类 add 函数
    return 0;
}

#C++有哪几种的构造函数

C++中的构造函数可以分为4类:

举个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
using namespace std;

class Student{
public:
    Student(){//默认构造函数,没有参数
        this->age = 20;
        this->num = 1000;
    };  
    // 注意n不能赋默认值,否则会跟转换构造函数重复
    Student(int a, int n):age(a), num(n){}; //初始化构造函数,有参数
    Student(const Student& s){//拷贝构造函数
	    if (this == &s) return;
        this->age = s.age;
        this->num = s.num;
    }; 
    Student(int r){  //转换构造函数只有一个形参
        this->age = r;
		this->num = 1002;
    };
    ~Student(){}
public:
    int age;
    int num;
};

int main(){
    Student s1;  // 默认
    Student s2(18,1001);  // 初始化
    Student s3(10);  // 转换
    Student s4(s3);  // 拷贝
    return 0;
}
  • 默认构造函数和初始化构造函数在定义类的对象,完成对象的初始化工作
  • 复制构造函数用于复制本类的对象
  • 转换构造函数用于将其他类型的变量,隐式转换为本类对象,如int to Student

#浅拷贝和深拷贝的区别

浅拷贝

浅拷贝后两个指针指向同一个内存空间,如果原来的指针所指向的资源释放了,那么再释放浅拷贝的指针的资源就会出现错误。可以用shared_ptr解决。

深拷贝

深拷贝后两个指针指向两个内存空间,它不但对指针进行拷贝,而且对指针指向的内容进行拷贝。

#各种访问权限和各种继承权限

总结

一、访问权限

访问权限外部派生类内部
public
protected
private

public、protected、private 的访问权限范围关系:

public > protected > private

二、继承权限

  1. 派生类继承自基类的成员权限有三种状态:public、protected、private,排序为 public > protected > private
  2. 派生类对基类成员的访问权限取决于两点:一、继承方式;二、基类成员在基类中的访问权限
  3. 基类成员在派生类中的访问权限:访问权限 > 继承权限 ? 继承权限 : 访问权限。不会继承private成员
  • public 继承 + private 成员 => 不可见
  • public 继承 + protected 成员 => protected
  • protected 继承 + public 成员 => protected
  • private 继承 + protected 成员 => private
  • private 继承 + public 成员 => private

#如何用代码判断大小端存储?

大端存储:数据的高字节存储在低地址

小端存储:数据的低字节存储在低地址

所以在Socket编程中,往往需要将操作系统所用的小端存储的IP地址转换为大端存储,这样才能进行网络传输

如何在代码中进行判断呢?

方式一:使用强制类型转换-这种法子不错

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <iostream>
using namespace std;
int main()
{
    int a = 0x12345678;
    // 由于int和char的长度不同,借助int型转换成char型,只会留下低地址的部分
    char c = (char)(a);
    if (c == 0x12)
        cout << "big endian" << endl;
    else if(c == 0x78)
        cout << "little endian" << endl;
}

方式二:巧用union联合体

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <iostream>
using namespace std;
//union联合体的重叠式存储,endian联合体占用内存的空间为每个成员字节长度的最大值
union endian
{
    int a;
    char ch;
};
int main()
{
    endian value;
    value.a = 0x12345678;
    //a和ch共用4字节的内存空间
    if (value.ch == 0x12)
        cout << "big endian"<<endl;
    else if (value.ch == 0x78)
        cout << "little endian"<<endl;
}

#volatile、mutable和explicit关键字的用法

(1)volatile

volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改

当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据多线程中被几个任务共享的变量需要定义为volatile类型。

拓展:

  • 可以把一个非volatile int赋给volatile int,但是不能把非volatile对象赋给一个volatile对象。
  • 除了基本类型外,对用户定义类型也可以用volatile类型进行修饰。
  • C++中一个有volatile标识符的类只能访问它接口的子集,一个由类的实现者控制的子集。用户只能用const_cast来获得对类型接口的完全访问。此外,volatile向const一样会从类传递到它的成员。

(2)mutable

C++中,被mutable修饰的变量,将永远处于可变的状态,即使在一个const函数中。

样例一

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class person
{
    int m_A;
    mutable int m_B;//特殊变量 在常函数里值也可以被修改
public:
    void add() const//在函数里不可修改this指针指向的值 常量指针
    {
        m_A = 10;//错误  不可修改值,this已经被修饰为常量指针
        m_B = 20;//正确
    }
};

(3)explicit

  • explicit 关键字只能用于类的构造函数(运行时才会报错)
  • 被explicit修饰的构造函数的类,不能发生相应的隐式类型转换

#什么情况下会调用拷贝构造函数

  • 用类的一个实例化对象去初始化另一个对象的时候
  • 函数的参数是类的对象时(非引用传递)
  • 函数的返回值是函数体内局部对象的类的对象时
  • 区分拷贝赋值运算符

总结就是:即使发生NRV优化的情况下,Linux+ g++的环境是不管值返回方式还是引用方式返回的方式都不会发生拷贝构造函数,而Windows + VS2019在值返回的情况下发生拷贝构造函数,引用返回方式则不发生拷贝构造函数(自行测试)。

在VS2019下进行下述实验:

举个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
using namespace std;
class A
{
public:
    A(){};
    A(const A &a)
    {
        cout << "copy constructor is called" << endl;
    };
    ~A(){};
};

void useClassA(A a) {}

A getClassA() // 此时会发生拷贝构造函数的调用,虽然发生NRV优化,但是依然调用拷贝构造函数
{
    A a;
    return a;
}

// A& getClassA2()//  VS2019下,此时编辑器会进行NRV优化,不调用拷贝构造函数
//{
//	A* a = new A();
//	return *a;
// }

int main()
{
    A a1, a3, a4;
    A a2 = a1;         // 调用拷贝构造函数,对应情况1
    useClassA(a1);     // 调用拷贝构造函数,对应情况2
    a3 = getClassA();  // 发生NRV优化,但是值返回,依然会有拷贝构造函数的调用 情况3
    a4 = getClassA2(); // 发生NRV优化,且引用返回自身,不会调用
    return 0;
}

情况1比较好理解

情况2的实现过程是,调用函数时先根据传入的实参产生临时对象,再用拷贝构造去初始化这个临时对象,在函数中与形参对应,函数调用结束后析构临时对象

情况3在执行return时,理论的执行过程是:产生临时对象,调用拷贝构造函数把返回对象拷贝给临时对象,函数执行完先析构局部变量,再析构临时对象, 依然会调用拷贝构造函数