1. C++快速入门--变量和基本类型, 类别
1 基本内置类型#
1.1 算术类型#
算术类型介绍
- bool 类型
- 字符类型
- 整数类型
- 实数浮点、虚数浮点和 复数浮点
参看如下表
类型 | 含义 | 最小尺寸(位) |
---|---|---|
bool | 布尔类型 | 未定义 |
char | 字符 | 8 |
wchar_t | 宽字符 | 16 |
char16_t | Unicode字符 | 16 |
char32_t | Unicode字符 | 32 |
short | 短整型 | 16 |
int | 整型 | 16 |
long | 长整型 | 32 |
long long | 长长整型 | 64 |
float | 单精度浮点数 | 6位有效数字 |
double | 双精度浮点数 | 10位有效数字 |
long double | 长双精度浮点数 | 10位有效数字 |
带符号和无符号类型的变量
分为: signed 和 unsigned
unsigned
变量只能表示大小- 在
int,short,long,long long
前加unsigned
就变为无符号的 类型 unsigned int
可简化为unsigned
`char` 和 `signed char` 是不同的
- `char` 可能是有符号的,也可能是无符号的,这取决于编译器的实现
- `signed char` 明确指定为有符号字符类型,可用于存储 -128 到 127 之间的整数
1.2 算术类型转换#
不要在一个表达式中混用 无符号和有符号类型
当算术表达式中有 unsigned
和 int
型时, int
可能会转为 unsigned
这种隐式地转换可能导致问题,比如使得转换后的 unsigned
变为负值
unsigned a=10;
int b=-15;
cout<<a+b; //这个会导致意料之外的结果,结果和int所占位数有关
整数类型 和 浮点类型 的转换
整数->浮点: 小数部分为0, 若超过空间, 则损失精度
浮点->整数: 只保留小数点之前的部分(不同于整数部分)
布尔类型 和 算术类型 的转换
算术->布尔: 若值为0, 则为 false
, 否则为 true
布尔->非布尔算术: false
为0, true
为1
将值赋给 无符号类型
若超出其表示范数, 则取模后的余数.
比如-1 在8位的 unsigned char
的值为 255
1.3 字面值常量#
字面值常量 literal constant
字面值常量不是变量, 它是值本身
在`int a = 45;`中, `45`就是字面值常量
字符和字符字面值
'a'
是一个字符字面值
"abcde"
是字符串字面值, 实际上是由常量字符构成的数组
- 每个字符串字面值后面会自动加上`'\0'`, 因此它的长度总是比他内容长度多1
- 当多个字符串字面值写在一起时,编译器会将它们连接成一个新的字符串字面值,并添加一个 `'\0'` 作为结尾
例子:
cout<<"我" "是"
"一个人"<<endl;
cout<<"我是一个人"<<endl;
此时会将他们之间的 \0
去除
转义字符\
\
配合其他字符可实现换行, 制表符等
转义序列 | 含义 | 说明 |
---|---|---|
\n | 换行 | 将光标移到下一行的开头 |
\t | 横向制表符 | 将光标向右移动到下一个制表位 |
\a | 响铃 | 发出响铃声,通常用于提示用户注意 |
\v | 纵向制表符 | 功能类似于换行,但有些终端可能支持更复杂的布局 |
\b | 退格 | 将光标向左移动一个位置,相当于删除前一个字符 |
" | 双引号 | 在字符串中表示一个双引号字符本身 |
' | 单引号 | 在字符串中表示一个单引号字符本身 |
|反斜杠 | 在字符串中表示一个反斜杠字符本身 | |
? | 问号 | 通常用于表示一个未知的字符 |
\r | 回车 | 将光标移到本行的开头,但不换行 |
\f | 进纸 | 将光标移到下一页的开头 |
指定字面值的类型
通过一些前缀和后缀来指定字面值类型,例如L'a'
![[Pasted image 20230814180026.png]]
布尔字面值
false
, true
是字面值
指针字面值
nullptr
是指针字面值
自定义的字面量
- 允许使用 数字/字符文字 加上 后缀构成自定义字面量
- 后缀一般要用下划线开头
比如12_km
表示12千米的字面量
如何实现这种效果? 通过重载后缀运算符来实现, 句法如下
字面量类型 operator ""后缀名 (形参列表);
例子
using ull = unsigned long long;
ull operator""_m(ull x) { return x; };
ull operator""_km(ull x) { return 1000_m * x; };
int main() {
auto x = 12_km; // 实际上x是long double类型 最后转换为120000m
std::cout << x;
}
另外在标准库 chrono
中的[[§18. 用于大型程序的工具#内联命名空间|内联命名空间]] chrono_literals
内部定义了时间相关的字面量:
//源码
constexpr chrono::duration<long double, ratio<3600, 1>>
operator""h(long double __hours) {
return chrono::duration<long double, ratio<3600, 1>>{__hours};
}
- 返回类型
chrono::duration<>
是一个时间间隔类型 - 当使用这个命名空间后,
1h
就表示1小时的时间间隔. - 标准库中没有加下划线也能通过编译, 是因为加了
#pragma GCC diagnostic ignored "-Wliteral-suffix"
, 告诉编译器不检查后缀名.
2 struct 和 union 自定义数据结构#
2.1 struct 结构体基础#
struct基本概念
- 常常用于存放组合的数据, 这一点优于
std::tuple
类型 - c++11之后, 可为数据成员指定 类内初始值
- 定义struct结构体后必须加
;
, 否则可能有问题. 因为struct定义后 可接着定义对象
struct mystr{
int a=0; //指定类内初始值
int b=0;
} str_test; // 定义了一个 mystr类型的对象 str_test
- struct的定义 和 对象的定义. 应该分开写
struct mystr {int a=0; int b=0;};
mystr str_test;
struct和class的区别
struct
的成员默认为public
, 而class
默认为private
- 如果基类没有访问说明符, 则在派生类声明为
struct
时默认使用public
继承, 而在将类声明为class
时默认为private
继承. - 关键字
class
可以用来声明模板参数,而struct
关键字不能这样使用
匿名类型的结构体
当直接创造一个结构体对象时, 可以不写它的结构体类型名称
struct {int a; int b;} str_test; // 直接创造了一个对象str_test, 没有为结构体命名
struct的初始化
https://en.cppreference.com/w/c/language/struct_initialization
初始化的形式如下:
str_test={ designator(optional) expression , ... };
str_test={}; //从c23开始, 可以用空列表初始化
- 其中
designator
是成员指示符, 可以指定为某个成员初始化 - 成员指示符是可选的
- 初始化列表的个数, 不能超过struct的数据成员个数.
- 所有未显式初始化的成员都被 空初始化.
**空初始化的行为**
- 指针初始化为其类型的 `null` 指针值
- 整型对象初始化为无符号整数0
- 浮点类型的对象初始化为正零
- 数组的所有元素、结构的所有成员和联合的第一个成员都以递归方式进行空初始化,并且所有填充位都初始化为零
例子
struct mystr {
int x;
char c[4];
};
mystr A={.x=1}; //将A.x设为1
mystr B={.c={'\1'}}; //将数组B.c设为 {'\1','\0','\0','\0'}
访问结构体的数据成员
由于成员一般是 public
的, 可以通过 点运算符 直接访问它的成员
mystr.a=4;
std::cout<<mystr.a;
struct 的内存对齐
当未自定义内存对齐的规则时, 将采用默认的对齐规则, 如下:
struct
对象的首地址能被最宽的基本类型成员大小整除- 每个成员的内存偏移量, 都是最宽的基本类型成员的整数倍
- 结构体总大小为 最宽基本类型成员大小的整数倍
例子:
class A{
int a; //4
char b[1]; //1
char b[2]; //2
}; //8
class D { //8字节
int a;
int b;
};
struct B {
D d;
int a;
char b[10]; //10 3*4
};
对于 B
的对齐解释
- 最宽基本类型为 int, 它是4字节, 因此以4字节对齐.
- 不必遵从类型
D
的长度对齐.
- 不必遵从类型
- 而字符数组需要 10个字节, 因此它"占用" 12字节
- 因此总体占用为 8+4+12=24 字节
alignas 控制内存对齐
alignas(整型常量)
- 整型常量指定了内存对齐的大小
- 该大小指的是整个结构体的大小 (Byte)
- 规定一个类型对象的起始地址, 必须为该值的倍数
- 设定的值不能小于默认值, 且必须是默认值的整数倍. 默认值即为 [[#struct 的内存对齐]]规则所阐述
struct alignas(32) MyStruct { //整个结构体大小为32字节
int a;
double b;
};
2.2 联合体/共同体 union#
什么是 union?
- union和struct很像, 但区别很大
- 它的多个成员 共用同一块内存
- 在任何时刻, union 的对象只存储 一个成员\
- 在 c++17之后, 建议使用
std::variant
类型来代替联合体
基本用法 union
定义一个 union:
union Data{
int i;
double d;
char str[10];
};
声明一个 union 对象
Data data;
何时使用 union?
- 当内存受限时
- 需要一个对象 在不同时候存储 不同类型的变量
- 底层编程中, 访问寄存器或硬件的数据结构时.
union 的内存对齐
union 占用的内存由两个东西决定
- 成员的类型中, 占用内存最大的基础类型
- 基础类型指的是, 如果是数组类型
int[]
, 那么基础类型是int
- 基础类型指的是, 如果是数组类型
- 所有成员中, 占用内存最大的成员
最终的内存必须满足
- 它是 最大类型所需内存的 整数倍
- 它足够容纳 最大的成员
union对齐规则和 struct 几乎没有区别.
都以 最大基本类型 为对齐数
例子
union Data{
long long i;
char j[10];
int k;
};
std::cout<<sizeof(Data);
long long
是 最大的类型, 它是8字节的.char j[10]
是最大的成员, 至少需要10个字节- 因此 内存必须是8的整数倍, 且大于等于10, 因此占用16
为什么要这么做?
这实际上是为了内存对齐而设计的.
匿名类型的 union
可直接构建匿名类型的 union
, 并声明它的对象
union {
int i;
double d;
char str[10];
} data; //声明了一个union对象
完全匿名的 union
- 允许构建完全匿名的 union, 如下面例子
- 它没有类型名, 也没有对象名
- 完全匿名的 union 不能处于全局作用域中
- 匿名后没有唯一的标识符,无法在全局范围内进行引用
#include <iostream>
int main() {
union {
int i;
float f;
}; //完全匿名的union.
i = 10;
std::cout << "i = " << i << std::endl;
std::cout << "f = " << f << std::endl;
// 访问 f,但其值是不确定的,因为 i 和 f 共享相同的内存位置
f = 3.14f;
std::cout << "i = " << i << std::endl;
// 访问 i,但其值是不确定的,因为 i 和 f 共享相同的内存位置
std::cout << "f = " << f << std::endl;
return 0;
}
与之对应的struct不能构建完全匿名的形式, 它必须有类型名, 或者给出对象名
为 union 对象初始化和赋值
- 可以使用
{}
来初始化- 只能初始化第一个成员.
- 可以用点运算符访问它的成员, 然后赋值
- 一旦赋值完成, 其他的成员将被暂时隐藏, 不应该被读取
- 可以对隐藏的成员重新赋值
union S {
int a;
double b;
};
S s; //第一个成员被初始化为0
int main() {
std::cout << s.a; //ok
s.b = 1.1; //使用第二个成员
std::cout << s.b; //ok
std::cout << s.a; //访问越界,但不会报错
}
需要自定义 默认构造函数/析构函数 的情况
- 当某个成员的默认构造函数是 非平凡的(Non-trivial), 合成的默认构造函数是
delete
的 - 此时需要自定义默认构造函数, 否则默认初始化将失败.
- 同理, 当成员具有非平凡的析构函数时, 也需要自定义析构函数
什么是平凡/非平凡的默认构造函数?
- 平凡默认构造函数 (Trivial default constructor): 编译器自动生成的构造函数,它不做任何事情,也不需要初始化任何成员。
- 非平凡默认构造函数 (Non-trivial default constructor): 需要执行一些操作的构造函数,例如初始化成员变量。
union U{
int a;
double b;
std::string c;
U() : a{} {} // 值初始化 成员a
~U(){} //析构函数
};
int main(){
U u; //默认初始化
u.a=1;
u.c="123";
}
联合体的析构函数问题
- 不需要在联合体的析构函数中析构所有成员
- 联合体同一时间只存储一个成员的值. 如果析构所有成员,你将会尝试销毁那些从未被初始化或者已经被其他成员覆盖的内存区域。
- 只需要在析构函数中析构当前活动的成员
- 为了实现该目的, 需要使用一个结构体来管理 union (一般用匿名 union)
- 然而这违背了 union 使用原则.
- 一般不建议使用自定义的类型作为 union 成员.
例子
#include <iostream>
#include <string>
struct MyUnion {
union { //匿名union作为成员
int i;
double d;
std::string s; //可能需要析构的东西
};
enum class Type { NONE, INT, DOUBLE, STRING } activeType; //使用一个枚举类型对象, 记录激活的成员
MyUnion() : activeType(Type::NONE) {}
~MyUnion() { destroy(); }
void setInt(int value) {
destroy();
i = value;
activeType = Type::INT;
}
void setDouble(double value) {
destroy();
d = value;
activeType = Type::DOUBLE;
}
void setString(const std::string& value) {
destroy();
new(&s) std::string(value);
activeType = Type::STRING;
}
private:
void destroy() {
switch (activeType) {
case Type::STRING:
s.~basic_string();
break;
default: break;
}
activeType = Type::NONE;
}
};
int main() {
MyUnion u;
u.setString("Hello, world!");
u.setInt(42);
u.setDouble(3.14);
return 0;
}
3 变量和初始化#
3.1 变量的基本概念#
变量和对象的区别
在 C++ 中,术语 “变量” 和 “对象” 通有一些细微的差别。
- 变量(Variable):
- 变量是程序中存储数据的命名内存位置。
- 变量有类型和名称,可以通过名称来引用和修改其存储的值。
- 变量的生命周期取决于其定义的作用域, 以及 存储类型说明符。
- 对象(Object):
- 当谈论一个无名的值, 往往称为"对象". 当然变量也可称为对象, 因为变量包含两部分: 名字和对象实体.
- 对象术语常用于
- 作为匿名临时量的物体,
- 某些字面量, 如自定义字面量
- 使用
new
构建并初始化的物体.
局部变量: 指的是块作用域中的局部的, 有命名的变量
- 比如函数体内定义的变量.
- 比如
for
循环中定义的局部变量
临时对象: 在表达式求值过程中创建的临时实例. 它们没有经过显式定义.
- 用于临时计算或传递给函数
- 或作为返回值时, 临时创建
不要混用 局部/临时, 变量/对象
声明和定义的区别
声明:
- 声明一个变量或函数表明了它的存在和类型
- 但并不分配存储空间
- 声明也可以发生多次
定义: - 定义时分配了存储空间, 并可能初始化该空间
- 对于变量,定义是创建该变量的实例
- 对于函数,定义是提供函数的实现
- 每个变量和函数必须且只能有一个定义
如何 定义变量
类型说明符+(多个)变量名
例如: int a;
3.2 结构化绑定声明 Structured Binding Declaration#
结构化绑定概念
- 是C++17引入的一种新特性
- 允许将对象的多个成员或元素绑定到新的变量名
- 当绑定时, 将对右边的值进行解构
- 可以对 结构体、内置数组、
std::array
、或提供类似元组API的对象 解构- 比如可以对
std::pair
,std::tuple
等类型实施结构化绑定 - 不能用 结构化绑定 对初始化器列表
std::initializer_list
解构, 因为它的元素存放在 private 指针数组上 - 类似的也不能解构
std::vector
对象
例子
- 比如可以对
#include <initializer_list>
#include <iostream>
#include <string>
#include <tuple>
struct MyStruct {
int i = 0;
std::string s;
};
int main() {
MyStruct ms{42, "hello"};
auto [u, v] = ms; //正确
auto [u1,v1] = std::make_tuple(10,11);
std::initializer_list<int> list ={1,2};
auto [u2,v2] = {1,2}; //错误
std::vector<int> vec{1,2};
auto [u3,v3] = std::vector<int>({1,2}); //错误
auto [u4,v4] = std::array<int,2>{1,2}; //正确
int a[2] = {1,2};
auto [u5,v5] = a; //正确
}
**不能对一般的标准库容器结构**
比如在解构 vector 时将发生两个错误
- 一个错误是由于 private 成员无法解构导致的
- 另一个是数量匹配错误, 因为 vector 的数据成员并非它的元素, 而是 size, 容量和指针
结构化绑定的规则
- 绑定的变量数目必须匹配
- 对函数返回值进行结构化绑定时, 必须显式指定数组的大小以使用结构化绑定
- 当解构结构体或类对象时, 其非静态成员必须为 public 的
class MyClass {
public:
int k=0;
int j=0;
private: static int i;
};
int MyClass::i = 42; //外部初始化静态成员
int main() {
auto [u6,v6] = MyClass(); //正确
}
3.3 变量声明和定义的关系#
分离式编译的要求: 声明和定义分开
- c++ 支持分离式编译,可以分开分割成多个文件.一个文件可能要用另一个文件的代码变量
- 因此声明和定义要分开
extern 说明符
作用
- 是"语言链接"说明符, 声明为具有静态存储期, 和外部链接属性的
- 当作用于模板时, 则可以显式模板实例化声明
[[#const对象和extern]]
extern
表示声明但不定义变量, 一般用于全局作用域- 由于声明可以多次, 因此
extern
声明语句可以重复多次 extern
语句中, 不能进行初始化 (除非是 const)- 对于内置类型, 不会分配存储空间
- 对于类类型, 不会调用默认构造函数
- 若同时使用 const 修饰则可以初始化, 并且该语句变为定义.
- const extern 必须在全局作用域 或命名空间中
- const extern 不能在局部作用域中
- 若用
extern
声明变量时, 但同时也初始化了, 则这条语句变为定义.- 这违反了
extern
的初衷, 编译器将警告 - 对于 const extern 则是正确的.
- 这违反了
extern int a; //这是声明
extern int a=2; //发出警告, 这是定义 ,此时相当于没有 extern
extern int a; //再次声明
int a; //错误, 不能再定义了, 重复定义
除了 `const extern`,还有一种情况可以在 `extern` 声明时初始化变量,那就是[[§16. 模板和泛型#6.1 变量模板|变量模板]]。
在 C++ 中,模板变量必须在每个编译单元中都有定义,因此可以在 `extern` 声明时初始化变量模板。
由 extern 声明的变量, 必须初始化后才能使用.
- debug 程序并不检查对象是否初始化.
- 当仅声明变量后, 若没有初始化, 则出错
extern int a;
int main(){
a=1; //错误, 但IDE不报错, 编译器报错
}
在函数内部不能初始化 由extern标记的变量
注意是不能初始化, 若它已经被初始化过了, 正常使用它, 是没问题的
extern int a; //声明
int a=2; //定义并初始化
int main(){
a=1; //赋值
}
extern "C" 声明
extern "C"
是C++中的一个关键字,它用于指定一个函数或变量的链接方式。它告诉编译器,这个函数或变量应该使用C语言的链接方式,而不是C++的链接方式。
在C++中,函数和变量的名称会被编译器进行名称修饰(也称为名称重整),以避免名称冲突。这种名称修饰会在函数或变量的名称前面添加一些额外的字符,例如函数的返回类型、参数类型等。
例如,一个C++函数 int foo(int x)
的名称可能会被编译器重整为 _Z3foo1i
。
但是,C语言不进行名称修饰,因此C函数的名称保持原样。
当我们使用 extern "C"
时,我们告诉编译器,不要对这个函数或变量进行名称修饰,而是使用C语言的链接方式。这意味着函数或变量的名称保持原样,不会被编译器重整。
extern "C" {
int foo(int x) {
return x * 2;
}
}
在这个例子中,函数 foo
的名称保持原样,不会被编译器重整,因此C代码可以正确地调用这个函数。
声明可以重复, 定义不能重复
- 一般地, 若某个文件需要跨文件地用其他文件的变量, 可以写一个声明, 但是多个文件不能重复定义一个变量
- 后面会提到
const
除外
- 后面会提到
3.4 初始化对象#
初始化的含义
当对象在创建时, 就被赋予了值, 这称为初始化
值初始化
https://en.cppreference.com/w/cpp/language/value_initialization
什么是值初始化 value Initialization?
当对象使用 空初始化器 构造时, 将实施 值初始化.
例子: 发生值初始化的场景
T(); //无名临时对象 T()
T{}
new T(); //动态分配的对象
new T{};
//创建有命名的变量对象时, 但使用了{}
T my_obj {};
//在构造函数中的成员值初始化
myClass::myClass(T &x, ...): memberX(x) {...}
myClass::myClass(T &x, ...): memberX{x} {...}
//调用基类的构造函数的值初始化
myClass::myClass(T &x, ...): BaseClass(x) {...}
//数组的初始化器列表不足时
int a[10]{1,2,3};
std::array<int,10> a {1,2,3};
//局部静态变量
void foo(){
static int a;
}
何时发生值初始化?
- 用一对空的小括号或大括号 创建无名临时对象时
- 用一对空的大括号 创建有命名的变量对象时
- 注意这里没有小括号的情况, 那是函数声明.
- 具有 动态存储持续时间的对象 由带有一对空括号或大括号组成的初始值设定项的 new 表达式创建时
- 一对空括号或空大括号 的成员初始化器 初始化非静态数据成员 或基类(调用基类构造函数)时
- 数组初始化时, 提供的值少于数组大小, 剩余的元素进行值初始化
- 在[[#局部作用域(Local Scope)|局部作用域]] 中定义静态变量, 但不给初始值时.
例外:
当使用空大括号初始化 聚合类对象时, 不是值初始化. 而是聚合初始化
值初始化的行为
- 内置类型被初始化为 0.
- 类类型将执行默认构造
直接初始化
https://en.cppreference.com/w/cpp/language/direct_initialization
即显式的调用构造函数, 并传入参数.
string mys("haha"); //调用接受一个const char*的构造函数
直接初始化的句法如下:
//1
T obj ( 实参 );
T obj ( 实参 1, 实参 2, ... );
//2
T obj { 实参 }; //T是内置类型
//3
T(other_obj) //这是表达式 而不是语句
T(实参1, 实参2, ...)
//4
static_cast<T>(other_obj)
//5
new T(实参列表, ...)
//6
classname::classname() : memberX(实参列表,...) {...}
//7
[实参](){ ... } //在lambda表达式中, 复制捕获
- 定义语句中, 用括号括起 一些参数/初始化器列表 来初始化某个对象.
- 定义语句中, 对于非类类型, 用花括号括起参数. 它不能是列表初始化
- 表达式中, 通过传入参数, 使用构造函数构建 纯右值临时对象
- 强制类型转换时, 如果括号中的参数恰好能 被
T
类型的构造函数使用 - 使用
new
分配并构造对象时, 传入参数直接初始化该对象 - 在成员列表初始化器中, 使用构造函数初始化成员
- 当 lambda 表达式按值捕获时, 将调用拷贝构造器, 并且将
explicit
调用
#include <iostream>
struct A {
A() = default;
explicit A(const A& a) { std::cout << "haha"; }
};
int main() {
A a1;
auto lambda = [a1] {}; //可以调用explicit 拷贝构造函数
lambda; //ok
}
列表初始化 (list initialization)
https://en.cppreference.com/w/cpp/language/list_initialization
列表初始化简介
- 列表初始化是c++11引入的新特性
- 它用一个花括号的参数序列来初始化一个对象.
句法
//直接列表初始化
T obj {参数表}; //1
T{参数表}; //2
new T{参数表}; //3
class myclass{T member {参数表}; }; //4
myclass::myclass():member{参数表} {...}; //5
//拷贝形的列表初始化 Copy-list-initialization
T obj = {参数表}; //6
func({参数表}); //7
return {参数表}; //8
obj = [{参数表}]; //9
obj = {参数表}; //10
U({参数表}) //11
class myclass{ T member = { 参数表 }; }; //12
解释如下:
- 用花括号初始化 命名的对象
- 用花括号初始化 匿名临时量
- 在 new 分配内存时
- 类非静态成员的 类内初始值
- 构造函数中, 成员初始化列表 语法中, 使用花括号.
- 和1类似, 只是采用了等号
- 函数的形参可用 大括号参数表 来初始化
- 函数的返回类型 可用 大括号参数表 来初始化
- 非常特殊的情况, 调用
operator[]
, 并且该函数的形参可用 大括号参数表 来初始化 - 非常特殊的情况, 赋值语句中, 调用
operator=
, 该函数的形参 用大括号参数表 来初始化 - 类型 U 的转换构造函数, 且该转换构造函数接受的类型为 V, 发生了由 inisailizer_list 到类型 V 的隐式转换.
- 或者 U 是一个函数, 接受类型 V
例子
- 或者 U 是一个函数, 接受类型 V
//直接列表初始化
int a{1} //1
int a = int{1}; //2
auto ptr = new int{1}; //3
class myclass{
int member {1}; //4
myclass():member{1}{} //5
};
//拷贝形的列表初始化 Copy-list-initialization
int a = {1}; //6
std::pair<int,int> func(std::initializer_list<int> a){
return {1,1}; //8
}
func({1}); //7
struct myclass2(){
void operator= (std::vector<int> v){};
void operator[] (std::vector<int> v){};
};
myclass2 a;
a[{1}]; //9
a = {1}; //10
struct V{
V(std::initializer_list<int> v){};
};
struct U{
U(const V &v){};
}
auto u = U({1}); //11
列表初始化和 [[§7. 类基础#5.1 构造函数初始化列表|构造函数初始化列表]] 是两个不同的概念
初始化器列表 std::initializer_list
- 在头文件
initializer_list
中定义 - 是一个模板类型, 接受一个类型参数 T
- 它是一种轻量级代理对象
如何使用 initializer_list
?
- 它本身是一种容器, 可以通过 begin, end 返回它的迭代器
- 可以通过范围 for 语句遍历
auto l = {1, 2, 3, 4};
std::cout << l.begin()[2];
为何推荐使用花括号的列表初始化?
- 它能保证大部分的初始化语法形式统一
- 不会和函数声明 混淆
- 可以兼容不同的构造函数
- 如果使用空的
{}
则调用的是默认构造器 - 如果 括号内是 相同类型的左值, 则使用拷贝
- 如果 括号内是 相同类型的右值, 则使用移动
- 如果 构造函数可以接收
initializer-list<>
作为参数, 则也可以调用.
- 如果使用空的
struct A
{
A() { std::cout << "A()" << std::endl; }
A(const A&) { std::cout << "A(const A&)" << std::endl; }
A(A&&) { std::cout << "A(A&&)" << std::endl; }
A(const int&) { std::cout << "A(const int &)" << std::endl; }
A(const std::initializer_list<int>)
{
std::cout << "A(const std::initializer_list<int>)" << std::endl;
}
};
int main(){
A a1 = {1}; //优先使用 initializer_list 匹配
}
自动构造 initializer_list 对象的情况
- 大括号括起来的初始化列表 用于 列表初始化类对象, 其中相应的构造函数接受
std::initializer_list
参数 - 大括号括起来的初始化列表 用于 赋值操作, 或作为函数参数传入
- 这些赋值和函数必须 接受一个 initializer_list 对象.
- 大括号括起来的初始化列表, 用于初始化 auto 推导定义的变量
- 包括在范围 for 语句中.
自动构造 initializer_list 对象的例子
struct myclass{
myclass(std::initializer_list<int> ini){}
};
void func(std::initializer_list<int> ini){}
int main(){
// 下面的情况都将隐式构造 initializer_list对象
myclass A = {1,2,3};
func();
auto Ini = {1,2,3};
for (auto k : {1,2,3}){
}
vector<int> myvec = {1,2,3}; //该构造函数接受一个initializer_list对象, 下面同理
vector<int> myvec {{1,2,3}};
vector<int> myvec ({1,2,3});
}
区别术语 初始化列表和 std::initializer_list
- 初始化列表(initializer list) 是一种语法结构
- 也翻译为初始化器列表
- 术语列表初始化, 指的就是使用 初始化列表 来初始化
std::initializer_list
是类模板- 并非所有的列表初始化都会使用
std::initializer_list
- 并非所有的列表初始化都会使用
- 当使用
{}
来初始化对象时, 可能发生隐式转换- 有可能构造一个 initializer_list, 然后用它来初始化对象, 这取决于对象类型中是否存在相应的构造函数
- 也可能不发生隐式转换, 比如对内置数组初始化时
- 参考[[#自动构造 initializer_list 对象的情况]]
- 参考 [[§2. string, vector, array#数组和 auto, decltype, initializer_list]]
例子
一些类类型 有接受一个 std:: initializer_list 对象的转换构造函数, 这时, 既用到术语 initializer list, 又用到 std::initializer_list
vector<int> vec = {1,2,3};
- 这是一个列表初始化
- 它将创建
std::initializer_list<int>
临时量 - 然后通过转换构造函数完成构造
列表初始化的基本例子
即可以用{}来进行赋值,比如
int a{5};
int a(5);
int a={5};
//多个变量的情况
int a{1},b{2}; //正确
int a,b={1,2}; //!错误
这些都是一样的效果
列表初始化不允许缩窄转换
用列表初始化时, 如果变量类型不同, 可能丢失数据.
例如当从double变为int时, 将发生缩窄转换, 列表初始化不允许该转换.
而一般的初始化可能允许缩窄转换
long double pi=3.1415926;
int a{pi}, b={pi}; //列表初始化. 出错, 不允许缩窄转换
int c(pi), d=pi; //非列表初始化. 正确, 但丢失信息
列表初始化 和 拷贝初始化的区别
在c++ primer 76页的描述如下
![[Pasted image 20240228112345.png|850]]
这是不准确的, 当采用列表初始化时, 它也可以用等号, 但这不算拷贝初始化 (从c++11开始)
一个[[§7. 类基础#5.5 聚合类|聚合类]]初始化的例子:
struct myclass
{
int a;
int b;
};
myclass A={1,2}; //这不是拷贝初始化, 而是列表初始化 (从c++11开始)
聚合初始化 (Aggregate Initialization)
https://en.cppreference.com/w/cpp/language/aggregate_initialization
聚合类型
- array数组是一种聚合类型
- 类类型 (通常为 结构体 或 联合体), 若满足如下也可称为聚合类型
- 没有用户声明的, 或继承的 构造函数
- 数据成员没有类内初始值(c++14之后 这条不需要了)
- 没有虚基类
- 等等... 情况比较复杂, 参考链接aggregate_initialization
聚合类类型的例子参考 [[§7. 类基础#聚合类]]
聚合初始化
- 当使用一个初始化列表对 聚合类型对象 初始化时, 称为聚合初始化
- 聚合初始化 属于 [[#列表初始化 (list initialization)|列表初始化]]
句法
T object = { arg1, arg2, ... }; //1
T object { arg1, arg2, ... }; //2 (since C++11)
T object = { .des1 = arg1 , .des2 { arg2 } ... }; //3 (since C++20)
T object { .des1 = arg1 , .des2 { arg2 } ... }; //4 (since C++20)
拷贝初始化 (Copy Initialization)
https://en.cppreference.com/w/cpp/language/copy_initialization
https://zh.cppreference.com/w/cpp/language/copy_initialization
拷贝初始化的句法
c++11之后的句法如下:
T object = other; (1)
function(other) (2)
return other; (3)
throw object; catch (T object); (4)
T array[N] = {other-sequence}; (5)
- 对象声明并使用拷贝形式初始化语句: 典型的拷贝初始化, T不是引用类型. other是 被"拷贝"的对象
- 当函数/可调用对象 按值传参时, 将发生拷贝, 这导致拷贝初始化
- 按值返回时, 导致拷贝初始化
- 当按值抛出或捕获异常时.
- array 内置数组类型的 聚合初始化, 也视为一种拷贝初始化
两个特殊的情况:
- 拷贝初始化和列表初始化的联系
`T array[N] = {other-sequence};`
这是拷贝初始化, 同时也是聚合初始化, 而聚合初始化是列表初始化的一种
- 形式很像拷贝初始化, 但不是
`T object = {other}`
从c++11开始, 这不再是拷贝初始化, 而是列表初始化
当采用*拷贝形式*的初始化, 若不是列表初始化的形式, 则可归类为 拷贝初始化
T obj1;
T obj2(obj1) ; //调用拷贝构造函数, 但这是直接初始化
拷贝初始化的作用
- 一般情况下, 将调用拷贝构造函数 或 [[§7. 类基础#定义 转换构造函数|转换构造函数]] 完成初始化
- 注意: 必须是非 explicit 才能成为 拷贝构造或转换构造
默认初始化
默认初始化的时刻
- 若定义(而非仅声明)变量时, 没有指定初值, 则发生默认初始化
- 当构造对象时, 类类型 成员没有显式初始化, 则发生默认初始化
- 默认初始化的规则取决于变量的类型和定义位置
[[#8 作用域的概念]]
内置类型的默认初始化
在局部作用域和其他作用域下, 有不同的行为
- 局部作用域变量: 函数内部定义的内置类型变量不会被默认初始化。它们的值是不确定的,取决于内存中之前存储的内容。
void func() {
int x; // 未初始化,值不确定
bool b; // 未初始化,值不确定
}
- 全局变量、命名空间作用域变量、静态变量:
- 定义在函数外部、命名空间作用域或使用
static
关键字修饰的内置类型变量会被默认初始化为 0。
- 定义在函数外部、命名空间作用域或使用
int global_x; // 初始化为 0
static int static_x; // 初始化为 0
namespace my_namespace {
int x; // 初始化为 0
}
类类型的默认初始化
- 类类型拥有默认构造函数,默认初始化会调用 默认构造函数
- 如果没有定义默认构造函数,编译器可能会合成默认构造函数
class MyClass {
public:
int x;
MyClass() : x(0) {} // 默认构造函数
};
MyClass obj; // 调用默认构造函数,x 被初始化为 0
复合类型默认初始化
- 数组:
- 内置类型数组:
- 全局/文件作用域(静态): 所有元素初始化为 0
- 局部作用域: 元素值不确定。
- 类类型数组: 调用每个元素的默认构造函数。如果没有默认构造函数,则会报错
- 内置类型数组:
int global_arr[5]; // 全局,所有元素初始化为 0
struct MyStruct {
int x;
MyStruct() : x(0) {} // 默认构造函数
};
MyStruct struct_arr[3]; // 调用 MyStruct 的默认构造函数初始化每个元素
- 结构体 (struct)
- 没有构造函数: 逐个成员进行它们自己的默认初始化。
- 有构造函数: 调用构造函数进行初始化。如果没有定义构造函数,编译器会生成一个默认的构造函数,该构造函数会对成员进行默认初始化。
struct Point {
int x;
int y;
};
Point p1; // x 和 y 的值不确定
struct Line {
Point start;
Point end;
};
Line l1; // start 和 end 的成员 x 和 y 的值不确定
* 使用未初始化的复合类型变量,特别是访问其成员或元素,会导致未定义行为。
* 强烈建议始终对复合类型进行显式初始化,即使是全局或静态变量,以避免潜在的错误和提高代码可读性。
3.5 标识符#
标识符
- 标识符就是给程序中的各种元素(比如变量、函数、类等)起的名字。
- 标识符由字母,数字,下划线构成; 数字不能作为开头,不能用
-
- 不能用保留字作为标识符
3.6 存储类别说明符#
存储类型说明符
auto, register (现代 c++弃用), [[§1. 变量和基本类型, 类别#static 说明符|static]], [[§20. 多线程基础#thread_local 关键字|thread_local]], [[§1. 变量和基本类型, 类别#extern 说明符|extern]], mutable
存储类型说明符 | 作用 | 特点 | 示例 |
---|---|---|---|
auto | 自动类型推导 | 根据初始化表达式自动推断变量类型 | auto i = 42; |
register | 建议编译器将变量存储在寄存器中(已弃用) | 提高访问速度,但编译器可能忽略 | register int x; |
static | 1. 限制全局变量作用域; 2. 保留局部变量值; 3. 表示类成员属于类 |
生命周期长,初始化仅执行一次 | static int count = 0; |
thread_local | 为每个线程创建独立的变量副本 | 线程安全,生命周期与线程相同 | thread_local int thread_id; |
extern | 声明一个变量在其他文件中定义 | 用于在多个文件中共享全局变量 | extern int x; |
mutable | 允许在 const 成员函数中修改成员变量 | 仅用于类成员变量 | mutable int count = 0; |
存储期的概念
存储期是对象的属性
- 静态存储期
- 具有静态存储期的对象在程序的整个运行期间都存在
- 如果没有显式初始化,会被自动初始化为 0 或默认值
- 自动存储期
- 通常在函数内部或代码块
{ }
中声明。 - 当超出块作用域, 将被销毁
- 一般存储在栈
- 通常在函数内部或代码块
- 动态存储期
- 通过动态分配内存(如使用
new
、malloc
等运算符)获得的对象具有动态存储期, - 必须使用
delete
或free
来释放
- 通过动态分配内存(如使用
- 线程存储期
- 与特定的线程相关联。具有线程存储期的对象在线程开始时被创建,在线程结束时被销毁
存储期 | 生命周期 | 内存管理 | 关键字 | 存储段 |
---|---|---|---|---|
自动 | 局部作用域内 | 自动 | 无 | 栈段 |
动态 | 手动控制 | 手动 | new , delete |
堆段 |
线程 | 线程内 | 自动 | thread_local |
栈段或堆段(具体取决于实现) |
全局静态区 | 自动 | static |
数据段(.data/.bss) |
static 说明符
声明变量作用:
- 在全局/命名空间中, 声明为具有静态存储期属性和内部链接属性
- 它的有效性只在该文件中. 有助于避免命名冲突
- 在块作用域中, 声明为具有静态存储期属性, 只被初始化一次.
- 它具有静态存储期, 因此不会在作用域结束后被销毁.
- 在类中声明 变量/函数 成员, 则使得该成员不和类型的任何实例绑定.
- 静态成员是共享的
- 静态函数可以直接通过类名调用.
可用于声明 变量 (函数参数声明除外) 或者 函数
可用于结构化绑定声明中
匿名联合体可声明为 `static` 的
一般的全局变量和 static 全局变量有什么区别?
一般的全局变量:
如果都在头文件 a.h
声明/定义, 一般的全局变量将引发错误.
- 当多个文件(实际上一个文件也会导致) 使用
include "a.h"
时, 将发生链接冲突. - 由于
a.h
经过编译后的obj
有这个变量了, 同时cpp
文件include
了它, 相当于代码复制了一份, 因此也有相同的变量.
一般的全局变量有使用两种模式
- 只在头文件中定义, 在源文件中使用
extern
声明 (且不能转为定义), 这就告诉编译器在链接时可以在其他obj
中寻找符号.
//-----------a.h
#ifndef A_H
#define A_H
int b = 52; //在头文件声明
#endif
//-----------main.cpp
#include <cstdio>
extern int b;
int main() {
printf("%d\n", b);
}
- 在头文件中使用
extern
声明, 然后在一个源文件中定义
//-----------a.h
#ifndef A_H
#define A_H
extern int b; //在头文件声明
#endif
//-----------a.cpp
#include "a.h"
int b = 44; //在源文件定义
//-----------main.cpp
#include "a.h"
#include <cstdio>
int main() {
printf("%d\n", b);
}
static 全局变量
若它在头文件中定义, 每个 include
它的文件, 实际上都有自己的一份 static 变量的拷贝,
它们只读取和修改此文件中的拷贝的版本
//-----------a.h
#ifndef A_H
#define A_H
static int b = 1;
void func(); //声明
#endif
//-----------a.cpp
#include "a.h"
#include <cstdio>
void func() {
b = 45;
printf("在func函数中: b = %d\n", b);
printf("地址是: %p\n", &b);
}
//-----------main.cpp
#include "a.h"
#include <cstdio>
int main() {
func();
printf("在main函数中: b = %d\n", b);
printf("地址是: %p\n", &b);
}
/* 打印结果
在func函数中: b = 45
地址是: 00007FF79514D000
在main函数中: b = 1
地址是: 00007FF79514D004
*/
- 可以发现,
func()
只是修改了 a.cpp 中的静态变量, 不影响 main.cpp 中的静态变量 - 它们的地址都是不同的
4 复合类型,指针和引用#
4.1 (左值)引用#
^960b61
- c++11中新增了右值引用(rvalue reference)
- 本节中的都是左值引用
[[§4. 表达式概念和一些特殊表达式#1.3 左值和右值]]
引用介绍
引用本质
- 引用只是变量的别名
- 没有 引用的引用, 另外注意 [[§4. 表达式概念和一些特殊表达式#引用折叠规则|引用折叠]] 的情况
- 引用不在某个内存中
- 引用必须被初始化
- 这是因为引用建立之后, 不能改变它引用的对象. 即一开始就绑定了
- 大部分情况下, 可以认为, 引用本身就是"顶层 const的变量". ([[#顶层和底层的含义]])
int a=5;
int &b=a;
用一条语句定义多个引用
在一条语句中定义多个引用, 每个地方都要加&
int a=5, b=4;
int &c=a,&d=b;
因此推荐使用 int &name = name_1
而不推荐使用 int& name = name_1
因为第二种用法有迷惑性,比如:
int& c = a, d = b; //此时只有c是引用, d不是引用, 这种写法会误导人
4.2 指针#
定义指针的写法
定义时不一定要初始化
如果在一行定义多个指针, 每个都要加*
推荐这样写: int *a, *b
不推荐这样: int* a, b //造成干扰,此时b并不是指针
int *a=&b;
指针的指针
指针是对象, 因此可以指向另一个指针对象.
对于多层指针, 同样有规范的写法
int b=5;
int *a1=&b;
int *a2=&b;
int* *s=&a1, *t=&a2; //出错, t是单层指针
int **s=&a1, **t=&a2; //正确 都是双层指针 int **类型
在出错的那行, t
是 int *
类型, int*
的写法有误导性
同样要遵从*
和变量名贴紧.
指针的类型必须匹配
指针的类型和它指向的类型必须严格匹配
在继承关系中, 可以不完全匹配(多态性). 此时将发生隐式转换, 转换后可以认为仍然匹配的
指针存储的内容是对象地址
直接打印指针,将看到 指向物的地址
指针的值有如下情况
- 指向某个对象
- 指向邻居对象所处空间的下一位置
- 常量表达式
nullptr
- 无效指针(其他情况)
拷贝或访问 无效指针 都会引发未定义问题.
用解引用符号访问
int a=5;
int *b = &a;
cout<<*b; //访问了a
nullpter 和 NULL
NULL 是宏变量(与 c++语言无关), 定义在 cstdlib 中
nullpter 是 std::nullptr_t
类型的纯右值
int *a=nullptr;
int *a=0; // 必须加上 #include cstdlib
int *a=null; // 必须加上 #include cstdlib
1. C++中 NULL 定义就是整数字面量0
2. 对于 C++函数,由于存在重载,使用 NULL 而不是 nullptr 可能导致函数走错重载。
3. C 中定义 NULL 为(void* )0,确实是代表空指针。使用时隐式转换成对应的需要类型的空指针。
4. C++中 void 指针不能隐式转换成其他指针,所以无法按照 C 那样定义。
5. C++中保留 NULL 可以兼容一些 C style 的代码,对于这些库,不会使用到函数重载,不会产生对应的问题。但对于纯 C++程序,请使用 nullptr 表示空指针
void* 指针
void*
指针可存放任何对象的地址- 但对该地址中是什么类型的对象不了解
- 因此不能直接操作
void*
指针所指的对象 - 一般要通过[[§4. 表达式概念和一些特殊表达式#现代c++的强制类型转换|强制类型转换]], 然后再使用
int a = 1;
void *p = &a;
int b=*(int*)p; //先强制转换为 int*类型, 然后解引用
4.3 数组#
[[§2. string, vector, array#定义内置数组|内置数组]]
5 const限定符#
const 和 volatile 合称为 vc 限定符
5.1 基本概念#
const 介绍
- 当使用 const 说明符修饰对象时, 则告诉编译器, 该对象不被程序修改
- 当第一次声明 const 对象时, 必须初始化, 否则出错
- 对于 extern const 对象, 至少有一个有初始化
const int buffSize=512; //这表明 buffSize变量是常数
const对象仅在文件内有效
-
编译时,编译器会用当前文件中的const变量的值, 替换对应的地方.
-
为了编译替换, const变量在当前文件中必须有初始值.
-
在多个文件中, 同名 const变量在每个文件中都要有初始值
-
为了不算作重复定义, 默认情况下, const对象仅在当前文件有效.
-
在不同文件中 同名的const变量, 实际是独立的, 他们可以有不同的值.
const对象和extern
- 加了extern 前缀的 const变量, 可以在多个文件中共享, 并且可以重复声明.
- 加了extern 前缀的 const变量, 出现在不同文件中, 不再是独立的变量.
- extern const 只在一个文件中定义, 在其他文件中是声明
- extern const 变量的初始值, 可以不是常量表达式
// file1.h
extern const int a = func(); //定义一个 extern const, 非常量表达式初始化
//file2.cpp
#include "file1.h"
extern const int a; //声明
const和常量表达式的关系
- const对象 不一定是 常量表达式
- const对象 可以用 非常量表达式 初始化
- 如果 const对象 被 常量表达式初始化, 则该对象也成为 常量表达式
int a=1;
const int b=a; //b不是常量表达式
std::array<int,b> arr; //出错, array不接受 非常量表达式 作为维度
5.2 const引用#
const引用
- 常量引用, 即 对一个常量的引用
- 不能通过常量引用来修改 所引用的变量
引用和被引用的 可行关系如下表:
引用类型 | 变量类型 | 是否可行 |
---|---|---|
常量引用const int & |
非常量 int |
是 |
常量引用const int & |
常量 const int |
是 |
非常量引用int & |
非常量 int |
是 |
非常量引用int & |
常量 const int |
否 |
表格总结: 非常量引用, 不能引用 常量
例子:
int a = 5;
const int &b=a; //这是可行的,表明不能用别名”b”来修改
const int a = 5;
int &d=c; //这是不可行的,必须要用常量引用
const int &a = 5; //这是可行,5本身是常量类型
不能将const引用绑定到不相同的类型
引用不像指针那样, 类型不必严格匹配, 但const引用则必须严格匹配
double db_num = 8;
const double &b = db_num; //可行
const int &c = db_num; //不可行,但编译器可能不会报错
由于 db_num
是double类型,编译器创建一个临时对象,并用它来转化,相当于:
double db_num = 8;
const int temp = db_num; //这里发生错误
const int &c = temp;
这里的第一句是错的
将非常量引用绑定到其他类型,也是坏习惯,非法的‼
例如:
double a=8.1;
int &b = a;
由于类型不同,会创建临时量转换:
int temp = a; //temp等于8
int &b = temp;
此时b
引用的是temp
, 并没有引用a
, 这违背了程序的原本意图.
c++将这种行为视为非法!
const引用,可以引用一个非const的对象
int a = 1;
const int &b=a; //这是可行的
即使a不是常量,也能行. 这是因为初始化时,会忽略顶层const;
但不能通过b来修改.
5.3 顶层和底层const#
顶层和底层的含义
大致可以用以下图像,形象化地描述底层和顶层的区别
![[顶层和底层示意图]]
顶层const可表示 任何对象 是常量
而底层const, 与指针 引用等符合类型有关
顶层和底层const指针
- 顶层const指针: 指针本身是常量,不能改变指向
- 底层const指针: 指针指向的对象是常量.
const int *a //这是底层const, 指向的内容不变
int *const a //这是顶层const,不能改变指向
const int *const a //既是顶层也是底层的const
顶层const指针
const型指针(常量型指针)
^febb8a
- 指针是对象,因此有const型指针, 这是一种顶层const
- 对于const指针, 不能修改它的指向地址
int a=5;
int b=6;
int * const b=&a; //这是一个const指针,是顶层const
*b=7;//合法
b=&c;//非法
多层指针和 const
const int **ptr1; // 底层const
int const **ptr2; // 也就是 const int **
int * const * ptr3; //
int ** const ptr4
const int ** const ptr5;
int const ** const ptr5; //等价于上面
const int * const * const ptr6;
底层const指针
const变量,必须用底层const指针
const int a = 1;
const int * p = &a; //正确,这是底层const指针
const引用,必须用底层const指针
int * func(const int &a)
{
return &a;
//这是非法的 此时a为const引用,必须用底层const指针
}
const int * func(const int &a)
{
return &a;
//合法,返回一个底层const指针
}
底层const指针,可以指向非常量
int a = 1;
const int & b = a; //正确
总之,指针或引用可以设置为更严格的const.
5.4 常量表达式 和 constexpr#
常量表达式
指不会改变的值. 且在编译时, 就得到计算结果的表达式
const int a=4; //用常量表达式初始化的 const变量, 也是常量表达式
const int b=a+4; //是常量表达式
int c=8; // 不是常量表达式
const int d = c; // 不是常量表达式
const int d=func(); //取决于func是否返回一个常量表达式
int func() {return 1;}
//返回的是int类型的变量,且值为1, 虽然1是常量表达式, 但按值返回后创建的临时对象并不是 常量表达式
const
constexpr 类型, 字面值类型
- 允许将变量声明为
constexpr
类型, 以此让编译器验证 其是否为常量表达式. - 声明为
constexpr
后, 变量也成为const
变量 - 只有字面值类型才能 声明为
constexpr
- 字面值类型包含: 算数类型, 引用, 指针 等
- 自定义的类和一些标准库中的类, 比如
string
等都不是字面值类型
constexpr int d=get_size(); //当get_size()是一个constexpr函数时,才能通过编译
指针或引用 的constexpr型
- 对指针和引用 声明为
constexpr
时,有严格的限制 constexpr
指针 初始值必须是nullptr
或0
, 或者是固定地址的对象- 函数体内定义的 变量 (除了静态变量) 都在非固定地址中, 因此不能用
constexpr
指针 - 全局变量, 静态存储期的局部静态变量 可以用
constexpr
指针
- 函数体内定义的 变量 (除了静态变量) 都在非固定地址中, 因此不能用
constexpr指针是顶层的
constexpr int *a=nullptr; //这使得a是一个常量指针
const int *b=nullptr; //a是指向常量的指针
6 volatile 限定符#
volatile 的用途
- 它是类型修饰符
- 定义变量为 易变类型
- 告诉编译器变量可能 在程序的控制之外 被修改, 编译器在访问这个变量时不应该对其进行优化, 以确保每次都直接从内存中读取该变量的值
volatile
不能和constexpr
同时使用
volatile int flag = 0;
void interrupt_handler() {
flag = 1; // 假设这是一个中断服务例程中的代码, 该代码可能在其他线程中调用
}
int main() {
while (flag == 0) {
// 等待中断发生
}
// 当中断发生并且中断服务例程将flag设置为1时,循环将终止
return 0;
}
- 变量在程序之外被改变的情况
- 硬件事件
- 其他并发执行的线程
- 一个典型的`const volatile`变量的例子是一个只读的硬件寄存器
- 寄存器的值可能会因为硬件事件而改变(因此是`volatile`)
- 但作为只读存储, 程序不应该尝试修改它(因此是`const`)
const volatile
- 二者不是冲突的
const
的含义是 变量不被程序修改volatile
的含义是 变量在程序外可能被修改
- 二者可以同时使用, 但存在影响
const volatile
修饰的变量不视为常量表达式- 因此不能用于定义普通数组长度 [[§2. string, vector, array#定义内置数组]]
//全局作用域, 将发生默认初始化
const int a = 1; //a是常量表达式
const volatile int b = 3; //b 不是常量表达式
int c =3;
int arr1[a];
int arr2[b]; //可变长数组, 不能出现在全局作用域
int arr3[c];
7 类型别名和类型推导#
7.1 类型别名#
typedef 的使用
可以给类型创建别名, 用关键字 typedef
, 比如
typedef int zhengshu;
typedef int *intptr; // intptr 是 int* 类型
用using指定类型别名
在新一些的c++中,可以用 using
来声明别名
using
的语法更加直观.
using SI = Sales_item;
const和指针别名, 产生混乱
typedef int *intptr;
const intptr a; //常量指针
const int *b; //指向常量的指针
这里可见他们的区别,当 intptr
将 int *
变为一个整体,那么 const
将作用在 intptr
整体上,变成顶层 const
.
为了防止混乱, 尽量不要给指针类型取别名.
在作用域中定义类型别名时, 最好在开头
如果定义在中间, 可能引起困惑.
例子
typedef int len;
struct A{
len a; //1. 实际上是int
typedef float len; //定义在中间 会发生什么?
len b; //1. 实际上是float
};
- 这是因为
typedef float len
的作用域从第二行开始, 到类型定义末尾结束 - 因此第一个成员, 使用的是全局作用域的别名.
更规范的写法为
typedef int len;
struct A{
typedef float len;
::len a;
len b;
};
7.2 auto#
auto 声明变量
- 用
auto
说明变量类型时必须显式初始化, 否则编译器无法推断 auto
推断在编译期发生, 必须在编译时就能推断变量的类型, 而不是在运行中- 当在一条语句中同时声明多个变量时, 总是从左到右地推导
- 使用条件表达式初始化
auto
声明的变量时,编译器总是使用表达能力更强的类型
int a = 0;
auto b = a; //b自动设为int型
auto *p = &a, c = 10; //auto 推导为 int类型
auto i = true ? 5 : 8.0; //推导为double类型
auto 声明成员变量
- auto 不能声明非静态成员变量
- 因为在定义类型时, 无法初始化非静态成员, 此时无法通过 auto 推导
- auto 可以声明静态成员变量
- 在 c++17 之前, 必须使用
const
- 在 c++17之后, 可以不使用
const
- 在 c++17 之前, 必须使用
auto不能同时声明两种不同的类型
auto a, b = 1, 1.2; //这是不可行的
auto 推导规则
[[§4. 表达式概念和一些特殊表达式#万能引用]]
对于一般的情况(非万能引用)
auto
一般会忽略cv
限定- 如果使用引用或指针, 即
auto *
或auto &
, 则保留底层cv
- 顶层
cv
必须手动说明
- 如果使用引用或指针, 即
- 默认不保留引用.
解析对象的类型 | 解析结果 |
---|---|
引用类型 | 非引用类型 |
函数名字 | 函数指针 |
数组 | 指针 |
例子 |
void f(){};
int main(){
int x1 = 1;
int &x2 = x1;
const int &x3 = x1;
int &&x4 = 1;
const int *p = &x1;
int x5[3] = {1, 2, 3};
auto y1 = x1; //int -> int
auto y2 = x2; //int& -> int
auto y3 = x3; //const int& -> int
auto y4 = x4; //int&& -> int
auto y5 = p; //const int* -> const int* 保留底层const
auto y6 = f; //void() -> void(*)()
auto y7 = x5; //int[3] -> int*
auto &y8 = x3; //const int& -> const int& 保留底层const
}
对于初始化器列表
- 直接初始化形式
auto X{a}
, 括号中必须只有单个对象, 推导为类型T
- 拷贝初始化器的形式
auto X={a,b,c}
, 推导为std::initializer_list<T>
, 不允许缩窄转换, 且元素必须相同
万能引用的情况
auto &&
是万能引用, 将左值解析为左值引用, 将右值解析为右值引用- 如果解析为左值引用, 将保留底层
cv
void f(){};
int main(){
int x1 = 1; //左值
int &x2 = x1; //左值
int &&x3 = 2; //左值
//这三个都是 int &
auto y1 = x1;
auto y2 = x2;
auto y3 = x3;
auto y4 = f; //void (&)()
auto y5 = 1; //int &&
}
7.3 decltype 类型指示符#
现代 c++语言核心特性解析
decltype 解析规则
[[§4. 表达式概念和一些特殊表达式#2. 值类别 (左值和右值)]]
当 decltype(e)
时, 有如下结果:
注意:
- 这里的 e 是非纯标识符表达式, 不是纯粹的标识符(变量名字, 函数名字).
- 对于变量名字, 可以加一层
()
将其转换为左右值表达式.
e 的内容 | decltype 解析结果 |
---|---|
e 是类型为 T 的左值 表达式 |
T& |
e 是类型为 T 的将亡值 表达式 |
T&& |
e 是类型为 T 的纯右值 表达式 |
T |
e 是函数/函数对象 的调用 表达式 | 函数/函数对象的返回类型 |
当 e 是未加括号的标识符名字, 比如未加括号的函数名字, 变量名字时, 有如下规则.
e 的内容 | decltype 解析结果 |
---|---|
e 是无重载的函数名 | 非函数指针的函数类型 |
e 是重载可见的函数名字 | 解析失败 |
e 是类型为 T 的变量名字, 或类成员名字(类成员访问表达式) |
T |
例子
int *p=nullptr; // T = int*
decltype(*p) x2; //*p是运算表达式, 且解引用的结果是左值, 解析的类型为引用类型 int&
decltype(&p) x3; //&p是运算表达式, 且取地址运算的结果是纯右值, 解析的类型为指针 int**
struct Num {
int x = 0;
int y = 0;
};
void func(int a);
Num num;
Num *p_Num = #
const Num *cp_Num = #
Num &lr_Num = num;
Num &&rr_Num = {};
using T1 = decltype(num); //Num
using T2 = decltype(p_Num); // Num *
using T3 = decltype(cp_Num); // const Num *
using T4 = decltype(lr_Num); // Num &
using T5 = decltype(rr_Num); // Num &&
using T11 = decltype((num)); // Num &
using T22 = decltype((p_Num)); // Num * &
using T33 = decltype((cp_Num)); // const Num * &
using T44 = decltype((lr_Num)); // Num &
using T55 = decltype((rr_Num)); // Num & 这是因为(rr_Num)是左值
using Tf1 = decltype(func); // void (int)
using Tf2 = decltype(&func); // void (*)(int) 函数指针, 此时&func是一个void(*)(int)的右值
using Tf3 = decltype((func)); // void (&)(int)
using Tm = decltype(num.x); // int 解析一个 类成员访问表达式
注意: 对指针解引用时, 得到的是左值, 可以通过这个左值来修改内存信息.
为什么加一层括号会改变 decltype 的行为?
名词解释
- 标识符: 标识符是用于标识某些元素(如变量、函数、数组、类型等)的名称
- 标识符表达式 不属于 左右值的范畴
- 类访问表达式: 指的是用于访问类的成员(属性或方法)的表达式。类访问表达式的主要形式有两种:使用点运算符(
.
)和箭头运算符(->
)
原因
当对 标识符使用 ()
时, 它构成一个左值表达式. 而不再是纯标识符表达式
这影响了 decltype 的行为
decltype 和 cv 限定符
- 一般地, decltype 解析 标识符表达式时, 将保留 cv 限定
- 特殊的, 解析不加括号的 类成员访问表达式 时, 对象的 cv 被忽略
- 定义在类型中, 成员本身的 cv 限定不会忽略.
例子
struct Num {volatile int n = 0;};
const Num num; //对象是const的
using T = decltype(num.n) ; //忽略了const, 结果为 volatile int
using T = decltype((num.n)) ; //(num.n) 是左值表达式, 此时不忽略const, 结果为 const volatile int &
decltype(auto)
[[# decltype 解析规则]]
- 告诉编译器用
decltype
的推导表达式规则来推导auto
decltype(auto)
不能结合指针、引用以及 cv 限定符- 不能用于函数形参说明符
- 可以作为非类型模板形参 的类型说明符
template<decltype(auto) N>
int func(int (&a)[N]);
8 作用域的概念#
8.1 作用域介绍#
作用域(scope)指的是程序中标识符(如变量、函数等)的可见范围和生命周期。
了解作用域有助于管理变量的生存期和访问权限,避免命名冲突。
作用域运算符
若要访问一个局部作用域内声明的名字, 可以通过 作用域名称::目标名
来访问.
- 适用于结构体和 class 类型, 枚举类型, 命名空间
- 但不适用于访问块作用域
若在局部作用域中, 存在和外部同名 的局部变量,
- 如果作用域是有名字的, 比如类作用域, 或者命名空间, 则可以通过
作用域名字::变量名
来访问其中的变量. (前提是可见且权限允许) - 对于全局变量, 则只要加上
::
即可 - 如果不存在同名的, 则可直接访问
int a=1; //全局变量
namespace haha{
int a=10;
}
int main()
{//子作用域
int a=2; //局部变量
{//子子作用域
int a=3;
{//子子子作用域
std::cout<<::a; //访问的是全局变量 a=1
std::cout<<haha::a; //访问的是haha下的
}
}
}
全局作用域(Global Scope)
- 定义在所有函数、类和命名空间之外的变量和函数。
- 在整个程序的任何地方都可以访问
例子
int globalVar = 42; // 全局变量
void globalFunction() {
// 可以访问 globalVar
}
块作用域 Block scope
函数形参作用域 Function Parameter Scope
每个形参声明 P 都会引入一个包含 P 的函数参数 Scope
该作用域从形参声明处开始, 到函数声明结尾/函数定义结尾
模板形参作用域 Template parameter scope
每个模板形参将引入一个作用域, 作用范围包括整个模板参数列表 和 其内部的requires 子句
比如第二个模板形参可以使用第一个模板形参, 因为第一个模板形参的作用域先开始并包含了第二个模板形参的作用域
template
<
// 第一个形参引入了作用域S1
template // the template template parameter “T”
// introduces another template parameter scope “S2”
<
typename T1,
typename T2
> requires std::convertible_from<T1, T2> // scope “S2” ends here
typename T,
typename U
>
class X; //作用域S1结束
类作用域
- 定义在类或结构体中的变量和成员函数。
- 在类的内部可以访问,访问权限还受 public、private、protected 修饰符的影响。
例子
class MyClass {
public:
int memberVar;
void memberFunction() {
// 可以访问 memberVar
}
};
枚举作用域
命名空间作用域(Namespace Scope)
- 定义在命名空间中的变量和函数。
- 在该命名空间内可以直接访问,外部需要使用命名空间的名称。
例子
namespace MyNamespace {
int namespaceVar;
void namespaceFunction() {
// 可以访问 namespaceVar
}
}
// 使用命名空间中的变量和函数
MyNamespace::namespaceVar = 100;
MyNamespace::namespaceFunction();
文件作用域(File Scope)
- 适用于静态变量和函数,即用
static
修饰的全局变量和全局函数 - 这些变量和函数只能在 该文件内使用. 当尝试通过
#include
包含这个文件来使用它时, 实际上是重新拷贝了这些代码, 这将导致重复定义.
例子
static int fileVar = 10; // 文件作用域变量
static void fileFunction() {
// 只能在本文件中访问
}
8.2 局部作用域的详细解释#
局部作用域包含在函数或代码块(如 for 循环、if 语句)内声明的变量。它们在声明的块内是可见的,块结束时生命周期结束。
例子
void exampleFunction() {
int x = 0; // 局部变量 x
for (int i = 0; i < 10; ++i) { // i 是 for 循环的局部变量
int y = i; // y 是块作用域变量
}
// i 和 y 在这里不可见
if (x == 0) {
int z = 5; // z 是 if 语句块的局部变量
}
// z 在这里不可见
}
8.3 使用 extern 的限制#
extern
关键字通常用于全局作用域和命名空间作用域,不适用于局部作用域。- 在局部作用域中,变量的生命周期和可见范围受限于代码块,
extern
没有实际意义。
9 现代 c++ 的一些特性#
属性说明符(attribute specifier)
- 一般也称为 Attribute specifier sequence, 它可作为序列形式出现
- 属性几乎可以在 C++ 程序中的任何地方使用
- 是为实现定义的语言扩展提供统一的标准语法
- 是一种标记, 用于细微控制编译器如何编译程序. 不同的编译器可能支持不同的 属性, 当然有一些比较通用的属性, 称为标准属性
形式如下
[[ attribute-list ]]
[[ using attribute-namespace : attribute-list ]]
- 用双层中括号 括起
- attribute-list 若有多个属性, 则用逗号分开
using attribute-namespace
表示使用某个 Attribute 的命名空间- 后面的 Attribute 都属于这个命名空间
- 也可以直接用
namespace::attribute
可变类型 std::variant
作用
- 使用该类型声明的变量, 可具有多重身份
- 这类似一种[[#2.2 联合体/共同体 union|联合体]], 不过只有一个成员名.
- 它可用于替代联合体.
- 它可以作为函数的返回类型, 这使得函数的多个返回类型可以不同
用法
使用 get
成员查看值, 他是函数模板, 需要指定查看的类型
例子
std::variant<int,std::string,double> x; //作为变量类型
x = 10;
x =1.5;
x = "s12344";
std::variant<int, std::string> func(int x){ //作为函数返回类型
if (x>0)
return x;
return string{"haha"};
}
可选类型 std::optional
[[§6. 函数#std optional 作为返回类型]]
std::any
- C++17 引入的一种类型
- 它允许存储 任何类型的对象
- 并提供了一些方法来访问和操作这些对象。
- 它可以看作是一个类型安全的容器
std::any
实现原理:
通过类型擦除(type erasure)技术实现的,即它会将具体类型的对象存储在一个类型不明确的内部表示中。因此,你可以将任何类型的对象放入 std::any
中。
10 本节难点#
几个基本概念
[[#自定义的字面量]]
[[#声明和定义的区别]]
[[#变量和对象的区别]]
[[#extern 说明符]]
[[#6 volatile 限定符]]
初始化
[[#默认初始化]]的行为, 和[[#8 作用域的概念|作用域]]的关联
[[#列表初始化]]的概念
[[#值初始化]]的概念
结构体和内存对齐
[[#什么是 union?]]
[[#struct 的内存对齐]]
[[#union 的内存对齐]]
[[#完全匿名的 union]] 和[[#匿名类型的 union]]
类型推导
[[#7 类型别名和类型推导]]
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具