C++小记

本文最后更新于:2 个月前


前言

这篇东西本来是大二上C艹课写的笔记,为了应付考试写了很多八股的、不太实用的内容。后续用C艹的过程中发现越陷越深,越学越发现不会C艹,于是重新捡起了这篇东西,把一些新写的东西放到了上面,之前写过的八股也留在下面没删除。

结构体内存对齐

  1. 顺序分配
  2. 每个成员的首地址需要是自身大小的整数倍。
  3. 总大小需要是其中最大类型的整数倍。

匿名函数原理、闭包

假如有这样一个Lambda函数:

int a = 2;
auto func = [&](int x){
	return x + a;
};

如果不是临时使用,而是把它作为一个 function<int(int)> 到处传的话,这个 a 在其他地方也能同样生效吗?

事实上,匿名函数会创建一个 SomeAnonymousType 类型的对象,它会直接存储捕获到的变量,如果使用引用方式捕获,存储的就是指针。

所以匿名函数的内存大小和捕获到的变量数有关,上述写法试图捕获可见域内所有变量,好在编译器有优化,实际上只会捕获用到的,所以大多数情况下我们使用没问题。

但还是要注意对局部变量的引用捕获,可能在使用时指针已经野了。

另外,functionSomeAnonymousType 并不是一个类型,function 是又一层封装,它把 SomeAnonymousType 存在了堆中并维护,故 function 的大小是个常量。

编译原理

  • #include 指令是粗暴的将文件内容复制过来;(预编译期)
  • 编译都是对单个文件进行的,需要在单个文件内自圆其说(用到的函数都有其声明)。
  • 声明是声称了某个符号的存在,使得编译时即使它还没有完整定义也能用。
  • 最后的链接阶段,才是将这些符号和其具体定义关联起来。

因为两个头文件中的函数实现可能互相依赖,故通常使用定义实现分离的写法,不在头文件中写定义。

因为头文件引用可能循环套娃 ,故常写 #ifndef 避免无限循环。

多个文件分别编译,但最后要进行链接,此时多个源文件中不能有重复定义,故通常不在头文件中写定义(头文件写定义,当头文件被多个源文件引用时,每个源文件中都有一份定义,链接时即报错)。

const,static只在单个文件中有效,故可以写在头文件中(不会被链接),但需注意static全局变量写在头文件中后,被多个文件引入时就不再是同一个变量了。

inline内联函数可以写在头文件中,因为它本质是将函数内容拷贝到调用的地方去;

类成员的定义可以写在头文件中,具体后续再理解(。。

右值引用与移动语义

右值和左值的概念此处不赘述。

先看右值引用的经典作用:实现移动语义

class Node {
public:
    char* p;
    Node(const char* str) {
        p = new char[255];
        memcpy(p, str, strlen(str) + 1);
    }
    Node(Node& t) {
        p = new char[255];
        memcpy(p, t.p, strlen(t.p) + 1);
    }
    Node(Node&& t) {
        swap(p, t.p);
    }
};

int main() {
    Node x("hello");
    printf("%s\n", x.p);
    Node y(move(x));
    printf("%s %s\n", x.p, y.p);
}
hello
(null) hello

对于大部分类型来说,用右值构造还是左值构造并没有什么区别,但关键就在于,右值引用和左值引用可以作为不同的形参,实现重载。

一些含有资源的类(如 stringvector,这里的资源指它们自行从堆上申请来的内容)就可以利用右值引用,实现另一种构造:直接窃取资源而非拷贝的构造。

如同上面的 Node 类,在以右值做形参的拷贝构造函数中,它直接偷来了对方的指针,没有资源复制的过程,效率极高。

当然,此时对方应当放弃这个资源,否则共享一块资源就乱了,故这通常用于临时对象的转移。

这便是移动语义,它并不是对每种类型都有效的,一般是含资源的类自行重载了拷贝构造函数,自行定义了资源转移的方法,才能发挥移动语义的作用。

右值有一个特点,它没有“名”。右值引用变量仅仅只是限制了它只能绑定到右值,在绑定完成后,它有了名字,于是便和左值无异了(事实上,右值引用变量、形参就是一个左值)。

常左值引用也可以绑定到右值。

int&& x = 2;
Node&& func();
Node y(func());

如上 func 函数返回了一个右值引用,y 直接利用返回值进行构造,它就是一个右值。

如上定义了右值引用变量 x,令他引用2,这样 x 就和一般的左值变量一样了。

目前理解有限,我认为右值引用的主要意义就是作为形参或返回值,作为形参时会限制左值不能作为实参,作为返回值时可以作为真正的右值使用。

move函数就是将一个左值引用强制转换成了右值引用,因此宏观来看,上述代码 Node y(move(x)); 就实现了资源移动的效果。

引用折叠、万能引用

C++中,不能直接创建引用的引用,但可以间接创建,间接的多层引用会被折叠,等价于一层引用。

对于模版形参 T&& 来说,由于可以进行类型推导, T既可以接收右值引用,也可以借由引用折叠特性,接受左值引用。同理,auto也可以当做万能引用(但我还没见过实际应用)。

完美转发

首先我们知道,不管实参是右值还是左值,一旦变成了形参,它就变成左值了(因为有了名),如果需要将右值在多层函数间传递,则每层都需要进行move。

对于可变参数来说,我们不清楚其中有哪些是右值、哪些是左值,不能统一进行move;std::forward提供了一种机制,仅在传入类型是右值时进行move。通常用法(也是我能想到的唯一用法?):

template<typename... T>
void func(T&&... args) {
	fun2(std::forward<T>(args)...);
}

可以看到,forward 需要模版参数T,这就是自动推导得到的万能引用,从T的类型中可以得知实参到底是左值还是右值,从而让forward实现完美转发。

const,引用和指针

const,顶层与底层

const 类型必须在定义时附上初始值;class中的const成员必须在初始化列表中有初始值;

分辨const修饰的对象:

const int const * const p;
//上面有三个位置可能放const限定符,按照*划开,*左端为指针指向的类型,右端才是对这个指针的限定修饰;
//举例:
const int *p;//p指向的是int常量,p本身不是常量,可以更改指向
int const *p;//和上条完全等价
int *const p;//p指向的是int变量,p本身是一个常量,不能改变指向
const int *const p;//p指向int常量,且p本身是一个常量,不能改变指向

按照上述方式划分清楚const的修饰对象即可,如果一定要顶层和底层const的概念的话:

通常,顶层const指本身为const,底层const指其指向的对象为const;

可见前两条为底层const,第三条为顶层const,第四条既是底层又是顶层const;

个人感觉顶层底层的概念把问题复杂化了。。。其实就是“这个变量本身是否为常量”

//const用于函数
void find(const int &x){
    //表明这个x是不可变的,一般和引用传递同时使用以保护实参变量
}
const int& find(){
    //修饰引用、指针型的返回值,加const意味着返回的值不能修改(不可作为左值)
    //不太理解对非引用、指针型返回值加const的意义
}
void compare() const{
    //const修饰整个函数,只能对成员函数(类中的函数)使用;
    //意味着这个函数不能更改任何自身数据成员,也只能调用同被const修饰的方法。
}

另外提一句:返回值为非引用、指针的内置类型时,返回的值不能作为左值(当然),而返回的对象则可以作为左值,可以被赋值或者立即调用方法等,不过在这一行过后它会立刻析构(因为也没有变量指向它)。

square f(int x, int y) {
	return square(x, y);
}
int main() {
	cout << f(3, 4).area() << '\n';
}

void*指针可以指向任意类型的对象,但因为没有存储对象类型,不能通过其修改对象;

引用:变量的别名

int a = 3;
int &b = a;//相当于a, b使用同一块存储区域,且都标记为了int类型,两者没有区别
const int& c = a * 2;//常量可以初始化成任意值,引用无意义
int &d = 42;//错误,不能将非常量引用绑定到常量

对象作为参数时加入&引用可以让传参更快(免去了拷贝的过程),对于内置类型,这样的加速无必要。


继承、多态

继承

子类继承了父类的所有非私有的成员和方法,除了以下几种:

  • 构造函数、析构函数、
  • 重载的运算符
  • 父类的友元函数

子类在没有显示调用父类的构造函数时,会默认先调用一遍父类的无参构造函数;

注意此时父类的无参构造必须存在,否则会报错;

或者,在子类中显示地调用父类的构造函数,形如:

square(int x, int y) : shape(x, y) {}

可以加 final 关键字阻止其他类继承此类

class Last final: public base{..}

重载,重写,隐藏

重载是同名但参数列表不同方法,依据参数列表决定调用哪个(仅返回值不同,不能构成重载,因为依然不能根据调用时传递的参数判断调用的是哪一版);

隐藏是指子类中方法,会隐藏父类中同名的方法(即使参数列表不同),子类有一个find()方法后,再要通过这个子类调用它父类的名为find的方法,要使用作用域符号::

重写指将父类中的方法定义为虚函数,然后子类的同名同参的方法可以覆盖父类中这一方法,相较于隐藏在父类方法中多写了一个virtual,和隐藏的区别在于:

  • 不加virtual时,通过父类指针/引用调用方法,固定调用父类中的方法(因为是父类指针,隐藏时调用哪个完全看名字);
  • 加virtual时,通过父类指针/引用调用方法,指向哪个类就调用哪个类的方法;

重写可以加上一个 override 表示这个函数试图重写父类虚函数,它是一种标识符,标记后如果他没有重写(比如你自己写错名字了),编译器则会报错。主要作用是提醒程序员。

隐式转换

可以将子类对象转换为父类对象(派生方式必须为public),这完全可行,子类本身就包含了父类信息;

将父类对象转化为子类的对象,是不安全的,可以使用 dynamic_cast 进行父指针到子指针的转换,若转换失败返回空指针,可以用于检查类型。

动态绑定

C++中,父类指针或引用可以指向子类对象,这会导致以下情况:

在通过父类指针/引用调用一个虚函数时,按照上述原则,应该根据指针的指向决定调用的函数;

但在编译期,我们还不知道这个父类指针具体指向了哪个类,所以这次调用的函数版本不是在编译期决定的,而是在运行期才决定,所以被称为动态绑定。

或者换一个说法,父类指针的绑定对象是可以更改的,所以在编译期不能直接确定。

虚函数表(虚表)是属于类的,每个类都仅有一个,类对象中有指向该类虚表的指针。

虚表是一个指针数组,存储类中每个虚函数的指针,它或是指向重写的新定义,或是指向父类的定义(继承),总之,虚表可以直接查询该类对象调用某虚函数时,应当执行哪个定义。

虚表指针存储在对象的首部,在用父类指针指向子类对象时,通过父类指针调用,查到的就是子类的虚表,于是可以方便地实现多态。

多态的含义

我们可以对“多种形式”的同一类物体进行同一操作,而无需在意他们的差异;

一个接口,多种实现;


构造函数和初始化列表

类在构造时,任何类的成员对象(类对象),若没有被显式地构造,都会隐式地调用其缺省构造函数,包括父类构造也是这样隐式调用。而对于基础类型则不会这样,没初始化就是没初始化。

若未定义任何构造函数,则会合成一个Public的默认无参构造函数,它完全等价于 A() {};,除了隐式构造成员对象(上述)外不做任何事情。注意:一旦人为定义了构造函数(哪怕是有参的,哪怕是私有的),这个无参构造函数就不会再自动合成了,此时这个类就没有了无参构造函数,无法用类似 A a; 的方式直接创建。

初始化列表的特殊用处在于:初始化const类型的成员变量。

直接在构造函数中为const类型赋值显然是不规范的,所以用到了初始化列表,可以认为它比构造函数“更早地”初始化了成员变量,const修饰的变量必须在初始化列表中初始化它

初始化列表中还可以使用类(或其父类)中的其他构造函数来初始化(委托构造函数):

(且对于父类成员,只能使用父类构造函数来初始化)

class shape {
public:
	shape(int x, int y) : w(x), h(y) {}
	shape(const shape &t) : shape(t.w, t.h) {}
	int area();
private:
	int w, h;
};

class box : public shape {
public:
	box(int x) : shape(x, x) {}
};
class box : public shape {
public:
	box(int x) : shape(x, x) {}
    //explicit box(int x) : shape(x, x) {} 加上explicit限定,禁止其隐式转换
};

int main() {
	box t = 3;//构造函数中若只有一个实参,可以隐式地将这个实参直接转化为类类型
}

拷贝控制

在类的复制拷贝构造函数中参数一定要+引用,因为非引用的传参本就包含了拷贝过程,会造成递归调用。

一般也同时加上一个const来保护,这是一种典型的拷贝构造函数:

class student{
public:
    student(const student &x);
};

注意:写了拷贝构造函数的同时覆盖了默认的拷贝构造函数,依据上一节中的隐式转换规则,在初始化时直接使用等于号,也将隐式调用仅有一个参数的拷贝构造函数;

可以给构造函数加上 explicit 关键字来阻止上述隐式转换;

而实际上的 = 赋值运算符是对一个已经初始化完毕的对象重新赋值时调用的,以此区分,见下代码:

class box : public shape {
public:
	box(int x) : shape(x, x) {}
	box(const box& t) : box(t.w + 1) {}//修改过的拷贝构造函数
	void operator =(const box& t) {//赋值运算符
		w = t.w;
		h = t.h;
	}
};

int main() {
	box a(5);
	box b = a;					//无初值的对象初始化,看似是等于号,其实调用的是拷贝构造函数

	cout << b.area() << '\n';	//结果为36
	b = a;						//有初值的对象,这里的等于号就是赋值运算符了
	cout << b.area() << '\n';	//结果为25
}

所以,拷贝构造函数除了直接调用之外,还会在用=定义变量,传参,返回,初始化列表等等地方被调用,需要与operator=辨别开来;

老规矩,operator=和拷贝构造函数都有一个默认版本,自己定义后将默认版本覆盖

默认版本中,拷贝与赋值都是将所有成员一一对应赋值,对于指针成员,并不会为其开辟新的空间。

使用=default显示地保留默认版本

box() = default;

使用=delete来删除默认版本,可以在不自定义的情况下阻止拷贝,赋值等等(析构不能被delete)

box() = delete

例如iostream类中就使用了上述方式阻止了拷贝和赋值。


Shared_ptr智能指针

这篇很好:https://blog.csdn.net/qq_29108585/article/details/78027867

当我们new一个对象时,会在堆中开辟一块空间,称之为$A$,这块空间的地址我们称为原始指针,new返回的即为此指针;

用原始指针初始化shared_ptr时,会在ptr中记录这个地址,同时开辟一块新的区域(可以这么理解)保存引用计数,这块区域称为$M$,ptr中也记录了$M$ 所在的地址;

当用ptr初始化或赋值另一个shared_ptr(假设为ptr2)时,ptr2不再会开辟一个新的$M$,而是和ptr指向同一个$M$,这个是他们的共同引用计数(这里没什么问题)

但如果用原始指针初始化或复制另一个shared_ptr时,就会出现另一个引用计数$M2$,并且两个引用计数都为1,这时任意一方计数归零时,都会直接回收对象的存储空间$A$,另一方则出大事;

这就是shared_ptr一些麻烦事的原因:

  • 不要重复用原始指针构造shared_ptr,而是用已经有的;
  • .get() 方法返回原始指针,不要用这个方法去赋值其他shared_ptr,原因同上;
  • 不要混用指针和智能指针:智能指针不会对普通指针的引用计数,容易造成普通指针悬空;

友元

在类 $A$ 中声明一个友元函数,则这个函数(全局函数或者其它类的成员函数)可以访问 $A$ 中的私有成员

在类 $A$ 中声明一个友元类 $B$,则 $B$ 中所有成员函数都可以访问 $A$ 的私有成员

class shape {
public:
	friend void expend(shape x);
	friend class box;
private:
	int width, height;
};

class box {
public:
	int areaof(shape x) {
		return x.width * x.height;
	}
};

void expend(shape x) {
	x.width++;
	x.height++;
}

友元并不是类的成员,其声明不受访问控制符的影响(放在public还是private都可以),听说不能把私有成员函数声明为友元,我试了一下可以(?

模板和泛型

template <typename T>
bool compare(const T &a, const T &b){
    return a < b;
}

模板实际上就是将这一类程序员需要重复做的事情交给了编译器。

模板在定义时不生成代码,而是在使用处生成对应版本的模板定义(还是在编译期),因此模板实际上还是为每种(用到的)类型生成一个定义。

正因如此,对模板定义中 T 变量执行的操作,只要符合所有使用到的类型,即可通过编译。

模板也可以带有一些非类型的参数:

template<unsigned N, unsigned M>
bool compare(const char a[N], const char b[M]){
    ...
}
compare("hello", "world"); //调用,同样根据实参推断

typename 用在template中,和class完全一样(class是为了兼容历史代码而保留的,建议使用typename);在模版定义中,typename还可以用于显式说明一个名称是类型名。对于依赖模版参数的嵌套名称,使用typename可以提前告诉编译器这是一个类型,而非一个变量。最好为所有依赖模版参数的嵌套名称都加上typename修饰。

例如 T::abc, 这里的 abc 可能是类型也可能是变量,使用typename显式地通知编译器以消除歧义。

模板类

template<class T> //也可以用typename,基本没有区别,typename一定能用
class Array {
    ...
}
Array<int> a;//之后,Array永远要跟着一个<>

定义可以分离,定义时语法如下:

template<typename T>
int Array<T>::front(){...}

但,无论是模板函数还是模板类的成员,定义和声明都必须在同一个文件中

原因是:模版只有在被使用时才会编译相应版本的代码(编译后的目标文件不存在模版的说法,只有许多种确定类型的代码),编译遇到一般的函数时,只需要有其声明,预留个指针以后再链接就好了。而编译遇到模版时,必须有其实现,供编译器立刻生成一个对应版本的代码。

不分离不一定会报错,若编译时遇到模版却没有其实现,也会留下空指针等着以后链接,如果恰好别的地方编译了相应版本的模版,也能链接上,不然会报链接错误。

模版特化

一套模版定义可能不适用于所有模版类型,模版特化是指对于特殊的模版类型,专门实现一种定义。

template<class T>
bool Compare<T>::equal(T a, T b) {
 return a == b;
}
template<> // 必须有
bool Compare<float>::equal(float a, float b) {
 return std::abs(a - b) < 10e-3;
}

有偏特化和全特化之分,偏特化即仅限定一部分类型。函数模版只能全特化,类模版可以偏特化。通常来说,函数模版的全特化可以用函数重载替代,因此比较少用。

特化所施加的限制也可以是这样:

template<class T>
class Data<T*> {...}

template<class T>
class Data<T&> {...}

不是限制类型,而是限制必须为指针或引用。

可变参数模版

template<typename ...T>
void fun(T... args) {
    int a[] = {(args + 10)...};

    for(int i = 0;i < sizeof...(args);i++) {
        cout << a[i] << "\n";
    }
}

fun(3, 3, 2, 2, 4);

上面的模版写法和参数写法,我认为是一种特殊语法,套用就好。

关键在于怎么用。基础的用法 args... 将可变参数列表展开,展开的东西可以用作初始化列表、可以丢进下一个函数作可变参数,可以粗略理解为,展开的东西在代码里就是一串逗号相连的值(注意,这都是编译期执行的)

可以将 args 换成一个带有 args 的表达式,例如 (args * 2)...,也是一样展开,每个参数都按照同样的表达式形式展开(不计算,仅展开)

内联函数

在函数的定义前加上inline可以让这个函数在编译时就“展开”到调用它的地方,让执行时效率更高

inline是一种“用于实现的关键字“,必须和定义写在一起,放在声明旁边没有用

类中定义的成员函数默认为 inline


Lamda表达式

Lamda表达式可以造一个临时的匿名函数,省去了声明的麻烦,而且在用完后立刻销毁,非常绿色;

语法:[捕获参数](参数列表)修饰符->返回类型{函数体}

  • 捕获参数:可以获取这个函数所在处的一些局部变量来用,例如:
    • [var] 按值传递方式获取 var 变量
    • [&var] 按引用传递方式获取 var 变量
    • [=] 按值传递方式获取局部所有变量,[&] 按引用传递获取局部所有变量
    • [=, &var] var按引用传递,其他所有按值传递
    • 其他同理
  • 参数列表:就是参数列表,若无可以和 () 一起省略
  • 修饰符:捕获参数时默认为const,加mutable修饰符可以取消这个const
  • 返回类型:如果函数体只有一次return或返回类型为void,可以连->一同省略

Lamda表达式常用于sort等STL方法时,方便地自定义一个临时函数


其他


typedef、decltype和auto

有几个需要注意的点:

typedef double aaa, *bbb;
//对bbb的理解:其实是typedef double* bbb;
//bbb p <=> double *p
using bbb = double*;
decltype(x) p;//根据x的类型推断出一个类型;
//分两种情况:若x是一个变量,则推断出变量本身的类型;
//若x是表达式,则推断出表达式本身类型之后,再根据这个表达式能否作为左值,决定是否保留引用;
int x = 3;
int &y = x;
decltype(x) //推断为int
decltype(&x) // 推断为int*
decltype((x)) //因为是表达式,而(x)是左值,所以被推断为了int&,最特殊的情况
decltype(y) //推断为int&
decltype(x*1.5) // 推断为double(经测试,占用了8个字节)
//decltype和auto都可以直接加上const修饰符
const auto t = 32;

函数的默认参数

void func(int x, int y, int z, double d = 2.0, int f = 3){
    //可以使用默认的参数,在调用时可以不填写这一参数;
    //默认参数必须都在最后面(即任意默认参数后不能再有非默认参数
    //这样调用时非默认参数依然是按顺序对应的
    //注意:默认参数和定义写在一起,单独声明时不管
}

函数指针

int add(int x, int y) {
	return x + y;
}

int main() {
	int (*p)(int x, int y) = add;//这个函数指针可以指向任意参数相同,返回值相同的函数;
	cout << p(2, 3) << '\n';//直接用,结果是5
}

关于在类的成员函数内可以随意调用这个类(即使不是同一对象)的私有成员:

可以从作用域的角度来理解,私有成员的作用域仅在成员函数当中,类本身就是一个域。

重载cin, cout

这两个东西的类型分别是istreamostream,它们都是类;

这两种类的基本操作是 >> << 两种运算符,返回值类型是其本身的引用,原理大概这样:

istream& operator >>(int &x) {
	//读取x
	return *this;
}
//当然这不是可以运行的代码,只是大概展示原理

运算后返回了cin本身,所以可以连着>>;

//试图用一般类的方式重载,失败
istream& istream::operator >>(int& x) {
	scanf_s("%d", &x);
	return *this;
}

//用全局变量的方式重载,成功
istream& operator >(istream& f, int& x) {
	scanf_s("%d", &x);
	return f;
}

需要注意的是:传参和返回都一定要是引用类型,这两个类本身不支持传值拷贝

C++风格的强制转换

static_cast<类型>(表达式) C++风格的一般强制转换,暂且当做C语言的强制转换替代来用

const_cast<类型>(指针或引用) 可以取消指针或引用中的底层常量性。也就是说,本来这个指针认为,它指向的内容是不可更改的,现在告诉它可以更改了。

int a = 3;
const int *p = &a;//定义指向常量的指针,指向了一个变量,指针也将其视为常量,不可通过*p更改
int *q = const_cast<int *>(p);//现在指针认为它指向的不是常量了
*q = 4;//可行

dynamic_cast<类型>(表达式) 带有安全检查的强制转换,不能用于内置类型;

将子类转换为父类时,由于本身就是安全的,dynamic_caststatic_cast 一样;

将父类转换为子类时,dynamic_cast 会在转换失败时返回NULL,比 static_cast 更安全

#define A(x) #x

#用于将x变成字符串,即”x”,因为字符串里面直接写的x不会被替换所以有这个语法

#define A(x, y) x##y

##用于连接两部分,因为xy直接写一起不会被替换所以有这个语法,也可以连接形参和普通字符,##左右可以有空格,都会被忽略。

内存

new实际上包括operator new和placement new。operator new仅分配内存(相当于malloc),placement new即调用构造函数

new (place) T(…)

placement new的显示调用方法,不分配地址,直接在已有的地址place上构造。

operator new

可以全局重载或在类局部重载,例如

void* operator new(size_t, size) { 
     return malloc(size);
}

针对类,可以显示地将new定义为私有或delete。

::xxx可以限定使用全局作用域下的xxx,例如 ::new 显示地使用全局的operator new方法分配,跳过类的operator new使用全局的operator new。


C++小记
http://www.lxtyin.ac.cn/2022/11/02/2022-11-02-C++小记/
作者
lx_tyin
发布于
2022年11月2日
许可协议