C/C++八股文

文章详细阐述了C++中的面向对象特性,包括类与对象、构造函数和析构函数的执行时机、继承和派生的原理、多态与虚函数的作用。此外,还介绍了C++中的内存模型、拷贝构造函数、移动构造函数、内存分配以及静态成员和友元函数等核心概念。
摘要由CSDN通过智能技术生成

目录

类和对象

struct和class的区别

  1. C语言中的 struct 只能包含变量,而 C++ 中的 class 除了可以包含变量,还可以包含函数。

为什么会诞生面向对象的编程思想

举一个例子说明:你想写一个计算器的小程序可以完成加减乘除四种运算,可以写四个函数,把这四个函数放在一个源文件中,并提供一个头文件(屏蔽了源码细节,同时也保护了自己的知识产权),别人就可以调用它。
接着,你要写一个操作系统的大型程序,而前面写的计算器是操作系统中的一个小工具,同时会有很多像计算器的小工具,你当然可以一个小工具写一个源文件,但是这样源码文件会有很多,更好地做法是,写一个小工具集合源文件,里面写很多小工具类,这样就可以用一个源文件存放很多小工具。而且一个小工具就是一个类很容易区分,符合人的思维习惯,就是为了方便人思考。

析构函数的执行时机

析构函数在对象被销毁时调用。

  1. 全局对象在内存的全局数据区,程序结束时调用这些对象的析构函数。
  2. 函数内部创建的对象,在栈区,函数执行结束时调用。
  3. new出来的对象在堆区,delete时调用。

初始化 const 成员变量

只能使用初始化列表。当然在类内定义的时候也可以赋初值,但是在编码阶段就指定值并不适用所有情景。
jjj

C++ const对象(常对象)

为什么常对象只能使用常成员函数?
因为非const成员函数可能会修改成员变量的值。(本质:常对象禁止修改它的成员变量)。

Student stu()和Student stu的区别

他们都是调用无参构造函数,无区别。

C++函数编译原理

  1. 函数重命名
    C++中的函数在编译时会根据它所在的命名空间、它所属的类、以及它的参数列表(也叫参数签名)等信息进行重新命名,形成一个新的函数名。
  2. 成员函数最终编译成全局函数,如果成员函数中用到了成员变量该怎么办呢?(成员变量的作用域不是全局),编译成员函数时,会额外添加一个参数(this),把当前对象的地址传进去,以此来让成员函数找到成员变量。(this 实际上是成员函数的一个形参,在调用成员函数时将对象的地址作为实参传递给 this。)

拷贝构造函数、赋值构造函数

对象不存在,且没用别的对象来初始化,就是调用了构造函数;
对象不存在,且用别的对象来初始化,就是拷贝构造函数;
对象存在,用别的对象来给它赋值,就是赋值函数。
初始化对象是指,为对象分配内存后第一次向内存中填充数据,这个过程会调用构造函数。只要创建对象,就会调用构造函数。

  1. 拷贝构造函数只有一个参数,它的类型是当前类的引用,而且一般都是 const 引用。
    1)为什么必须是当前类的引用呢?
    如果拷贝构造函数的参数不是当前类的引用,而是当前类的对象,那么在调用拷贝构造函数时,会将另外一个对象直接传递给形参,这本身就是一次拷贝,会再次调用拷贝构造函数,然后又将一个对象直接传递给了形参,将继续调用拷贝构造函数……这个过程会一直持续下去,没有尽头,陷入死循环。
    2)为什么是 const 引用呢?(不管是const对象还是非const对象都可以用来初始化当前对象)
    拷贝构造函数的目的是用其它对象的数据来初始化当前对象,并没有期望更改其它对象的数据,添加 const 限制后,这个含义更加明确了。
    另外一个原因是,添加 const 限制后,可以将 const 对象和非 const 对象传递给形参了,因为非 const 类型可以转换为 const 类型。如果没有 const 限制,就不能将 const 对象传递给形参,因为 const 类型不能转换为非 const 类型,这就意味着,不能使用 const 对象来初始化当前对象了。

  2. 如果程序员没有显式地定义拷贝构造函数,那么编译器会自动生成一个默认的拷贝构造函数。这个默认的拷贝构造函数很简单,就是使用“老对象”的成员变量对“新对象”的成员变量进行一一赋值。但是,对于深拷贝,默认拷贝构造函数就不够用了

  3. 对于基本类型的数据,我们很少会区分「初始化」和「赋值」这两个概念,即使将它们混淆,也不会出现什么错误。但是对于类,它们的区别就非常重要了,因为初始化时会调用构造函数(以拷贝的方式初始化时会调用拷贝构造函数),而赋值时会调用重载过的赋值运算符。对象被创建后必须立即被初始化。

  4. 这种将对象所持有的其它资源一并拷贝的行为叫做深拷贝,我们必须显式地定义拷贝构造函数才能达到深拷贝的目的。

  5. 如果一个类拥有指针类型的成员变量,那么绝大部分情况下就需要深拷贝,因为只有这样,才能将指针指向的内容再复制出一份来,让原有对象和新生对象相互独立,彼此之间不受影响。如果类的成员变量没有指针,一般浅拷贝足以。
    C++深拷贝和浅拷贝(深复制和浅复制)完全攻略

移动构造函数

  1. 所谓移动语义,指的就是以移动而非深拷贝的方式初始化含有指针成员的类对象。简单的理解,移动语义指的就是将其他对象(通常是临时对象)拥有的内存资源“移为已用”。
  2. 事实上,对于程序执行过程中产生的临时对象,往往只用于传递数据(没有其它的用处),并且会很快会被销毁。因此在使用临时对象初始化新对象时,我们可以将其包含的指针成员指向的内存资源直接移给新对象所有,无需再新拷贝一份,这大大提高了初始化的执行效率。
  3. 在实际开发中,通常在类中自定义移动构造函数的同时,会再为其自定义一个适当的拷贝构造函数,由此当用户利用右值初始化类对象时,会调用移动构造函数;使用左值(非右值)初始化类对象时,会调用拷贝构造函数。
  4. std::move 的作用是无论你传给它的是左值还是右值,通过 std::move 之后都变成了右值。
  5. ++i 是左值,i++ 是右值。
    前者,对 i + 1 后再赋给 i,最终的返回值就是 i,所以,++i 的结果是具名的,名字就是 i;而对于 i++ 而言,是先对 i 进行一次拷贝,将得到的副本作为返回结果,然后再对 i + 1,由于 i++ 的结果是对 i + 1前 i 的一份拷贝,所以它是不具名的。假设自增前i的值是 6,那么,++i 得到的结果是 7,这个 7 有个名字,就是 i ;而 i++ 得到的结果是 6,这个 6 是 i + 1 前的一个副本,它没有名字,i 不是它的名字,i 的值此时也是 7。可见,++i 和 i++ 都达到了使 i + 1的目的,但两个表达式的结果不同。
    参考

new、delete、malloc()、free()

new和delete是C++里的关键字。malloc()和free()是C语言里的函数。

#include<iostream>
#include<stdio.h>
#include<stdlib.h>

int main()
{
    // new
    int *p = new int;
    *p = 5;
    printf("%d\n",*p);
    delete p;

    int *pp = new int[5];
    for (int i=0;i<5;i++)
    {
        pp[i]=i+1;
        printf("%d ",pp[i]);
    }
    delete[] pp;

    // malloc
    // char *pp = (char*)malloc(sizeof(char)*15);
    // pp[0]='h';pp[1]='e';pp[2]='l';pp[3]='l';pp[4]='o';pp[5]=0;
    // printf("%s\n",pp);

    //free
    // free(pp);
    return 0;
}

C++对象的内存模型

类是创建对象的模板,不占用内存空间,不存在于编译后的可执行文件中;而对象是实实在在的数据,需要内存来存储。对象被创建时会在栈区或者堆区分配内存。

编译器会将成员变量和成员函数分开存储:分别为每个对象的成员变量分配内存,但是所有对象都共享同一段函数代码。成员变量在堆区或栈区分配内存,成员函数在代码区分配内存

对象所占用的内存仅仅包含了成员变量。sizeof(对象)
类可以看做是一种复杂的数据类型,也可以使用 sizeof 求得该类型的大小。从运行结果可以看出,在计算类这种类型的大小时,只计算了成员变量的大小,并没有把成员函数也包含在内。

C++继承时的对象内存模型

在派生类的对象模型中,会包含所有基类的成员变量。这种设计方案的优点是访问效率高,能够在派生类对象中直接访问基类变量,无需经过好几层间接计算。

基类的成员变量排在前面,派生类的排在后面。

封闭类——包含成员对象的类

  1. 构造函数执行顺序。
    先执行成员对象的构造函数,再执行封闭类自己的构造函数。
  2. 析构函数的执行顺序。
    先构造的后析构。

为什么要有this指针

用于关联成员函数和成员变量。请见前文C++函数编译原理。

C++ static静态成员变量详解

  1. 静态成员变量可以实现多个对象共享数据的目标。在内存中只有一份。
  2. 类内声明,类外初始化(不加static:int Student::m_total = 0;)。
  3. 内存分配的时机。
    在类外初始化时分配内存,也就是说,没有在类外初始化的静态成员变量不能使用。
  4. 静态成员变量不占用对象的内存,在全局数据区分配内存。

C++ static静态成员函数详解

静态成员函数的形参中不会添加this,因此无法访问非静态成员(非静态成员函数也不行,因为该函数内部可能会访问非静态成员,没有this,根本访问不到)。一般通过类来调用。声明和定义与静态成员变量一样。

C++友元函数和友元类(C++ friend关键字)

理解:你给你的朋友授权(声明友元),他可以进你家(访问你的成员变量)。

C++ class和struct到底有什么区别

  1. 成员的默认访问权限不同。
    class:默认为private;struct:默认为public
  2. 默认的继承方式不同。
    class:默认为private继承;struct:默认为public继承。
  3. class 可以使用模板,而 struct 不能。

C++ string的内部究竟是什么样的?

string可以和C风格字符串混用,这体现了C++兼容C的特点。

  1. C++标准没有定义string的内存布局,由编译器厂商自己实现。
  2. 一种内存分配方式——引用计数。
    对有几个人引用他做一个计数,这样避免了存在同一数据的多个副本。
    copy-on-write策略:当字符串修改的时候才创建各自的拷贝。

借助指针突破访问权限的限制,访问private、protected属性的成员变量

C++ 的成员访问权限仅仅是语法层面上的,是指访问权限仅对取成员运算符.和->起作用,而无法防止直接通过指针来访问。
本节的目的不是为了访问到 private、protected 属性的成员变量,这种“花拳绣腿”没有什么现实的意义,本节主要是让大家明白编译器内部的工作原理,以及指针的灵活运用。

通过指针偏移来访问成员。
参考

C++类成员的访问权限

在类的内部(定义类的代码内部),无论成员被声明为 public、protected 还是 private,都是可以互相访问的,没有访问权限的限制。

在类的外部(定义类的代码之外),只能通过对象访问成员,并且通过对象只能访问 public 属性的成员,不能访问 private、protected 属性的成员。

注意点:下面代码中,operator+ 函数是complex 类的成员函数(在类的内部),A.m_real 是可以访问到的。
在这里插入图片描述

继承与派生

继承

  1. 继承(Inheritance)可以理解为一个类从另一个类获取成员变量和成员函数的过程。例如类 B 继承于类 A,那么 B 就拥有 A 的成员变量和成员函数。
  2. 语法
    class 派生类名:[继承方式] 基类名{
        派生类新增加的成员
    };
    
  3. 继承方式限定了基类成员在派生类中的访问权限,包括 public(公有的)、private(私有的)和 protected(受保护的)。此项是可选项,如果不写,默认为 private(成员变量和成员函数默认也是 private)。
  4. 三种继承方式
    继承方式中的 public、protected、private 是用来指明基类成员在派生类中的最高访问权限的。
    不管继承方式如何,基类中的 private 成员在派生类中始终不能使用(不能在派生类的成员函数中访问或调用)。

注意,我们这里说的是基类的 private 成员不能在派生类中使用,并没有说基类的 private 成员不能被继承。实际上,基类的 private 成员是能够被继承的,并且(成员变量)会占用派生类对象的内存,它只是在派生类中不可见,导致无法使用罢了。private 成员的这种特性,能够很好的对派生类隐藏基类的实现,以体现面向对象的封装性。

由于 private 和 protected 继承方式会改变基类成员在派生类中的访问权限,导致继承关系复杂,所以实际开发中我们一般使用 public

  1. 改变访问权限(应用场景呢???)

使用 using 关键字可以改变基类成员在派生类中的访问权限,例如将 public 改为 private、将 protected 改为 public。
注意:using 只能改变基类中 public 和 protected 成员的访问权限,不能改变 private 成员的访问权限,因为基类中 private 成员在派生类中是不可见的,根本不能使用,所以基类中的 private 成员在派生类中无论如何都不能访问。
参考
实际上,就两个方向:升权限(protected 改为 public)和降权限(public 改为 private)。这样做增加了访问权限的灵活性。

虚继承

虚继承的本质:让某个类做出声明,承诺愿意共享它的基类。
虚继承和普通继承的一个区别:参见3。

1、为了解决多继承时的命名冲突冗余数据问题(例如菱形继承),C++ 提出了虚继承,使得在派生类中只保留一份间接基类(虚基类)的成员。
在继承方式前面加上 virtual 关键字就是虚继承

2、可以看到,使用多继承经常会出现二义性问题,必须十分小心。上面的例子是简单的,如果继承的层次再多一些,关系更复杂一些,程序员就很容易陷人迷魂阵,程序的编写、调试和维护工作都会变得更加困难,因此我不提倡在程序中使用多继承,只有在比较简单和不易出现二义性的情况或实在必要时才使用多继承,能用单一继承解决的问题就不要使用多继承。也正是由于这个原因,C++ 之后的很多面向对象的编程语言,例如 Java、C#、PHP 等,都不支持多继承。

参考
3、C++ 规定必须由最终的派生类 D 来初始化虚基类 A,直接派生类 B 和 C 对 A 的构造函数的调用是无效的。(D只保留一份数据的话,没必要让B和C构造A了,况且 B 和 C 在调用 A 的构造函数时很有可能给出不同的实参,这个时候编译器就会犯迷糊,不知道使用哪个实参初始化 m_a。)参考
4、 注意:如果类A1和类A2都有成员m_a,但他俩并不是继承自同一个类,那么继承C就算虚继承他们,也还是有两份m_a

C++虚继承下的内存模型

对于普通继承,基类成员变量始终在派生类成员变量的前面,而且不管继承层次有多深,它相对于派生类对象顶部的偏移量是固定的。

而对于虚继承,恰恰和普通继承相反,大部分编译器会把基类成员变量放在派生类成员变量的后面,这样随着继承层级的增加,基类成员变量的偏移就会改变,就得通过其他方案来计算偏移量。

虚基类表:本质上就是一个数组,存放各个虚基类子对象的偏移地址。参考
构造函数的调用顺序还是和普通继承一样:先调用父类再调用子类。
内存模型则不同:先放指向虚基类表的指针,再放自己的成员变量,最后放虚基类的子对象。

不管是虚基类的直接派生类还是间接派生类,虚基类的子对象始终位于派生类对象的最后面。
在这里插入图片描述

C++多态与虚函数

C++将派生类赋值给基类(向上转型)

包括:指针的赋值,对象的赋值,引用的赋值。
赋值的本质是将现有的数据写入已分配好的内存中,对象的内存只包含了成员变量,所以对象之间的赋值是成员变量的赋值,成员函数不存在赋值问题。

编译器通过指针来访问成员变量,指针指向哪个对象就使用哪个对象的数据;编译器通过指针的类型来访问成员函数,指针属于哪个类的类型就使用哪个类的函数。

为了使用基类指针访问派生类的成员函数,引入了虚函数

虚函数—实现多态的一个工具

在父类和子类的成员函数(子类重写了父类的成员函数)前加上virtual,就可以实现使用基类指针调用派生类的成员函数
重写:函数的声明相同,实现不同。
可以只将基类中的函数声明为虚函数。

多态

生物学定义:同一物种不同形态的个体。(龙生九子,形态各异)
直观理解:想象一个函数,根据传入的参数不同,执行的逻辑和结果也不同。

为什么会有虚析构函数?

应用场景:当子类的构造函数中new了一段内存空间,当使用父类指针指向子类对象,delete该指针时,只调用了父类的析构函数,而没有调用子类的析构函数,导致内存泄漏
将父类的析构函数申明为虚函数时,就会先调用子类的析构函数,子类的析构函数又会调用父类的析构函数。

C++纯虚函数

语法:virtual 返回值类型 函数名 (函数参数) = 0;
无函数体,只有声明。

抽象类

包含纯虚函数的类称为抽象类。(无法创建对象)

虚函数表

问题:为什么编译器能通过指针指向的对象找到虚函数?
答:因为在创建对象的时候,增加了虚函数表。如果一个类包含有虚函数,那么在创建对象的时候就会额外创建一个数组(虚函数表),数组里的每一个元素都是虚函数的入口地址。但是数组和对象是分开存的,对象中有一个指针指向数组的首地址。

C++ static_cast、dynamic_cast、const_cast和reinterpret_cast

语法xxx_cast<newType>(data)

1、为什么要引入这四种类型转化?

答:C语言中,强制类型转换很简单:(new type)。但是这样做有缺点:语义不明确,不利于代码审查,可读性很差。

const int n = 3;
int *p = (int*)&n;

比如上面两行代码,我们从第二行代码根本不知道发生了什么样的类型转换。因此,C++引入了这四种强制类型转换,增强了代码的可读性。

2、应用场景。

1)static_cast用于相近类型之间的转换,编译器隐式执行的任何类型转换都可用static_cast,但它不能用于两个不相关类型之间转换。
2)const_cast用于删除变量的const属性,转换后就可以对const变量的值进行修改。
3)dynamic_cast用于在类的继承层次之间进行类型转换,它既允许向上转型(Upcasting),也允许向下转型(Downcasting)。向上转型是无条件的,不会进行任何检测,所以都能成功;向下转型的前提必须是安全的,要借助 RTTI 进行检测,所以只有一部分能成功。只能转换指针类型和引用类型参考
4)reinterpret_cast类似C语言中的强制类型转换,reinterpret顾名思义,重新解释的意思,即对内存中的数据重新解释。

C/C++类型转换的本质

数据是存在内存中的,数据的类型就是解释数据的方式,数据类型转换就是对内存中的数据重新做出解释

typeid运算符

可以求一个表达式的类型,常用来判断两个类型是否相等。
用法例子。有点像Java中的isInstance()

CFather *son = new CSon;
if (typeid(*son) == typeid(CSon))
    cout<<"1"<<endl;

RTTI机制

Run-Time Type Identification 运行时类型识别。
用父类指针指向子类对象时,有时候编译器在编译期没法确定该指针指向哪个对象,即不能确定*p的类型。只有程序运行之后,才能确定,这就叫运行时类型识别。参考

底层原理:根据对象指针找到虚函数表的地址,再找到当前类对应的 type_info 对象,就能知道当前对象是哪个类的对象,就能调用相应的被重写的成员函数。
(编译器会在虚函数表 vftable 的开头插入一个指针,指向当前类对应的 type_info 对象)

在 C++ 中,只有类中包含了虚函数时才会启用 RTTI 机制,其他所有情况都可以在编译阶段确定类型信息。

静态绑定和动态绑定

我们不妨将变量名和函数名统称为符号(Symbol),找到符号对应的地址的过程叫做符号绑定

编译期就能确定符号对应的地址,就是静态绑定
等到程序运行的时候才能确定符号对应的地址,就叫动态绑定

C++运算符重载(operator)

本质上就是函数重载。参考

运算符重载函数不能有默认的参数

运算符重载函数不能有默认的参数,否则就改变了运算符操作数的个数,这显然是错误的。

C++默认参数:实参给形参传值是从左到右依次匹配的。(一旦某个形参有了默认值,那么它后面的形参也必须有默认值)
有了默认参数,按理说就可以少传一些参数,而使用默认值,但是这违背了运算符原本的性质。(比如+号,两边必须有东西)

以哪种方式重载运算符?

重载运算符的两种方式:成员函数全局函数(友元函数)

  1. 全局函数方式的应用场景。(保证参数的对称性)
    类的成员函数不能对称的处理数据
    以成员函数方式重载,只能计算c+15.6c.operator+(Complex(15.6))) (ccomplex类的对象,且该类中存在一个参数的构造函数,所以可以把15.6 转为为complex对象),不能计算15.6+c(15.6).operator+(c) 是错误的,C++ 只会对成员函数的参数进行类型转换,而不会对调用成员函数的对象进行类型转换。
    不禁会问了,为什么不能对double类型也定义运算符重载呢?如果double中也重载了operator+ 不就可以完成上面不能完成的运算了嘛?

C++ 创始人 Bjarne Stroustrup 也曾考虑过为内部类型(bool、int、double 等)定义额外运算符的问题,但后来还是放弃了这种想法,因为 Bjarne Stroustrup 不希望改变现有规则:任何类型(无论是内部类型还是用户自定义类型)都不能在其定义完成以后再增加额外的操作。这里还有另外的一个原因,C内部类型之间的转换已经够肮脏了,决不能再向里面添乱。而通过成员函数为已存在的类型提供混合运算的方式,从本质上看,比我们所采用的全局函数(友元函数)加转换构造函数的方式还要肮脏许多。

  1. 成员函数方式的应用场景。

我们首先要明白,运算符重载的初衷是给类添加新的功能,方便类的运算,它作为类的成员函数是理所应当的,是首选的。

C++ 规定,箭头运算符->、下标运算符[ ]、函数调用运算符( )、赋值运算符=只能以成员函数的形式重载。

重载输入输出运算符

// 1、在complex类中申明友元函数
// 2、重载输入运算符
istream & operator>>(istream &in, complex &A) {
    in>>A.m_real>>A.m_imag;
    return in;
}

// 1、在complex类中申明友元函数
// 2、重载输出运算符
ostream & operator<<(ostream &out, complex &A) {
    out<<A.m_real<<" + "<<A.m_imag<<"i"<<endl;
    return out;
}

重载下标运算符[]

C++ 规定,下标运算符[ ]必须以成员函数的形式进行重载。

两种声明格式。

返回值类型 & operator[] (参数); // 既可以访问元素,也可以修改元素
const 返回值类型 & operator[] (参数) const;  // 只能访问,不能修改

实际开发中,应该同时提供这两种形式。第二种形式最后的const修饰整个函数,表示这是一个const成员函数,const对象只能调用const成员函数,所以提供这种形式是有必要的。const对象表示该对象不可修改,所以返回值应该也为const。这就是写两个const的原因。

模板

函数模板

template关键字用于定义函数模板,typename用于声明类型参数(也可以写成class)。

template<typename T>
void swap1(T &a, T &b) {
    T c = a;
    a = b;
    b = c;
}

类模板

注意成员函数也要写模板头。类名后面要写<类型参数1,类型参数2>。

template<typename T1, typename T2>
class Point{
public:
    Point(T1 x, T2 y): m_x(x), m_y(y) {}

public:
    T1 getX() const;
    void setX(T1 x);
    T2 getY() const;
    void setY(T2 y);

private:
    T1 m_x;
    T2 m_y;
};

template<typename T1, typename T2>
T1 Point<T1, T2>::getX() const {
    return m_x;
}

template<typename T1, typename T2>
void Point<T1, T2>::setX(T1 x) {
    m_x = x;
}

template<typename T1, typename T2>
T2 Point<T1, T2>::getY() const {
    return m_y;
}

template<typename T1, typename T2>
void Point<T1, T2>::setY(T2 y) {
    m_y = y;
}


int main() {
    Point<int, int> p1(10,20);
    cout<<"x="<<p1.getX()<<"y="<<p1.getY()<<endl;

    Point<int, char*> p2(10, "东京180度");
    cout<<"x="<<p2.getX()<<"y="<<p2.getY()<<endl;

    Point<char*, char*> *p3 = new Point<char*, char*>("东京180度", "北纬210度");
    cout<<"x="<<p3->getX()<<"y="<<p3->getY()<<endl;

	return 0;
}

C++异常处理

C++ 语言本身以及标准库中的函数抛出的异常,都是 exception 类或其子类的异常。

C++输入输出流

C++输入输出流本质上就是已经定义好的类对象,之所以称它们为"流",C++ 开发者认为数据传输(包含输入和输出)的过程像水一样,从一个地方流到另一个地方,所以称实现输入的为输入流,实现数据输出的为输出流。

参考

C++多文件编程

C++项目的文件大致可以分为两类:.h文件、.cpp文件。
.h 文件:又称“头文件”,用于存放常量、函数的声明部分、类的声明部分;
.cpp 文件:又称“源文件”,用于存放变量、函数的定义部分,类的实现部分。
这两种文件除了后缀不一样便于区分和管理外,其他的几乎相同。区分两者,并不是C++语法的规定,而是约定俗称的规范。

如何防止头文件被重复引入

1、条件编译。

#ifndef _STUDENT_H
#define _STUDENT_H
class Student {
    //......
};
#endif

编译效率低,可移植性好。一般使用这个。
2、使用#pragma once避免重复引入。
特点:写在文件最开头位置,#pragma once 只能作用于某个具体的文件,而无法向 #ifndef 那样仅作用于指定的一段代码。编译效率高,可移植性差。

3、使用_Pragma操作符。
_Pragma("once")写在文件开头。

综合1和2,兼顾可移植性和编译效率,可以按下面的方式写。当编译器可以识别 #pragma once 时,则整个文件仅被编译一次;反之,即便编译器不识别 #pragma once 指令,此时仍有 #ifndef 在发挥作用。

#pragma once
#ifndef _STUDENT_H
#define _STUDENT_H
class Student {
    //......
};
#endif

C++ const常量如何在多文件编程中使用?

const的功能:1、表明其修饰的变量为常量。 2、将所修饰变量的可见范围限制为当前文件。
那么,如何定义 const 常量,才能在其他文件中使用呢?参考
1)将const常量定义在.h头文件中。
当包含该头文件时,const常量自然也被包含进来。

头文件中一般写什么?

.h 头文件的作用就是被其它的 .cpp 包含进去,其本身并不参与编译,但实际上它们的内容会在多个 .cpp 文件中得到编译。

申明可以多次,但定义只能一次,因此头文件中应该只放变量和函数的声明,而不能放它们的定义。但是有三种情况例外,以下三种情况属于定义,但应该放在.h文件中。

1)头文件中可以定义 conststatic对象。
解释:因为没有用extern关键字声明的const对象仅在当前文件可见,即使被包含进多个文件,也不会出现重复定义的错误。

2)头文件中可以定义内联函数。
解释:内联函数在编译阶段就被展开了(编译器必须在编译时就找到内联函数的完整定义,普通函数都是先声明在链接),放在头文件中刚刚好。
3)头文件中可以定义类。

因为在程序中创建一个类的对象时,编译器只有在这个类的定义完全可见的情况下,才能知道这个类的对象应该如何布局,所以,关于类的定义的要求,跟内联函数是基本一样的,即把类的定义放进头文件,在使用到这个类的.cpp文件中去包含这个头文件。

STL中hashtable的底层实现

STL中vector的底层实现

1、vector 容器在申请更多内存的同时,容器中的所有元素可能会被复制或移动到新的内存地址,这会导致之前创建的迭代器失效。
2、vector 容器还提供了 2 个成员函数,即 front() 和 back(),它们分别返回 vector 容器中第一个和最后一个元素的引用,通过利用这 2 个函数返回的引用,可以访问(甚至修改)容器中的首尾元素。
3、vector的reserve()和resize()的区别:reserve是预留的意思,value.reserve(20);即预留20个元素的空间,capacity变为20,size不变。
value.resize(21)将元素个数改变为 21 个,所以会增加 一些默认初始化的元素。size变为21,capacity也可能改变。
可以看到,仅通过 reserve() 成员函数增加 value 容器的容量,其大小并没有改变;但通过 resize() 成员函数改变 value 容器的大小,它的容量可能会发生改变。另外需要注意的是,通过 resize() 成员函数减少容器的大小(多余的元素会直接被删除),不会影响容器的容量。
4、另外需要指明的是,当 vector 的大小和容量相等(size==capacity)也就是满载时,如果再向其添加元素,那么 vector 就需要扩容。vector 容器扩容的过程需要经历以下 3 步:
完全弃

  1. 用现有的内存空间,重新申请更大的内存空间;
  2. 将旧内存空间中的数据,按原有顺序移动到新的内存空间中;
  3. 最后将旧的内存空间释放。

5、emplace_back()(c++11新增)和push_back()的区别
emplace_back() 和 push_back() 的区别,就在于底层实现的机制不同。push_back() 向容器尾部添加元素时,首先会创建这个元素,然后再将这个元素拷贝或者移动到容器中(如果是拷贝的话,事后会自行销毁先前创建的这个元素);而 emplace_back() 在实现时,则是直接在容器尾部创建这个元素,省去了拷贝或移动元素的过程。
push_back() 在底层实现时,会优先选择调用移动构造函数,如果没有才会调用拷贝构造函数。
显然完成同样的操作,push_back() 的底层实现过程比 emplace_back() 更繁琐,换句话说,emplace_back() 的执行效率比 push_back() 高。因此,在实际使用时,建议大家优先选用 emplace_back()。
参考

C语言位域(位段)详解

左值引用、右值引用

左值:有名字,可以取地址。
右值:无名字,不能取地址。临时对象、字面值。

“在C++之中的变量只有左值与右值两种:其中凡是可以取地址的变量就是左值,而没有名字的临时变量,字面量就是右值”。 正是因为这两种变量分别位于 = 的左右两侧,所以被命名为左值与右值。

左值引用:&

int num = 10;
int &b = num; //正确
int &c = 10; //错误

右值引用:&&

int num = 10;
//int && a = num;  //右值引用不能初始化为左值
int && a = 10;
  1. 右值引用主要用于移动语义和完美转发,其中前者需要有修改右值的权限。
  2. 将亡值,是C++11为了引入右值引用而提出的概念(因此传统C++中,纯右值和右值是同一个概念),也就是即将被销毁、却能够被移动的值。
    参考

C语言内存对齐

为了提高寻址效率。以空间换时间。
将一个数据尽量放在一个步长(CPU的一次寻址)之内,避免跨步长存储,这称为内存对齐

CPU 通过地址总线来访问内存,一次能处理几个字节的数据,就命令地址总线读取几个字节的数据。32 位的 CPU 一次可以处理4个字节的数据,那么每次就从内存读取4个字节的数据。64位的处理器也是这个道理,每次读取8个字节。

最后需要说明的是:内存对齐不是C语言的特性,它属于计算机的运行原理,C++、Java、Python等其他编程语言同样也会有内存对齐的问题。

参考

C/C++ 代码生成可执行文件的过程

在这里插入图片描述

GCC、gcc、g++的区别

GCC 是由 GUN 组织开发的一个编译器套件,支持很多语言。
gcc是一个通用命令,它会根据不同的参数调用不同的编译器或链接器。也就是说,你可以用该命令编译c++或者别的语言。
为了方便, GCC 又针对不同的语言推出了不同的命令,g++命令用来编译 C++,gcj命令用来编译 Java,gccgo命令用来编译Go语言。

C++命名空间(名字空间)详解

一个中大型软件往往由多名程序员共同开发,会使用大量的变量和函数,不可避免地会出现变量或函数的命名冲突。当所有人的代码都测试通过,没有问题时,将它们结合到一起就有可能会出现命名冲突。
为了解决合作开发时的命名冲突问题,C++ 引入了命名空间(Namespace)的概念。
在这里插入图片描述
使用变量、函数时要指明它们所在的命名空间。
在这里插入图片描述
很多教程中都是这样做的,将 std 直接声明在所有函数外部,这样虽然使用方便,但在中大型项目开发中是不被推荐的,这样做增加了命名冲突的风险,我推荐在函数内部声明 std。参考

声明的含义

我们知道,C语言代码是由上到下依次执行的,不管是变量还是函数,原则上都要先定义再使用,否则就会报错。但在实际开发中,经常会在函数或变量定义之前就使用它们,这个时候就需要提前声明

所谓声明(Declaration),就是告诉编译器我要使用这个变量或函数,你现在没有找到它的定义不要紧,请不要报错,稍后我会把定义补上。

例如,我们知道使用 printf()、puts()、scanf()、getchar() 等函数要引入 stdio.h 这个头文件,很多初学者认为 stdio.h 中包含了函数定义(也就是函数体),只要有了头文件程序就能运行。其实不然,头文件中包含的都是函数声明,而不是函数定义,函数定义都在系统库中,只有头文件没有系统库在链接时就会报错,程序根本不能运行。

extern

  1. 对于函数声明来说,有没有 extern 都是一样的。因为函数的定义有函数体,函数的声明没有函数体,编译器很容易区分定义和声明。
    在这里插入图片描述
  2. 变量和函数不同,编译器只能根据 extern 来区分,有 extern 才是声明,没有 extern 就是定义
    在这里插入图片描述

extern 是“外部”的意思,很多教材讲到,extern 用来声明一个外部(其他文件中)的变量或函数,也就是说,变量或函数的定义在其他文件中。不过我认为这样讲不妥,因为除了定义在外部,定义在当前文件中也是正确的。例如,将 module.c 中的int m = 100;移动到 main.c 中的任意位置都是可以的。所以我认为,extern 是用来声明的,不管具体的定义是在当前文件内部还是外部,都是正确的。
参考

C++中的const相比C发生的变化

在这里插入图片描述

  1. 赋值原理不同。
    C++中的 const 更像编译阶段的 #define(预处理阶段替换),C语言中会读内存,再赋值,C++不读内存,直接在编译阶段替换
  2. 可见范围发生改变。
    C++中全局 const 变量的可见范围是当前文件。注意:普通全局变量的作用域是当前文件,但在别的文件中也是可见的,在别的文件中通过extern申明后,就可以使用。而 const 变量就算在其他文件中用extern声明后,也不能用。但是可以在当前文件中定义的时候加上extern关键字来解决。
    参考

内联函数

  1. 为了消除函数调用的开销,产生了内联函数。注意:只有在函数执行时间和调用时间差不多的时候才需要考虑函数调用的开销。换句话说,内联函数适用于函数体比较小的情况。
  2. 另外,因为带参数的宏替换很容易犯错,所以内联函数也用于替代宏定义。内联函数更像是编译期间的宏(字符串级别的替换)。
  3. 建议直接定义在头文件中注意内联函数的定义和声明放在不同文件中会报错)。重复包含也没关系,因为它在编译阶段就被展开了。(内联函数的代码在编译后就被消除了,不存在于虚拟地址空间中,没法重复使用。
  4. 只是程序员对编译器的一个建议,编译器不一定会采纳。

函数重载

一同:函数名必须相同。
一不同:参数列表必须不同。
(返回值无要求)

extern “C”

在C++代码中调用C语言函数,由于编译方式的不同,函数重命名方式不同,导致找不到调用的函数实现。因此加上extern “C” 关键字,来告诉编译器用处理C语言代码的方式处理C++代码。参考

c源文件中要包含自己的头文件

是为了检查声明和定义的一致性,只有声明和定义在一个文件中,编译器才会进行一致性检查,否则有可能在运行的时候出现不好排查的bug。参考

C++引用

引用是对指针的封装(引用占用的内存和指针占用的内存长度一样)。让代码书写变简洁。

  1. 和指针的区别。
    (1)引用在定义时必须初始化,以后不能再指向别的数据;指针定义时无需初始化,以后也可以改变指向。
    (2)有const指针但是没有const引用,因为规定引用只能从一而终的指向一个数据,用const修饰多此一举。
    (3)有多级指针的概念,无多级引用。
  2. 引用不能指代临时数据。
    引用可以理解为别名,临时数据连名字都没有,哪来的别名。
    易错点:当引用作为函数的形参时,自然不能传递临时数据(没有名字的数据)。
  3. 常引用绑定到临时数据时,编译器采取了一种妥协机制:编译器会为临时数据创建一个新的、无名的临时变量,并将临时数据放入该临时变量中,然后再将引用绑定到该临时变量。
    因此将形参定义为常引用,以便可以接收临时数据
  4. 普通引用不能绑定到临时数据,也不能绑定到相近的数据类型(可以自动转换的)。但是常引用可以,原理:创建临时变量,将引用绑定到这个临时变量。
  5. 概括起来说,将引用类型的形参添加 const 限制的理由有三个:
    使用 const 可以避免无意中修改数据的编程错误;
    使用 const 能让函数接收 const 和非 const 类型的实参,否则将只能接收非 const 类型的实参
    使用 const 引用能够让函数正确生成并使用临时变量。

C 语言二维数组名——数组指针

n维数组名是指向n-1维数组的数组指针,所以sort1()的第一个形参的类型应该是一个指向字符数组的指针(该字符数组大小为80)。

#include<stdio.h>
#include<string.h>

void sort1(char (*word)[80], int n) {
    char p[80] = {0};
    int i, j;
    for (i = 0; i < n-1; i++) {
        for (j = 0; j < n-1-i; j++) {
            if (strcmp(word[j], word[j+1])>0) {
                strcpy(p,word[j]);
                strcpy(word[j], word[j+1]);
                strcpy(word[j+1], p);
            }
        }
    }
}

int main() {
    srand(time(0));
    // n维数组名是指向n-1维数组的数组指针
    char a[][80] = {"bak", "anxj", "kja", "avhs"};
    sort1(a, sizeof(a)/sizeof(char[80]));
    int i;
    for (i = 0; i < 4; i++) 
        printf("%s\n", a[i]);

    return 0;
}
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值