C++小记
本文最后更新于:2 个月前
前言
这篇东西本来是大二上C艹课写的笔记,为了应付考试写了很多八股的、不太实用的内容。后续用C艹的过程中发现越陷越深,越学越发现不会C艹,于是重新捡起了这篇东西,把一些新写的东西放到了上面,之前写过的八股也留在下面没删除。
结构体内存对齐
- 顺序分配
- 每个成员的首地址需要是自身大小的整数倍。
- 总大小需要是其中最大类型的整数倍。
匿名函数原理、闭包
假如有这样一个Lambda函数:
int a = 2;
auto func = [&](int x){
return x + a;
};
如果不是临时使用,而是把它作为一个 function<int(int)>
到处传的话,这个 a
在其他地方也能同样生效吗?
事实上,匿名函数会创建一个 SomeAnonymousType
类型的对象,它会直接存储捕获到的变量,如果使用引用方式捕获,存储的就是指针。
所以匿名函数的内存大小和捕获到的变量数有关,上述写法试图捕获可见域内所有变量,好在编译器有优化,实际上只会捕获用到的,所以大多数情况下我们使用没问题。
但还是要注意对局部变量的引用捕获,可能在使用时指针已经野了。
另外,function
和 SomeAnonymousType
并不是一个类型,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
对于大部分类型来说,用右值构造还是左值构造并没有什么区别,但关键就在于,右值引用和左值引用可以作为不同的形参,实现重载。
一些含有资源的类(如 string
,vector
,这里的资源指它们自行从堆上申请来的内容)就可以利用右值引用,实现另一种构造:直接窃取资源而非拷贝的构造。
如同上面的 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
这两个东西的类型分别是istream
和ostream
,它们都是类;
这两种类的基本操作是 >> <<
两种运算符,返回值类型是其本身的引用,原理大概这样:
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_cast
同 static_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。