avatar
文章
51
标签
75
分类
31

Archives
Tags
Categories
Link
About
Hiyoung'blog
搜索
Archives
Tags
Categories
Link
About

Hiyoung'blog

01背包-目标和
发表于2024-12-06|更新于2025-05-21|算法动态规划01背包

题面

力扣题目链接-目标和

给定一个非负整数数组,a1, a2, …, an, 和一个目标数,S。现在你有两个符号 + 和 -。对于数组中的任意一个整数,你都可以从 + 或 -中选择一个符号添加在前面。

返回可以使最终数组和为目标数 S 的所有添加符号的方法数。

示例:

输入:nums: [1, 1, 1, 1, 1], S: 3
输出:5

解释:

-1+1+1+1+1 = 3
+1-1+1+1+1 = 3
+1+1-1+1+1 = 3
+1+1+1-1+1 = 3
+1+1+1+1-1 = 3
一共有5种方法让最终目标和为3。

提示:

  • 数组非空,且长度不会超过 20 。
  • 初始的数组的和不会超过 1000 。
  • 保证返回的最终结果能被 32 位整数存下。

解析

记加负号的元素之和为neg,加正号的元素之和为pos

例如:
给出的数组是 [1,1,1,1,1]
目标的结果是 3
其中的一种组合方法为 +1+1+1 -1 —> pos = 3, neg = 1

$target = pos - ( neg )———①$
这里的关键在于把neg的负号提出来,所以:
$pos + neg = sum(sum是所有元素之和)———②$
②式变换:
$pos = sum - neg———③$
把③带入①式:
$target = (sum - neg) - (neg)———④$
最后:
$neg = \frac{sum - target}{2}$

算法动态规划01背包
动态规划算法
发表于2024-12-02|更新于2025-05-21|算法动态规划

动态规划

总方法论 - 动态规划五部曲

  1. 确定DP数组(DP table)以及下标的含义
    • 动态规划的核心在于理解和明确DP数组所表示的含义
    • 在进行动态规划递推公式推导的时候要紧紧围绕着DP数组的含义
  2. 推导递推公式
    • 推导递推公式的关键在于找到当前状态可以由哪些状态转移而来
    • 要搞清楚可以从哪些状态转移来就要紧紧围绕着DP数组的含义
  3. dp数组如何初始化
    • 在确定递推公式之后在进行初始化
  4. 确定遍历顺序
  5. 举例推导dp数组
    • 如果动规结果不正确,最好的方式是打印出来DP数组的值,分析与预期结果哪里不正确

动态规划:01背包理论基础

01背包问题基本题目(二维数组解法)


题目描述
有个背包可承受重量N,现有T件物品,每件物品重量为Wi,价值为Vi ,每件物品只有一个,这个背包可以装载物品的最大价值是多少?
输入格式
第一行,两个整数,分别表示N和T,用空格隔开(N≤1000,T≤100)
接下来T行,每行两个整数,分别表示T件物品的重量Wi和价值Vi(1≤Wi,Vi≤100)
输出格式
一行,表示这个背包可以装载物品的最大价值
输入输出样例
输入样例1:
100 5
77 92
22 22
29 87
50 46
99 90
输出样例1:
133


模拟过程(画图)可以参考:背包理论基础01背包-1

分析
  1. 状态转移过程
    dp[i][j]的转移可以由它的上方(dp[i-1][j])和左上方转移(dp[i-1][j-weights[i]] + values[i])而来
    算法-01背包-1

  2. dp[0][j] 和 dp[i][0] 的初始化

    • dp[i][0] 初始化为0,因为在背包载重为0时总价值肯定全为0
    • dp[0][j] 第一行表示装第一个物品后背包的价值(对应dp[0][i] = values[0])
      如果背包的载重装不了第一个物品则价值还是为零(对应i = weights[0],也就是从背包载重能满足第一个物品开始
  3. DP数组的其他值该如何初始化?

    • 都可以,0、-1、-100都没有关系,因为DP数组的其他值都是由之前的状态转移而来的
  4. 遍历顺序,填充DP数组

    • 那么问题来了,先遍历 物品还是先遍历背包重量呢?
    • 其实都可以!! 但是先遍历物品更好理解。
      • 物品的遍历从第二个开始
      • 背包重量的遍历从0开始
      • 如果背包重量小于当前物品的重量,那么就继承dp[i - 1][j]的值
        if(j < weights[i]) dp[i][j] = dp[i-1][j];
题解
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <iostream>
using namespace std;

int n,t;
/*
dp[i][j] 的含义是表示从下标为[0-i]的物品里任意取放进容量为j的背包
,价值总和最大是多少
*/
int dp[100+10][1000+10];
int weights[100+10];
int values[100+10];
int main() {
cin>>n>>t;
for(int i = 0;i < t;i++){
cin>>weights[i]>>values[i];
}

//初始化DP数组
/*
1. 背包承重为0,第一列全初始化为0(对应的dp[][0] = 0)
2. 第一行表示装第一个物品后背包的价值(对应dp[0][i] = values[0])
如果背包的载重装不了第一个物品则价值还是为零(对应i = weights[0],
也就是从背包载重能满足第一个物品开始
*/
for(int j = weights[0]; j <= n; j++){
dp[0][j] = values[0];
}

// 递推公式:dp[i][j] = max(dp[i-1][j],dp[i-1][j-weights[i]] + values[i]);
/*
dp[i][j] 可以由两个状态得到:
1. 放物品i ----> dp[i-1][j]
2.不放物品i ----> dp[i-1][j-weights[i]] + values[i]

在两者中取其大
*/

// 先遍历物品再遍历背包容量
for(int i = 1; i < t; i++){ // 遍历物品(从第二个物品开始遍历)
for(int j = 0; j <= n; j++){ // 遍历背包容量
// 如果装不下这个物品,那么就继承dp[i - 1][j]的值
if(j < weights[i]) dp[i][j] = dp[i-1][j];
else{
dp[i][j] = max(dp[i-1][j],dp[i-1][j-weights[i]] + values[i]);
}
}
}

cout << dp[t-1][n];
return 0;
}

01背包问题(一维数组解法)

注意点
  1. 为什么可以不对第一行进行初始化
    因为在后续的滚动过程中就相当于完成了这个初始化过程
  2. dp[i] 的含义容量为i的背包,所背的物品价值可以最大为dp[i]
  3. 为什么在使用滚动数组的时要倒序遍历背包容量
    因为如果使用正序遍历,在计算dp[i]时会用到dp[i-1],而这时dp[i-1]已经被修改了,
    倒序遍历是为了保证物品i只被放入一次,如果一旦正序遍历了,那么物品0就会被重复加入多次!
  4. 使用滚动数组二重循环能否调换顺序
    不可以!
    因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。
题解
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;

int n,t;
/*
dp[i] 的含义容量为i的背包,所背的物品价值可以最大为dp[i]
*/
int dp[1000+10];
int weights[100+10];
int values[100+10];
int main() {
cin>>n>>t;
for(int i = 0;i < t;i++){
cin>>weights[i]>>values[i];
}

//使用一维数组时可以不对第一行进行特殊的初始化
//因为在后续的滚动过程中就相当于完成了这个初始化过程

// for(int j = weights[0]; j <= n; j++){
// dp[j] = values[0];
// }

// 先遍历物品再遍历背包容量
for(int i = 0; i < t; i++){ // 遍历物品(从第1个物品开始遍历)
for(int j = n; j >= weights[i]; j--){ // 倒序遍历背包容量
dp[j] = max(dp[j],dp[j-weights[i]] + values[i]);
}
}

cout << dp[n];
return 0;
}
算法
C++のSTL
发表于2024-10-28|更新于2025-06-21|学习笔记cpp

set容器

https://www.cnblogs.com/linuxAndMcu/p/10261014.html#_label2

字符串和字符数组

cin.getline()与getline()的区别

cin.getline要使用字符数组做参数,getline要使用字符串做参数


```getline(cin,字符串名)
1
2
3
4
5
6
7
8
   char name[20];
string s;
cin.getline(name,20); //OK
getline(cin,name); //ERROR

cin.getline(s,20); //ERROR
getline(cin,s); //OK

sort排序

sort主要是使用了快排并融合了堆排序等方法

基本用法(一维数组)

  1. 默认对传入的数据进行从小到大排序
1
2
vector<int> a;
sort(a.begin(),a.end());
  1. 可以使用cmp函数改变排序规则
    使用cmp函数改为从大到小排序
    1
    2
    3
    bool cmp(const int &x,const int &y){
    return x > y;
    }

    二维数组排序

    sort()函数,默认的是对二维数组按照第一列的大小对每行的数组进行排序。
    所以可以加上cmp函数用按照任意列对数组进行排序。

下列代码是对数组的第二列按照从大到小排序(第一列应该是a[0] > b[0])

1
2
3
bool cmp(vector<int> &a,vector<int> &b){
return a[1] > b[1];
}

string 与 stringstream

参考资料:string和stringstream用法

string常用的成员函数

Cpp的STL-string-1

find()函数

使用方法:s.find(sub),有返回值

示例代码:

1
2
3
4
5
6
7
8
9
10
//find函数返回类型size_type
string s = "1a2b3c4d5e6f7g8h9i1a2b3c4d5e6f7g8ha9i";
string sub;
string::size_type pos;

pos = s.find(sub);

if(s.find(sub) != string::npos){
...
}

  • Q1: find函数的返回类型
    string::size_type 是一个与实现相关的无符号整数类型,通常与 size_t 相同,用来表示字符串的长度和下标等
    它定义为与unsigned型(unsigned int获unsigned long)具有相同含义
    与实现相关的意思是它在不同的机器上,长度是可以不同的,并非固定的长度,只要你使用了这个类型,就使得你的程序适合这个机器

  • Q2: != string::npos
    npos表示size_type的最大值,用来表示不存在的位置

  • Q3: find函数的返回值
    如果找到了子串,则find函数的返回值是这个字串在字符串中第一次出现的位置

优先队列 priority_queue

优先队列默认为大梗堆实现,内部维护大根堆时间复杂度为$log_2 n$
包含头文件 #include<queue>
优先队列具有队列的所有特性,包括队列的基本操作,只是在这基础上添加了内部的一个排序,它本质是一个堆实现的

基本语法

1
2
3
4
5
6
7
8
9
// priority_queue<int,vector<int>,less<int> >;  // 小于号表示降序
priority_queue<int> pq;

pq.top() 访问队头元素
pq.empty() 队列是否为空
pq.size() 返回队列内元素个数
pq.push(x) 插入元素x到队尾 (并排序)
pq.pop() 弹出队头元素

小根堆

1
2
3
//注意最后两个 > 之间有一个空格(输入运算符:>>)
priority_queue<Type,vector<Type>,greater<Type> >; //大于号表示升序

  • 其中 vector(也就是第二个参数)填写的是来承载底层数据结构堆(heap)的容器,如果第一个参数是 double 型或 char 型,则此处只需要填写 vector 或 vector;

  • 而第三个参数 less 则是对第一个参数的比较类,less 表示数字大的优先级越大,而 greater 表示数字小的优先级越大。

  • 传入的第三个参数是仿函数,是将新插入数据与父结点进行比较,当父结点小于子结点的时候,才会在堆中进行交换,所以less表示大根堆,greater表示大根堆

重载运算符

格式

  1. 运算符重载的必要语法
    运算符重载有明确的语法规则,以下是关键点:

  2. 使用 operator 关键词
    在 C++ 中运算符重载始终使用关键词 operator 开头。以下是加法运算符重载的声明:

1
ReturnType operator+(ParameterList);
  1. 限定只能为已有运算符重载
    C++ 允许重载大多数运算符,但有少数运算符不能重载:
  • 不能重载 .(成员访问运算符)
  • 不能重载 ::(域运算符)
  • 不能重载 sizeof(类型大小运算符)
  • 不能重载 ?:(三元运算符)
    原因:这些是语言层级的运算符,过于底层。
  1. 参数传递规则
    当操作符为二元操作符(如 +, -, *, /)时,通常需要两个参数:
  • 左操作数(隐式传递,即调用者本身 this)
  • 右操作数(显式传递的参数)
    当操作符为一元操作符(如 ++, —)时,只需要一个参数(调用者本身)。

作为类成员函数重载的格式:

1
2
ReturnType operatorOp(const Type& operand);  // 二元操作符
ReturnType operatorOp(); // 一元操作符

  1. 返回类型
    运算符重载函数的返回类型取决于实际需求:
  • 对于二元算术运算符(如 + ),返回一个新的对象。
  • 对于赋值运算符(如 =),返回 *this(即对调用对象的引用)。

重载运算符的写法

  1. 比较运算符 (<)

比较运算符需要返回布尔值,常用于排序和 STL 容器(如 std::sort 或 std::priority_queue)。

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
#include <iostream>
#include <queue>
#include <vector>
using namespace std;

struct node {
string name;
int age;

// 重载小于运算符
// this可省略
bool operator<(const node& other) const {
return this->age < other.age; // 按年龄降序排序
}
};

int main() {
priority_queue<node> pq;

pq.push({"Alice", 30});
pq.push({"Bob", 20});
pq.push({"Charlie", 25});

while (!pq.empty()) {
cout << pq.top().name << " (" << pq.top().age << ")" << endl;
pq.pop();
}

return 0;
}
  • 为什么需要这样写?

返回值是 bool,符合比较运算的预期。
标记函数为 const,因为它不改变操作数的值,符合比较逻辑的无副作用原则。
优先队列使用 operator< 作为比较基础,自定义行为可以实现不同的比较策略。

  • 升序和降序
    return this->age < other.age; // 按年龄降序排序
    返回规则在于,左操作数为调用者本身,this对象,右操作数为比较的对象。this可省略
    $ < $ 表示按照降序排列,$ > $表示按照升序排列
cppSTL
重装系统前后需要完成的事情
发表于2024-07-15|更新于2025-10-15|软件PC

重装系统之前备份

在重装系统之前需要备份的重要文件

  1. 备份浏览器插件(以chrome为例)
    插件安装本地地址:C:\Users\主机名\AppData\Local\Google\Chrome\User Data\Default\Extensions
    这里会有一堆文件夹,其文件夹名对应的是插件的ID
    点开Chrome-拓展程序-打包拓展程序-选择拓展程序根目录
    这里需要打包的是点开上文文件夹打包里面的版本号文件夹
    然后就会生成crx文件

    重装系统前后需要完成的事情1
    重装系统前后需要完成的事情2

  2. 备份桌面文件、C盘文档文件夹、图片文件夹、视频文件夹

  3. 截图备份:桌面图标位置、快速访问文件夹、开始菜单快捷方式
  4. 保存油猴脚本中的脚本(打包导出到本地)
  5. 参考我的常用软件恢复常用软件
重装系统
unity笔记
发表于2024-03-02|更新于2025-05-21|游戏制作

基础操作记录

跟学视频
脚本文件

第一天学习P6-P10 创建物体、坐标系

  1. 创建3D物体
    在层级下鼠标右击选择 3D object中选择各种形状

  2. 坐标系
    分为世界坐标系和物体坐标系
    选中物体后在属性中transform.position的数值是相对于世界坐标系计算的
    将一个物体变为另一个物体的子类后单独移动,position还是相对世界坐标系的,但在移动父类时它和父类的相对位置不会改变(上手实践一下)

  1. 对物体的基本操作

unity笔记-1

依次为:

  • 移动视角(可以按住鼠标中键),快捷键-Q
  • 移动物体,快捷键-W
  • 旋转物体,快捷键-E
  • 放缩物体,快捷键-R
  • 矩形工具,快捷键-T
  • 综合变换,快捷键-Y
  1. 导入模型和修改材质
    模型资源导入在assets中,还可以修改材质(materials),选择外部材质即可。project中选中物体->Inspector->Materials

也可以自己设置新材质,project右击->Create->Material

  1. 资源商定
    导航栏中Windows选中asset store,在商店中购买需要的素材。在package manager中下载和导入素材

第二天学习P11-P16 地编、脚本

  1. 地形编辑

在3D object中新建一个Terrain。在属性中可以修改地形地貌,创建山川等。还可以添加贴图和森林

  1. 添加C#脚本

Unity中事件函数的执行顺序

脚本要发挥作用需要添加到相应物体上才能实现

关于脚本的执行顺序:
可以使用 Script Execution Order 设置(菜单:Edit > Project Settings,然后选择 Script Execution Order 类别)
或者在脚本的inspector中点击 Execution Order添加脚本如何修改优先级

第三天学习P17-P23 标记、图层、预制体、向量、四元数

  1. 标记(Tag)和图层(Layer)

给物体添加一个标记(tag),用来标记物体便于查找
可以使用图层可以标记一类物体,一般用于碰撞检测、摄像机捕捉和显示

  1. 预制体(prefab)和预制体变体(prefab variant)

将物体从图层Hierarchy移至项目project即可生成一个预制体

一个预制体就是一个模板,可以批量生成和修改类似的物体。而预制体变体类似于继承某一预制体并加上自己的需求(例如:某一预制体是不带帽子的个体而不同的预制体变体可以带帽子、带项链等),并且通过原始预制体可以统一修改预制体变体

  1. 向量、欧拉角、四元数和Vector3的一些使用
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    void Start()
    {
    //旋转,欧拉角、四元数
    Vector3 rotate = new Vector3(0, 30, 0);
    //定义一个四元数
    Quaternion quaternion = Quaternion.identity;
    //四元数转换为欧拉角
    rotate = quaternion.eulerAngles;
    //欧拉角转换为四元数
    quaternion = Quaternion.Euler(rotate);

    //物体指向某个角度
    quaternion = Quaternion.LookRotation(new Vector3(90, 0, 0));

    }

第四天P24 GameObject类

使用脚本动态控制物体的属性,GameObject类的使用,和一些API的使用

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class EmptyTest : MonoBehaviour
{

public GameObject emptyCube;
//获取预设体,程序化生成物体
public GameObject prefab;


// Start is called before the first frame update
void Start()
{
//拿到当前脚本所挂载的物体
GameObject go = this.gameObject;
//名称
Debug.Log(go.name);
// = Debug.Log(gameObject.name);
//tag
Debug.Log(gameObject.tag);
//layer
Debug.Log(gameObject.layer);
//Empty_Cube的名称
Debug.Log(emptyCube.name);
//物体本身是否处于激活状态
Debug.Log(emptyCube.activeSelf);
//物体在图层中是否实际处于激活状态
Debug.Log(emptyCube.activeInHierarchy);

//获取transform组件
// transform tans = this.transform; ==
Debug.Log(transform.position);
//获取其他组件
BoxCollider bc = GetComponent<BoxCollider>();
//获取当前物体的子物体身上的某个组件
GetComponentInChildren<CapsuleCollider>(true);
GetComponentsInChildren<CapsuleCollider>(bc);
//获取当前物体父物体身上的某个组件
GetComponentInParent<BoxCollider>();
//添加组件
emptyCube.AddComponent<AudioSource>();

//通过游戏物体的名称来获取游戏物体,若物体同名则返回所有同名物体
GameObject enemy = GameObject.Find("enemy");
//通过标签来获取游戏物体,多个相同标签则返回最后一个创建的物体
GameObject enemy1 = GameObject.FindWithTag("enemy");
Debug.Log(enemy.transform.position);

//通过预设体来实例化一个游戏物体
for (int i = 0; i < 10; i++)
{
Instantiate(prefab,Vector3.zero, Quaternion.identity);
}

}
}

第五天P25-P29 一些类的使用

主要是Time类、Application类、场景类、异步加载场景、transform类相关的一些方法使用
笔记内容见脚本文件

第六天P30-P31 鼠标键盘操作

键盘、鼠标操作和虚拟轴的使用
笔记内容见脚本文件

第七天P32-P33 灯光、摄像机

灯光、摄像机的使用

灯光

按照类型主要分为:定向光、聚光、点光源和区域光(仅烘培)

其中定向光类似于太阳光仅代表光线方向与光影物体位置无关

区域光(仅烘培):由于实时光照性能消耗较大,在一个光源固定的场景中可以预先烘培光照来减轻负担。

要使用烘培我们首先需要设定对象为Contribute GI,选中对象,勾选Static下的Contribute GI(组成GI)。然后在Windows->Rendering->Lighting(快捷键Crtl+9)中点击Generate Lighting渲染

Lightmap适用于静态光照贴图对象,所有标记为Contribute GI的物体都会被被渲染到Lightmap(光照贴图)中

除了类型外,还可以选择光照的渲染方式(Mode)、强度(Intensity)、阴影类型(Shadow Type)、剪影(Cookie)

在光照下选择剔除遮罩(Culling Mask)可以选择是否渲染不同图层

摄像机

摄像机可以选择透视摄像机或者正交摄像机。其中透视摄像机在3D游戏中使用,有近大远小的特点。正交摄像机在2D游戏中使用,所有物体都被放在一个平面上。

在摄像机Clear Flags中可以选择在空区域中显示什么,例如天空盒(SkyBox)、纯色(Soiled color)、仅深度(Depth Only)或者不显示(Don`t clear)
若选择了天空盒则可以在摄像机中添加Skybox组件来更换不同天空。

深度(Depth)属性:若有多个摄像机会根据深度属性数值大小来决定哪个摄像机会优先显示在游戏界面。深度数值越大的会优先显示。

若有多个摄像机时选择仅深度可以实现在游戏(Game)视窗同时显示多个摄像机捕捉的物体

视野(Filed of View)可以调节摄像机视野

裁切平面(Clipping Planes)可以调节摄像机的远面和近面

Viewpoint Rect可以调整摄像机在游戏(Game)视窗的位置和高度宽度

目标显示(Target Display)可以修改摄像机的显示目标,在游戏(Game)视窗可以切换Display

在Project中可以新建一个目标纹理(Render Texture)(需挂载在一个物体上),然后将目标纹理拖到摄像机的Target Texture上即可实现将摄像机捕捉的画面映射在物体上

第八天P35-P38 声音、视频和角色移动控制

声音、视频和角色移动控制

声音

在物体上添加Audio Source组件后即可播放声音,只需将音频拖到AudioClip上即可,同时可以在Audio Source中调节声音效果
还可以使用脚本来控制声音的播放暂停等操作,具体细节可以看脚本
要听见声音需要一个Audio Listener组件,一般在摄像机上。若有多个摄像机则只能有一个有Audio Listener组件

视频

视频和声音的一些操作类似
需要一个Video Player组件,在组件的Video Clip上添加上需要播放的视频。此外,可以将Video Player的渲染模式(Render Mode)改为渲染器纹理(Render Texture),新建一个渲染器纹理将其拖到目标纹理上。再将该目标纹理拖到物体上即可。使用这种渲染器纹理方式可以统一改变所有使用该渲染器纹理的视频,在需要重复使用在多个物体上的视频来说很便捷。

还可以通过脚本来控制视频的播放暂停。(见脚本文件)

角色控制器

见脚本文件

第九天P39-P40

碰撞

Tips:选中摄像机,Ctrl+Shift+F可以对齐视图,快速调节视角

产生碰撞的条件:

  • 2个游戏对象都有Collider
  • 至少有一个游戏对象有Rigidbody
  • 2个游戏对象保持相对运动(一个Cube放在Plane上,不会产生碰撞,因为没有相对运动)

触发

要将物体改为触发器,只需在Box Collider中将is trigger勾选上,然后使用脚本完成触发逻辑(见脚本文件)

第十天P41-47

铰链、弹簧和物理关节

为物体添加铰链组件:Hinge Joint

弹簧组件:需要选中两个物体,添加Spring Joint,在Connected Body选中要连接的物体(连接的物体需要有刚体才可添加)

物理关节:添加Fixed Joint

物理材质(摩擦力)

在Assets中新建->物理材质(Physic Material),然后在Collider的材质中将物理材质拖入即可
在Physic Material中可以调节材质的动摩擦,静摩擦和弹力数值

射线检测

类似于从摄像机发出一条射线,点击某个位置可以检测出位置等信息

粒子系统

添加Particle System组件,来设置粒子效果

绘制线条和拖尾

添加Line Renderer组件

动画

有老版的Animation组件和新版的Animator组件
使用Animator组件需要给模型挂载一个Animator组件,然后再打开Animator窗口即可使用类似于蓝图的方式来完成动画

第十一天P48-P50

按键控制角色移动+角色动画的使用,见脚本文件

动画剪辑

第十二天P51-P54

动画曲线

在动画文件中可以找到Curves(曲线),并在animator中添加一个float与曲线名同名参数,相当于一个随动画播放而变化的数值函数,可以获得一个随动画播放而不断变化的数值。例如,如果做一个挥拳的动画,可以通过这个数值来控制挥拳可能用到的特效、声音等。

动画事件

动画事件(Evevts),事件是一个函数,事件名即函数名,可以在挂载在物体的脚本上调用同名函数。通过设置事件在动画中发生的时间点则可以调用事件函数中的代码,实现需要的效果。

混合动画

在animator界面中可以右键->Create State->From Blend Tree,添加一个混合动画,在Blend Tree中可以添加多个动作,修改动画的比例来混合动画。

动画遮罩

在Assets->Create->Avatar Mask,新建一个动画遮罩,在遮罩里选择Humanoid,可以选择只使用那一身体部分的动画,然后在Animator的动画层级中选择添加Mask即可

第十三天P55-

IK(反向动力学)

导航的使用

unity笔记游戏引擎
unity脚本
发表于2024-03-01|更新于2025-05-21|游戏制作

脚本文件

Vector3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void Start()
{
//向量,坐标,旋转,缩放
Vector3 v = new Vector3(0, 1,0 );
Vector3 v1 = Vector3.right;

//计算两个向量夹角
Debug.Log(Vector3.Angle(v,v1));
//计算两点间距离
Debug.Log(Vector3.Distance(v,v1));
//点乘
Debug.Log(Vector3.Dot(v,v1));
//叉乘
Debug.Log(Vector3.Cross(v,v1));
//插值
Debug.Log(Vector3.Lerp(Vector3.left,Vector3.right,0.5f));
//向量的模
Debug.Log(v.magnitude);
//规范化向量
Debug.Log(v.normalized);
}

Time类

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
public class Timetest : MonoBehaviour
{
private float _timer = 0;
//Time时间类
// Start is called before the first frame update
void Start()
{
//游戏开始到现在所花的时间
Debug.Log(Time.time);
//时间缩放值,加速减速
Debug.Log(Time.timeScale);
//固定时间间隔
Debug.Log(Time.fixedDeltaTime);
}

// Update is called once per frame
void Update()
{
//Time.deltaTime : 上一帧到这这一帧所用的游戏时间
//这里类似于计时器的作用
_timer += Time.deltaTime;

// Debug.Log(Time.deltaTime);
if (_timer > 3)
{
Debug.Log("时间大于3");
_timer = 0;
}
}
}

Application类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void Start()
{
//游戏数据文件夹路径(只读,打包后会加密压缩)
Debug.Log(UnityEngine.Application.dataPath);
//持久化文件夹路径(可以放存档)
Debug.Log(UnityEngine.Application.persistentDataPath);
//StreamingAssets文件夹路径(只读,但打包后不会加密压缩,可以放一些配置文件等)
Debug.Log(UnityEngine.Application.streamingAssetsPath);
//临时文件夹
Debug.Log((UnityEngine.Application.temporaryCachePath));
//控制是否在后台运行
Debug.Log(UnityEngine.Application.runInBackground);
//打开URL
// UnityEngine.Application.OpenURL("https://www.bilibili.com/");
//退出游戏
UnityEngine.Application.Quit();
}

场景类

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
void Start()
{
//场景类、场景管理类
//场景跳转
// SceneManager.LoadScene("MyScene");

//获取当前场景
Scene scene = SceneManager.GetActiveScene();
Debug.Log(scene.name);
// Debug.Log(SceneManager.GetActiveScene().name);
//场景是否已经加载
Debug.Log(scene.isLoaded);
//场景路径
Debug.Log(scene.path);
//场景索引
Debug.Log(scene.buildIndex);
//获取场景中所有物体
Debug.Log(scene.GetRootGameObjects().Length);

//场景管理类
//创建新场景
Scene newScene = SceneManager.CreateScene("NewScene");
//已加载的场景个数
Debug.Log(SceneManager.sceneCount);
//卸载一个场景
SceneManager.UnloadSceneAsync(newScene);
//加载场景(Signal和Additive方式)
SceneManager.LoadScene("MyScene",LoadSceneMode.Additive);
}

异步加载场景

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
public class Async : MonoBehaviour
{
private AsyncOperation _operation;

private float timer = 0;
// Start is called before the first frame update
void Start()
{
StartCoroutine(LoadScene());
}

//用协程方法来异步加载场景
IEnumerator LoadScene()
{
_operation = SceneManager.LoadSceneAsync("MyScene");
//加载完场景不要自动跳转
_operation.allowSceneActivation = false;
yield return _operation;
}

// Update is called once per frame
void Update()
{
//输出加载进度0-0.9
Debug.Log(_operation.progress);
timer += Time.deltaTime;
if (timer > 5)
{
_operation.allowSceneActivation = true;
}
}
}

transform类

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class transform : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
//获取位置
//position为物体所在的世界位置
Debug.Log(transform.position);
//localPosition为物体所在的相对父物体的位置(如何有的话)
Debug.Log(transform.localPosition);

//获取旋转
Debug.Log("旋转:"+transform.rotation);
Debug.Log(transform.localPosition);
Debug.Log("欧拉角:" + transform.eulerAngles);
Debug.Log("相对父物体的欧拉角:"+transform.localPosition);

//获取缩放
Debug.Log("获取缩放:"+transform.localScale);
//向量
Debug.Log(transform.forward);
Debug.Log(transform.right);
Debug.Log(transform.up);

//父子关系
//获取父物体
GameObject go = transform.parent.gameObject;
//子物体个数
Debug.Log(transform.childCount);
//解除与子物体的父子关系
transform.DetachChildren();
//获取子物体的两种方法
Transform trans = transform.Find("Child");
Transform trans1 = transform.GetChild(0);
//判断一个物体是否是另一个物体的子物体
bool res = trans.IsChildOf(transform);
Debug.Log(res);
//设置为父物体
trans.SetParent(transform);
}


// Update is called once per frame
void Update()
{
//固定看向某一点
transform.LookAt(Vector3.zero);
//旋转
transform.Rotate(Vector3.right, 1);
//绕某个物体旋转
transform.RotateAround(Vector3.zero, Vector3.up, 1);
//移动
transform.Translate(Vector3.forward * 0.1f);
}
}

鼠标、键盘操作

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
void Update()
{
//鼠标的点击
//按下鼠标 0左键 1右键 2按下滚轮
if (Input.GetMouseButtonDown(0))
{
Debug.Log("鼠标左键点击");
}

if (Input.GetMouseButton(0))
{
Debug.Log("持续按下鼠标左键");
}

if (Input.GetMouseButtonUp(0))
{
Debug.Log("抬起鼠标左键");
}

//按下键盘按键
if (Input.GetKeyDown("w"))
{
Debug.Log("按下w");
}

if (Input.GetKey(KeyCode.W))
{
Debug.Log("持续按w");
}

if (Input.GetKeyUp(KeyCode.W))
{
Debug.Log("松开w");
}
}

触摸操作

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
37
38
39
void Start()
{
//开启多点触摸
Input.multiTouchEnabled = true;
}

// Update is called once per frame
void Update()
{
//判断单点触摸
if (Input.touchCount == 1)
{
//触摸对象
UnityEngine.Touch touch = Input.touches[0];
//触摸位置
Debug.Log(touch.position);
//触摸阶段
switch (touch.phase)
{
case TouchPhase.Began:
break;
case TouchPhase.Moved:
break;
case TouchPhase.Stationary:
break;
case TouchPhase.Ended:
break;
case TouchPhase.Canceled:
break;
}
}

//判断多点触摸
if (Input.touchCount == 2)
{
UnityEngine.Touch touch1 = Input.touches[0];
UnityEngine.Touch touch2 = Input.touches[0];
}
}

虚拟轴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void Update()
{
/*
//获取水平轴
float horizontal = Input.GetAxis("Horizontal");
float vertical = Input.GetAxis("Vertical");
Debug.Log(horizontal + "------"+ vertical);
*/

//虚拟按键
if (Input.GetButtonDown("Jump"))
{
Debug.Log("space down");
}

if (Input.GetButtonUp("Jump"))
{
Debug.Log("space up");
}
}

声音

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class Audio : MonoBehaviour
{
//AudioClip
public AudioClip music;

public AudioClip se;

//播放器组件
private AudioSource _player;

// Start is called before the first frame update
void Start()
{
_player = GetComponent<AudioSource>();

//设定播放的音频
_player.clip = music;
//循环
_player.loop = true;
//音量
_player.volume = 0.5f;
//播放
_player.Play();


}

// Update is called once per frame
void Update()
{
//按空格控制播放
if (Input.GetKeyDown(KeyCode.Space))
{
if (_player.isPlaying)
{
//暂停播放
_player.Pause();
//停止播放(下次从头开始播放)
// _player.Stop();
}
else
{
//重新播放
_player.UnPause();
//与Stop一起使用
// _player.Play();
}
}

if (Input.GetMouseButtonDown(0))
{
//播放一次音效
_player.PlayOneShot(se);
}
}
}

视频

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
public class Video : MonoBehaviour
{
private VideoPlayer _player;
// Start is called before the first frame update
void Start()
{
_player = GetComponent<VideoPlayer>();
}

// Update is called once per frame
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
if (_player.isPlaying)
{
_player.Stop();
}
else
{
_player.Play();
}
}
}
}

控制角色移动

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
public class PlayerControl : MonoBehaviour
{
private CharacterController _character;
// Start is called before the first frame update
void Start()
{
_character = GetComponent<CharacterController>();
}

// Update is called once per frame
void Update()
{
//水平轴
float horizontal = Input.GetAxis("Horizontal");
//垂直轴
float vertical = Input.GetAxis("Vertical");
//创建成一个方向向量
Vector3 dir = new Vector3(-horizontal, 0, -vertical);
Debug.DrawRay(transform.position,dir,Color.red);
//朝向该方向移动

if (Input.GetKey(KeyCode.LeftShift))
{
_character.SimpleMove(dir * 30);
}
else
{
_character.SimpleMove(dir);
}
}
}

触发器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//可以使用三个函数来实现触发进入、持续、退出
private void OnTriggerEnter(Collider other)
{
GameObject door = GameObject.Find("trigger1");
if (door != null)
{
door.SetActive(false);
}
}

private void OnTriggerStay(Collider other)
{

}
private void OnTriggerExit(Collider other)
{

}

控制角色移动并播放动画P48

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
37
38
39
40
41
42
43
44
45
46
47
48
public class PlayerControl : MonoBehaviour
{
private Animator _animator;
// Start is called before the first frame update
void Start()
{
_animator = GetComponent<Animator>();
}

// Update is called once per frame
void Update()
{
//水平轴
float horizontal = Input.GetAxis("Horizontal");
//垂直轴
float vertical = Input.GetAxis("Vertical");
//创建成一个方向向量
Vector3 dir = new Vector3(horizontal, 0, vertical);
Debug.DrawRay(transform.position,dir,Color.red);


if (dir != Vector3.zero)
{
//朝向该方向移动
transform.rotation = Quaternion.LookRotation(dir);
_animator.SetBool("iswalk_bool",true);
//朝向前方移动
transform.Translate(Vector3.forward * 1 *Time.deltaTime);
}
else
{
_animator.SetBool("iswalk_bool",false);
}

if (Input.GetKey(KeyCode.LeftShift) && dir != Vector3.zero)
{
//切换跑步
_animator.SetBool("isrun",true);
transform.Translate(Vector3.forward * 3 *Time.deltaTime);

}
else
{
_animator.SetBool("isrun",false);

}
}
}
unity笔记游戏脚本
Cpp语言基础
发表于2024-01-15|更新于2026-06-19|学习笔记C++

部分内容复习

二级指针的作用

  1. 指向指针的指针的作用:当要修改实参指针的指向的时候,形参必须使用指向指针的指针。
  2. 当修改的是实参指针指向的内容时,则形参只需是指针即可
1
2
3
4
5
6
7
8
9
10
11
void GetMemory(char *p,int num)
{
p=(char*)malloc(sizeof(char)*num);//p是形参指向的地址
}
void main()
{
char *str=NULL;
GetMemory(str,100);//str是实参指向的地址,不能通过调用函数来申请内存
strcpy(str,"hello");
}

结构是编译能通过,却不能运行,为什么呢?

先说一下指针作为函数参数的意义:当将指针作为参数时,实参向形参传递的是地址,在函数执行过程中,既可以对该参数指针进行处理,也可以对该参数指针所指向的数据进行处理,(以上程序段来说就是可以对p或p进行处理)。*由于此时形参和实参都是指向同一个存储单元,因此当形参指针所指向的数据改变时,实参指针所指向的数据也作相应的改变,因此这时的形参可以作为输出参数使用。(str和p应同时更改!)

按照上面的说法,这个程序应该没有问题的啊,实参str和形参p指向同一个存储单元,给形参分配的内存单元应该也给实参分配了才对啊,问题就是在这里

实参和形参是指向同一个地址,它们只是指向相同,但它们自身的地址不是同时申请的,就是说p在申请内存时,相当于是把p的指向给改了,但是str的指向仍然没有改!所以尽管str调用了GetMemory,但它仍然是个空指针,所以进行strcpy是就不能运行

要使程序可以运行,只要小小的改动就行了(用指向指针的指针):

1
2
3
4
5
6
7
8
9
10
11
void GetMemory(char **p,int num)
{
  *p=(char*)malloc(sizeof(char)*num); //此时*p就变成了是形参本身的地址
}
void main()
{
  char *str=NULL;
  GetMemory(&str,100);//&str是实参的地址,所以实参和形参之间就可以直接调用
  strcpy(str,"hello");
  free(str);
}

C++对C语言的加强

namespace命名空间

C++命名空间基本常识

所谓namespace,是指标识符的各种可见范围。C++标准程序库中的所有标识符都被定义于一个名为std的namespace中。

c++标准为了和C区别开,也为了正确使用命名空间,规定头文件不使用后缀.h

  1. 当使用时,相当于在c中调用库函数,使用的是全局命名空间,也就是早期的c++实现;
  2. 当使用的时候,该头文件没有定义全局命名空间,必须使用namespace std;这样才能正确使用cout。
  3. 在程序开头使用using namespace std;,即可使命名空间std内定义的所有标识符都有效

与C的区别

  1. C中的命名空间
    • 在C语言中只有一个全局作用域
    • C语言中所有的全局标识符共享同一个作用域
    • 标识符之间可能发生冲突
  2. C++中的命名空间
    • 命名空间将全局作用域分成不同的部分
    • 不同命名空间中的标识符可以同名而不会发生冲突
    • 命名空间可以相互嵌套
    • 全局作用域也叫默认命名空间

在程序中使用命名空间

  1. 定义命名空间
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    namespace ns1//ns1为自定的命名空间名
    {
    const int RATE=0.08; //常量

      doublepay; //变量(可带有初始化)

      doubletax() //函数(可以是定义或声明)
    {
        return a*RATE;
      }

      namespacens2 //嵌套的命名空间
      {
        int age;
      }
    }
  2. 使用命名空间别名

    1
    2
    namespace Television //声明命名空间,名为Television
    { ... }

    可以用一个较短而易记的别名代替它。如:
    namespace TV=Television;//别名TV与原名Television等价

  3. 使用using命名空间成员名
    using nsl::Student
    在用上面的using声明后,在其后程序中出现的Student就是隐含地指nsl::Student

注意事项

在程序中如果直接使用using namespace std可以减少在变量前添加std::的繁琐编程,但是另一方面在多文件编译时可能会导致不同文件中定义的任何标识符可能与std命名空间中的同名标识符冲突,因此可以考虑使用,若干个”using命名空间成员”声明来代替“using namespace命名空间”声明,如:

1
2
3
4
5
using std::string;

using std::cout;

using std::cin;

等。

三目运算符

  1. C语言返回变量的值 C++语言是返回变量本身C语言中的三目运算符返回的是变量值,不能作为左值使用C++中的三目运算符可直接返回变量本身,因此可以出现在程序的任何地方

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    int main(void)
    {
    int a = 10;
    int b = 20;
    //返回一个最小数 并且给最小数赋值成30
    //三目运算符是一个表达式 ,表达式不可能做左值
    (a < b ? a : b ) = 30;//在Cpp中是可行的,而C中是错误的
    printf("a = %d, b = %d\n", a, b);
    return 0;
    }
  2. 三目运算符可能返回的值中如果有一个是常量值,则不能作为左值使用(a < b ? 1 : b )= 30;

const增强

外链C++中const关键字的使用方法总结

C++中的const修饰的,是一个真正的常量,而不是C中变量(只读)。在const修饰的常量编译期间,就已经确定下来了

1
2
3
4
//C中
const int a = 10;
int *p = (int*)&a;
*p = 11;//即可修改a中的值

C++中的const常量类似于宏定义
const int c =5; 约等于 #define 5

C++中的const常量与宏定义不同
const常量是由编译器处理的,提供类型检查和作用域检查。
宏定义由预处理器处理,单纯的文本替换

枚举

c语言中枚举本质就是整型,枚举变量可以用任意整型赋值。
而c++中枚举变量,只能用被枚举出来的元素初始化,简单来说,就是用 名字 来代表 整数值

1
2
3
4
5
6
7
8
9
10
11

enum season {SPR=1,SUM,AUT,WIN};
int main()
{
enum season s = SPR;
//s = 0; // error, 但是C语言可以通过
s = SUM;
cout << "s = " << s <<endl; //2

return 0;
}

引用

  • C 和 C++ 中引用

变量名,本身是一段内存的引用,即别名(alias)。引用可以看作一个已定义变量的别名

1
2
3
4
5
6
7
8
9
10
int a = 10; //c编译器分配4个字节内存, a内存空间的别名
int &b = a; //b就是a的别名
a = 11; //直接赋值
{
int *p = &a;
*p = 12;
cout << a <<endl;
}
b = 14;
cout << "a = " <<a << ", b = " << b <<endl;

  1. 引用没有定义,是一种关系型声明。声明它和原有某一变量(实体)的关系。故而类型与原类型保持一致,且不分配内存。与被引用的变量有相同的地址
  2. &符号前有数据类型时,是引用。其它皆为取地址
  3. 声明的时候必须初始化,一经声明,不可变更
  4. 可对引用,再次引用。多次引用的结果,是某一变量具有多个别名
1
2
3
4
5
int a,b;
int &r = a;
int &r = b; //错误,不可更改原有的引⽤关系
float &rr = b; //错误,引⽤类型不匹配 cout<<&a<<&r<<endl; //变量与引⽤具有相同的地址。
int &ra = r; //可对引⽤更次引用,表⽰ a 变量有两个别名,分别是 r 和 ra

引用的意义

  1. 引用作为其它变量的别名而存在,因此在一些场合可以代替指针
  2. 引用相对于指针来说具有更好的可读性和实用性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void swap(int a, int b); //无法实现两数据的交换
void swap(int *p, int *q); //开辟了两个指针空间实现交换

//使用引用
void swap(int &a, int &b){
int tmp;
tmp = a; a = b;
b = tmp;
}

int main()
{
int a = 3,b = 5;
cout<<"a = "<<a<<"b = "<<b<<endl;
swap(a,b);
cout<<"a = "<<a<<"b = "<<b<<endl;
return 0;
}

引用的本质

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main()
{
int a = 10;
int &b = a; // 注意: 单独定义的引用时,必须初始化。
b = 11;
printf("a:%d\n", a);
printf("b:%d\n", b);
printf("&a:%p\n", &a);
printf("&b:%p\n", &b);
return 0;
}

a:11
b:11
&a:000000000061fe14
&b:000000000061fe14
  1. 可见得Cpp编译器在定义引用时是将两个变量名指向同一个内存地址:引用在C++中的内部实现是一个常指针
    Type& name <===> Type* const name

  2. C++编译器在编译过程中使用常指针作为引用的内部实现,因此引用所占用的空间大小与指针相同

  3. 从使用的角度,引用会让人误会其只是一个别名,没有自己的存储空间。这是C++为了实用性而做出的细节隐藏。

间接赋值的三个必要条件

  1. 定义两个变量 (一个实参一个形参)
  2. 建立关联 实参取地址传给形参
  3. *p形参去间接的修改实参的值

引用在实现上,只不过是把:间接赋值成立的三个条件的后两步和二为一
当实参传给形参引用的时候,只不过是c++编译器帮我们程序员手工取了一个实参地址,传给了形参引用(常量指针)。

引用作为函数的返回值(引用当左值)

  1. 当函数返回值为引用时,若返回栈变量:不能成为其它引用的初始值(不能作为右值使用)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    int getA1()
    {
    int a;
    a = 10;
    return a;
    }
    int& getA2()
    {
    int a;
    a = 10;
    return a;
    }

    //将⼀一个引⽤用赋给另⼀一个引⽤用作为初始值,由于是栈的引⽤用,内存⾮非法
    int &a3 = getA2();
  2. 当函数返回值为引用时,若返回静态变量或全局变量可以成为其他引用的初始值(可作为右值使用,也可作为左值使用)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    int& getA2()
    {
    static int a;
    a = 10;
    return a;
    }

    //将⼀一个引⽤用赋给另⼀一个引⽤用作为初始值,由于是静态区域,内存合法
    int &a3 = getA2();
  3. 引用作为函数返回值,如果返回值为引用可以当左值,如果返回值为普通变量不可以当左值。

    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
    //函数当左值
    //返回变量的值
    int func1()
    {
    static int a1 = 10;
    return a1;
    }
    //返回变量本⾝
    int& func2()
    {
    static int a2 = 10;
    return a2;
    }
    int main(void)
    {
    //函数当右值
    int c1 = func1();
    cout << "c1 = " << c1 <<endl;
    int c2 = func2(); //函数返回值是一个引⽤,并且当右值
    cout << "c2 = " << c2 <<endl;
    //函数当左值
    //func1() = 100; //error
    func2() = 100; //函数返回值是一个引用,并且当左值
    c2 = func2();
    cout << "c2 = " << c2 <<endl;
    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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
struct Teacher
{
char name[64];
int age ;
};

// 在被调用函数 获取资源
int getTeacher(Teacher **p)
{
Teacher *tmp = NULL;
tmp = (Teacher*)malloc(sizeof(Teacher));
tmp->age = 33;
// p是实参的地址 *实参的地址 去间接的修改实参的值
*p = tmp;
return 0;
}

// 指针的引用 做函数参数
int getTeacher2(Teacher* &myp)
{
// 给myp赋值 相当于给main函数中的pT1赋值
myp = (Teacher *)malloc(sizeof(Teacher));
if (myp == NULL)
return -1;
myp->age = 36;
return 0;
}
void FreeTeacher(Teacher *pT1)
{
if (pT1 == NULL)
return;

free(pT1);
}

int main(void)
{
Teacher *pT1 = NULL;
// 1 c语⾔言中的二级指针
getTeacher(&pT1);
cout << "age:" << pT1 -> age << endl;
FreeTeacher(pT1);
// 2 c++中的引用(指针的引用)
// 引用的本质间接赋值后2个条件,让c++编译器帮我们程序员做了。
getTeacher2(pT1);
cout << "age:" << pT1 -> age << endl;
FreeTeacher(pT1);
return 0;
}

const 引用

const引用,它可以防止对象的值被随意修改。具有一些特性。

  1. const对象的引用必须是const的,将普通引用绑定到const对象是不合法的

    1
    2
    const int a = 1;
    int &b = a;//这是不合法的
  2. const引用可使用相关类型的对象(常量,非同类型的变量或表达式)初始化。这个是const引用与普通引用最大的区别。

    1
    2
    3
    4
    //以下语句是合法的
    const int &a = 2;
    double x =3.1;
    const int &b =a;
1
2
3
4
5
6
7
8
//普通引用
int a = 10;
int &b = a;

//常引用
int x = 20;
const int &y = x; //常引用是限制变量为只读不能通过y去修改x了
//y = 21; //error

const 引用的原理

const引用是指向const对象的引用:
通过ref2对ival赋值会导致修改const对象的值,为防止这样的修改,需要规定将普通的引用绑定到const对象是不合法的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
const int ival =1024;
const int &refVal = ival; //两者均为const对象
int &ref2 = ival; //error!不能使用非const引用指向const变量
}

{
double val =3.14;
// double *ref = &val;
// const double& ref = val;
const int& ref = val;
// int& refi = vali; //error
double & ref2 = val;
cout << ref << " " << ref2 <<endl;
val = 5.22;
cout << ref << " " << ref2 <<endl;
}


同样的初始化对于非const引用却是不合法的,而且会导致编译时错误,观察将引用绑定到不同的类型时所发生的事情,最容易理解上述行为。对于以下代码

1
2
3
4
5
6
double dval = 3.14;
const int &ri = dval;

//编译器会将这些代码转换为以下形式
int temp = dval;
const int &ri = temp;

可以发现对于将引用绑定到不同类型时,编译器会创建一个int型的暂时变量存储dval,然后将ri绑定到temp上

(Tips:引用在内部存放的是一个对象的地址,它是该对象的别名。对于不可寻址的值,如文字常量,以及不同类型的对象,编译器为了实现引用,必须生成一个临时对象,引用实际上指向该对象,但用户不能访问它。)

  • 结论
    1. const int & e 相当于 const int * const e
    2. 普通引用 相当于 int *const e
    3. 当使用常量(字面量)对const引用进行初始化时,C++编译器会为常量值分配空间,并将引用名作为这段空间的别名
    4. 使用字面量对const引用初始化后,将生成一个只读变量

inline内联函数

类似于C语言中宏函数,但是C中的宏函数处理发生在预处理阶段,没有语法检测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
inline void func(int a)
{
a = 20;
cout << a <<endl;
}
int main(void)
{
func(10);
/*
//编译器将内联函数的函数体直接展开
{
a = 20;
cout << a <<endl;
}
*/
return 0;
}
  1. 特点:

    1. 内联函数声明时inline关键字必须和函数定义结合在一起,否则编译器会直接忽略内联请求。
    2. C++编译器直接将函数体插入在函数调用的地方。
    3. 内联函数没有普通函数调用时的额外开销(压栈,跳转,返回)。
    4. 内联函数是一种特殊的函数,具有普通函数的特征(参数检查,返回类型等)。
    5. 内联函数由编译器处理,直接将编译后的函数体插入调用的地方,宏代码片段由预处理器处理,进行简单的文本替换,没有任何编译过程。
    6. C++的内联编译应该不包含循环、过多的条件判断、不宜实现太过复杂的的功能。
    7. 内联函数相对于普通函数的优势只是省去了函数调用时压栈,跳转和返回的开销。因此,当函数体的执行开销远大于压栈,跳转和返回所用的开销时,那么内联将无意义。
  2. 总结:

    • 本质:以牺牲代码段空间为代价,提高程序的运行时间的效率。
    • 适用场景:函数体很“小”,且被“频繁”调用

函数默认参数

单个默认参数

对于多次调用一函数同一实参时,C++给出了更简单的处理办法。给形参以默认值,这样就不用从实参那里取值了。

1
2
3
4
5
//1 若你填写参数,使⽤用你填写的,不填写默认
void myPrint(int x = 3)
{
cout<<"x: "<<x<< endl;
}

多个默认参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//2 在默认参数规则 ,如果默认参数出现,那么右边的都必须有默认参数
float volume(float length, float weight = 4,float high = 5)
{
return length*weight*high;
}
int main()
{
float v = volume(10);
float v1 = volume(10,20);
float v2 = volume(10,20,30);
cout<<v<<endl;
cout<<v1<<endl;
cout<<v2<<endl;
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
/*
函数占位参数
占位参数只有参数类型声明,⽽而没有参数名声明
⼀一般情况下,在函数体内部⽆无法使⽤用占位参数
*/
int func(int a, int b, int)
{
return a + b;
}

int main()
{
func(1,2); //error,必须把最后一个占位参数补上

func(1,2,3); //right
}

/*
可以将占位参数与默认参数结合起来使用
意义:为以后程序的扩展留下线索兼容C语⾔言程序中可能出现的不规范写法
*/

//C++可以声明占位符参数,占位符参数⼀一般⽤用于程序扩展和对C代码的兼容
int func2(int a, int b, int = 0)
{
return a + b;
}
int main()
{
//如果默认参数和占位参数在一起,都能调用起来
func2(1, 2);
func2(1, 2, 3);
return 0;
}

函数重载

规则(类似Java)

  1. 函数名相同。
  2. 参数个数不同,参数的类型不同,参数顺序不同,均可构成重载。
  3. 返回值类型不同则不可以构成重载。
  4. 一个函数,不能既作重载,又作默认参数的函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int func(int a, int b, int c = 0)
{
return a * b * c;
}
int func(int a, int b)
{
return a + b;
}
int func(int a)
{
return a;
}
int main()
{
int c = 0;
c = func(1, 2); //error. 存在二义性,调用失败,编译不能通过
printf("c = %d\n", c);
return 0;
}

C++基本

头文件保护

引出:重复定义问题

考虑以下示例:
File_1.h头文件包含以下内容

1
2
3
4
int getNumber()
{
return 4;
}

File_2.h

1
#include "File_1.h"

main.cpp

1
2
3
4
5
6
7
#include "File_1.h"
#include "File_2.h"

int main()
{
return 0;
}

这个看似正常的程序无法编译!下面是所发生的事情。首先,main.cpp包含File_1.h,它将函数getNumber的定义复制到main.cpp中。然后,main.cpp包含File_2.h,间接包含File_1.h。这会将File_1.h的内容(包括函数getNumber的定义)复制到File_2.h中,然后将其复制到main.cpp中。

因此,在解析所有#include之后,main.cpp最终如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
int getNumber() //from File_1.h
{
return 4;
}

int getNumber() //from File_2.h
{
return 4;
}
int main()
{
return 0;
}

或者是下面这个例子
假设你有两个类:

1
2
3
// A.h
#include "B.h"
class A { B b; };

1
2
3
// B.h
#include "A.h"
class B { A a; };

互相 include。

如果头文件里面 没有 include guard
编译器在展开 #include 的时候,会无限互相展开 → 死循环。

或者即使没有互相 include
一个头文件被 include 多次,类会重复定义:
error: redefinition of class X

头文件保护

我们可以通过一种称为头文件保护(include guard 也称为包含保护)的机制来避免上述问题。头文件保护是采用以下形式的条件编译指令:

1
2
3
4
5
6
7
8
//但根据惯例,它被设置为头文件的完整文件名,以大写字母键入,使用下划线表示空格或标点
//例如:square.h将被表示为:SQUARE_H
#ifndef SOME_UNIQUE_NAME_HERE
#define SOME_UNIQUE_NAME_HERE

// 这里放置你的声明

#endif

基本调试策略

  1. 注释代码
  2. 验证代码流程
    在需要验证的代码前添加调试输出语句。
    打印用于调试的信息时,请使用std::cerr而不是std:∶cout。因为,std::cout可能有缓冲区而std::cerr没有缓冲,这意味着您发送给它的任何内容都将立即输出
  3. 条件化调试代码
    在引入调试代码后,完成调试后,您要么需要删除它们,要么将它们注释掉。如果以后需要它们,则必须重新添加它们,或取消注释它们。非常麻烦且容易扰乱正常的代码功能。
    使用预处理指令,可以很容易的在整个程序中启用或禁用调试语句
    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
    #include <iostream>

    #define ENABLE_DEBUG // 注释掉这一行,可以禁用掉调试输出
    //如果这是一个多文件程序,#define ENABLE_DEBUG 将放在头文件中,被所有用到的地方引用,方便我们可以在单个位置注释/取消注释#define,并将其传播到所有代码文件。

    int getUserInput()
    {
    #ifdef ENABLE_DEBUG
    std::cerr << "getUserInput() called\n";
    #endif
    std::cout << "Enter a number: ";
    int x{};
    std::cin >> x;
    return x;
    }

    int main()
    {
    #ifdef ENABLE_DEBUG
    std::cerr << "main() called\n";
    #endif
    int x{ getUserInput() };
    std::cout << "You entered: " << x << '\n';

    return 0;
    }

  4. 使用日志记录器(Logger)
    现代常用的调试和错误记录方式,具体内容可以在网络上学习

面向对象

类和对象的关系

类是模板,对象是根据这个模板创建出来的实例

类和结构体的关系

使用结构体完成一段功能程序:

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
struct Node{
// 成员变量
string name;
int age;

// 成员函数
void setValue(string name1,int age1){
name = name1;
age = age1;
}
void show(){
cout<<name<<" "<<age;
}

}node;

int main()
{
string name;
int age;
cin>>name>>age;
node.setValue(name,age);
node.show();
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
class CNode{
public:
// 成员变量
string name;
int age;

// 成员函数
void setValue(string name1,int age1);
void show(){
cout<<name<<" "<<age;
}
};
// 如果函数实现太长可以放在类定义外,类中只放函数声明即可
void CNode::setValue(string name1,int age1){
name = name1;
age = age1;
}

int main()
{
CNode node; //定义一个CNode类
string name;
int age;
cin>>name>>age;
node.setValue(name,age);
node.show();
return 0;
}

简单来看C++中的类和结构体其实含义是相似的只是在表现形式上有所差别,但是其使用的思想是不太相同的。

类的权限

访问属性 属性 对象内部 对象外部
public 公有 可访问 可访问
protected 保护 可访问 不可访问
private 私有 可访问 不可访问

struct中所有行为和属性都是public 的(默认)。
C++中的class可以指定行为和属性的访问方式,默认是private。

对象的构造和析构

这就不得不提中文翻译的不便之处了,从英文意思来看很简单,
构造函数就是对对象进行基本的初始化,
析构函数就是在对象销毁前完成一些清理工作

构造函数 Constructor

基本语法

作用:

  • 当对象被创建时自动调用,用于初始化对象的成员变量或执行其他初始化操作。
    特点:
  1. 函数名必须和类名相同。
  2. 没有返回值(连 void 都不能写)。
  3. 可以有多个构造函数(函数重载),实现不同的初始化方式。
  4. 可以有默认参数,也可以写成 默认构造函数(无参数)。
  5. 访问权限必须是public
  6. 创建对象时只会自动调用一次,不能手动调用
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
class CNode{
private:
// 成员变量
string name;
int age;

public:
// 构造函数
CNode(){
name = "";
age = -100;
}
// @overload constructor
CNode(string name1){
name = name1;
age = 0;
}
// 成员函数
void setValue(string name1,int age1);
void show();
};
int main()
{
CNode node; //定义一个CNode类,调用默认构造
CNode node("张三"); //调用重载构造
...
}

注意:对构造函数进行重载的适合也遵从重载的要求:默认参数可能影响重载

1
2
void f(int x, int y = 0);
void f(int x); // ❌ 可能会产生二义性

初始化列表

基本语法:
类名(形参列表):成员1(值1),成员2(值2)... {......}

示例:

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
class CNode{
private:
// 成员变量
string name;
int age;
public:
// 构造函数
CNode(){
name = "";
age = -100;
}
// @overload constructor
CNode(string name1):name(name1),age(0){
//使用了初始化列表
}
// 成员函数
void setValue(string name1,int age1);
void show();
};
int main()
{
CNode node; //定义一个CNode类,调用默认构造
CNode node("张三"); //调用重载构造
...
}

注意事项:

  1. 从表现上来看使用初始化列表和直接对变量进行赋值没有区别。但是如果需要初始化的成员是类,使用初始化列表调用的是成员类的拷贝构造函数,而赋值则是先创建成员类的对象(调用成员类的普通构造函数),然后再赋值

  2. 如果成员是常量(const)和引用,必须使用初始化列表,因为常量和引用只能在定义的时候初始化

析构函数 Destructor

作用:

  • 当对象生命周期结束时自动调用,用于释放资源或执行清理操作(如释放内存、关闭文件等)。

特点:

  1. 函数名 类名前加 ~(波浪号)。
  2. 没有返回值,不能有参数,也不能重载。
  3. 对象销毁时自动调用,无需手动调用。
  4. 创建对象时只会自动调用一次,可以手动调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 节选
public:
// 构造函数
CNode(){
name.clear();
age = 0;
}
// @overload constructor
CNode(string name1){
name = name1;
age = 0;
}
// 析构函数
~CNode(){
cout<<"调用析构函数";
}

拷贝构造函数

用一个已存在对象创建一个新对象时调用的构造函数
也就是说新对象,不由构造函数来构造,而是由拷贝构造函数来完成。拷贝构造函数的格式是固定的。

语法:

1
2
3
4
5
6
7
class 类名
{
类名(const 类名 &形参名)
{
拷贝构造体
}
}

示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Person {
public:
string name;
Person(){
name.clear();
cout << "构造函数调用" << endl;
}
Person(const Person& other) // 拷贝构造函数
{
this->name = "拷贝"+other.name;
cout << "拷贝构造函数调用" << endl;
}
~Person(){
//析构函数
}
};

int main(){
Person p;
p.name = "张三";
Person p2 = p;
cout<<p.name<<" "<<p2.name;
return 0;
}

  • 拷贝构造函数可以重载,也可以有默认参数
  • 如果类中重载了拷贝构造函数却没有定义默认的拷贝构造函数,编译器也会提供默认的拷贝构造函数

浅拷贝和深拷贝

类型 拷贝内容 优点 缺点
浅拷贝 拷贝指针的值(只复制地址) 快 会导致重复释放、内存问题
深拷贝 拷贝指针指向的数据,重新开辟空间 安全、不互相影响 多一次开销
  • 什么时候必须深拷贝?
    类里有指针成员(尤其是 new 出来的资源)
    必须写深拷贝,否则一定出 bug

简单来说,当类类中有指针成员的时候,使用浅拷贝会在拷贝赋值的时候将两个指针指向一片内存,当类A生命周期结束会释放这片内存。
如果之后拷贝的类也结束了这个类的指针就成了野指针,再去尝试释放当初指向的内存就会导致错误崩溃。
而C++中默认的拷贝构造函数使用的都是浅拷贝,所以当类中需要用到指针时,一定要手动写深拷贝的拷贝构造函数。

看下面代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Person {
public:
int age;
int* score; //指针成员

Person(int a, int s){
age = a;
score = new int(s);
}
};

//假设我们这样操作
Person p1(18, 90);
Person p2 = p1; // 拷贝构造,默认浅拷贝


// 深拷贝的拷贝构造函数
Person(const Person& p){
age = p.age;
score = new int(*p.score); // 深拷贝
}

注意事项

  1. 在构造函数后面加括号和参数不是调用构造函数,而是创建匿名对象
    看下面这个例子:
    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
    class CNode{
    private:
    // 成员变量
    string name;
    int age;

    public:
    // 构造函数
    CNode(){
    name = "";
    age = -100;
    }
    // @overload constructor
    CNode(string name1){
    CNode(); //错误点:调用默认构造函数初始化,再对参数进行初始化
    name = name1;

    }
    // 成员函数
    void setValue(string name1,int age1);
    void show();
    };
    int main()
    {
    CNode node("张三"); //调用重载构造
    ...
    }

如果使用上面这个构造方式会导致非常严重的问题,在传参的重载构造函数中我们调用了CNode(),如果在类中需要初始化的内容很多这样写,似乎可以减少代码量。但是事实上这并不是调用构造函数,而是创建了匿名对象,并没有对对象进行初始化。如果在对象中使用了指针就会产生野指针,甚至导致程序崩溃。

  1. 下面两行代码有本质区别
    1
    2
    3
    4
    5
    // 方法1
    CNode node = CNode("张三"); //创建对象
    // 方法2
    CNode node; //创建对象
    node = CNode("张三"); //创建匿名对象,然后给现有对象赋值

上面代码的方法2 其实调用了两次构造函数和析构函数

创建对象的方式

栈上创建

栈上创建对象(直接创建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Person {
public:
string name;
Person(string n) : name(n) { cout << "构造函数: " << name << endl; }

/*
等价于
:name(n)是初始化列表写法
Person(string n){
name = n;
cout << "构造函数: " << name << endl;
}
*/
~Person() { cout << "析构函数: " << name << " 被调用" << endl; }
};

int main() {
Person p("张三"); // 栈上创建
cout << p.name << endl;
} // p 超出作用域,析构函数自动调用

堆上创建

堆上创建对象(使用 new 和 delete)

1
2
3
4
5
6
7
8
9
10
11
12

#include <iostream>
using namespace std;
// 类定义省略

int main() {
// new返回创建对象的地址,要使用指针
Person* p = new Person("李四"); // 堆上创建,调用构造函数
cout << p->name << endl;

delete p; // 手动释放内存,调用析构函数
}

两种方式的对比

对比项 栈对象 堆对象
内存位置 栈 堆
生命周期 随作用域结束自动销毁 程序员控制,通过 delete
内存管理 自动管理 需手动管理
访问方式 . ->(指针访问)
创建速度 快 慢一些
安全性 高 容易忘记释放,可能泄漏
使用场景 对象数量小、生命周期固定 对象数量大或生命周期不确定
  • 绝大多数情况下,直接在栈上创建对象更好

    • 简单、安全,效率高
    • 不需要手动管理内存
  • 只有在以下情况才建议用 new:

    • 对象数量非常多,栈空间不够
    • 对象需要跨作用域或动态控制
    • 对象需要在运行时动态创建和销毁
  • 现代 C++ 更推荐使用 智能指针,避免因为忘记delete而导致的内存泄漏

const修饰成员函数

在成员函数中后加 const 关键字修饰即可
示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Node{
private:
string name;
int age;

public:
Node(){
name = "张三"; age = 0;
}
void show() const;
};
void Node::show() const{
cout<<name<<" "<<age<<endl;
}

  • const修饰的成员函数只能调用const修饰的成员函数,非cosnt修饰的成员函数则不受限制
  • const修饰的对象只能调用const修饰的成员函数,非cosnt修饰的对象则不受限制

this指针

this 指针是 C++ 类中一个 隐式存在的指针
每一个成员函数里都带着它。
它指向当前对象本身。

使用 return 返回时使用 *this 返回当前对象

类的静态成员

作用:静态成员变量和静态成员函数可以在多个对象之间共享数据,比全局变量更加安全

本质:将类的成员声明为静态的,就可以把它和类的对象独立开来,或者说静态成员不属于对象而是只属于类的,整个程序中只储存一份

使用注意:

  • 静态成员变量不会再创建对象的适合初始化,需要在程序的全局区用代码清晰的初始化
  • 静态成员使用类名加::即可访问,不需要创建对象
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Node{
    static int age;
    };

    int Node::age; //初始化

    int main(){
    cout<<Node::age;
    ...
    }

总结:
| 类型 | 属于 | 是否有 this | 所有对象是否共享 |
| ——— | — | ———— | ———— |
| 普通成员变量 | 对象 | 有 | 否 |
| 静态成员变量 | 类 | 无 | 是 |
| 普通成员函数 | 对象 | 有 | — |
| 静态成员函数 | 类 | 无 | — |

友元

关键字: friend
产生原因:
如果要访问类的私有成员变量,可以通过调用类的public 成员函数,但是类的private 成员函数则无法访问。友元则提供了一种访问类的private 成员的方法。

友元的定义可以放在类中的任意位置

友元有三种:

  • 友元全局函数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class Person{
    friend void showAge(Person& p); // 声明 showAge 是 Person 的友元
    private:
    int age;
    public:
    Person(){}
    Person(int a):age(a){}

    };

    void showAge(Person& p){
    cout << p.age << endl; // 可以访问 private
    }

  • 友元类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class A{
    friend class B; // 整个 B 类都可以访问 A 的 private
    private:
    int x;
    public:
    A():x(10){}

    };

    class B{
    public:
    void f(A& a){
    cout << a.x << endl; // 可以访问 A::x
    }
    };

注意事项:

  • 友元关系不能被继承
  • 友元关系是单向的,不具备交换性
  • 友元成员函数
    假设有两个类 A 和 B,我们希望 B 的某个成员函数能访问 A 的私有成员,而不是整个类 B 都可以访问:
    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
    class B; // 前向声明

    class A {
    private:
    int x;
    public:
    A(int val) : x(val) {}

    // 声明 B 类的成员函数 f 为友元
    friend void B::f(A& a);
    };

    class B {
    public:
    void f(A& a) {
    cout << "访问 A 的私有成员 x = " << a.x << endl;
    }
    };

    int main() {
    A a(100);
    B b;
    b.f(a); // 可以访问 A::x
    }

说明:

  1. 需要前向声明:由于 A 里要声明 B::f 为友元,必须提前告诉编译器 B 是一个类
  2. friend 声明:写在类 A 内,声明 B::f 是 A 的友元成员函数

类的自动类型转换

概念:
C++ 支持对象在某些情况下自动转换成其他类型,或者其他类型转换成对象。

C++基础类型 → 类对象

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class A {
int x;
public:
A(){}
A(int val) : x(val) {} // 单参数构造函数
void show() { cout << x << endl; }
};

int main() {
A a = 10; // int 自动转换为 A 对象,调用 A(10)
a.show(); // 输出 10
}

int main(){
A a1(8); // 常规写法
A a1 = A(8); //显式转换
A a1 = 8 //隐式转换

//先创建对象再赋值
A a1;
a1 = 8;
}

说明:

  1. 单参数构造函数可以被用作 类型转换构造函数

禁止自动类型转换:
如果不想允许隐式转换(仍可以显示转换),可以在单参数构造函数前加 explicit

1
2
3
4
5
6
7
8
9
10
11
class A {
int x;
public:
explicit A(int val) : x(val) {} //禁用隐式转换
};

int main() {
// A a = 10; // 错误!必须显式调用
A a(10); // 正确
}

类 → 其他类型

通过在类中定义 operator Type()函数实现。

注意:

  • 在实际开发中使用operator进行类型转换不如使用自己手动编写的类型转换函数,因为隐式的转换有时候可能会导致错误和二义性,反而不利于代码维护和排查错误

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class A {
int x;
string y;
double z;
public:
A(int val) : x(val) {}

// 类 A → int 的转换
operator int() { return x; }
// 类 A → string 的转换
operator string() { return y; }
// 类 A → double 的转换
operator double() {return z; }
};

int main() {
A a;
int n = a; // 调用 operator int() 自动转换
string y = string(A);
double z = double(A);
}

CppC++
Godot基本操作
发表于2023-12-26|更新于2025-05-21|游戏制作

配置外部编辑器

使用idea发现不能idea中的tab在godot脚本中会变成空格导致错误,还有不能同步的奇怪问题,所以还是决定使用vscode做外部编辑器。

  1. 在下载vscode中下载godot的插件(适用于GDScript的那一个)
  2. 配置好lunch.json文件
    1. 参考网站外部编辑器设置
    2. 修改json代码
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
          "configurations": [
      {
      "name": "Godot Debug",
      "type": "godot",
      "request": "launch",
      "project": "${workspaceFolder}",
      "port": 6007,
      "debugServer": 6005, //godot4.x修改了默认的端口,这里也要修改插件中对应的端口号(Gdscript_lsp_server_port也改为6005)
      "address": "127.0.0.1",
      "launch_game_instance": true,
      "launch_scene": false
      }
      ]

基础操作

节点的基础操作

  • Q,选择工具,最强大的工具
1
2
3
4
Alt + 拖曳边框缩放点,保持选中目标中心位置不变进行缩放
Shift + 等比列缩放
Ctrl + 鼠标拖曳,以pivot为中心旋转当前对象
Alt + Shift + 拖曳边框缩放点,保持选中目标中心位置不变进行等比例缩放
  • E,旋转工具,改变 Rotation
1
2
3
4
5
6
7
8
9
轴心点,又称中心点,是节点的旋转中心

V,移动轴心点,Shift + V 拖拽轴心点

中心点是一个物体的中心,当我们对物体进行操作的时候,都是以中心点为中心进行对物体的操作
轴心点可以理解为把物体抽象成一个点,物体的位置就是指其轴心点的位置,就是该物体坐标系的原点

Rotate Mode 对象的旋转
沿轴心点旋转
  • F,定位到选中对象
godotgame engine
QuantumultX 使用记录
发表于2023-11-11|更新于2025-10-15|软件苹果

记录一下QuantumultX的使用教程和一些规则

如何添加JS脚本

点击规则资源->右上角第三个->在资源路径里填上要订阅的脚本链接即可

添加完成后可以在最下面的配置文件的[rewrite-remote]标签下修改参数

QuantumultX-1

QuantumultX
操作系统-进程和线程
发表于2023-07-26|更新于2025-05-21|计算机基础操作系统

简介

操作系统系列其二,主要是针对中国大陆考研所要求的内容对操作系统的知识体系进行总结和梳理,对于重点内容会使用黑体加粗,本篇内容主要包含以下几点:

  • 进程与线程的概念
  • CPU调度
    • 经典调度算法
  • 同步与互斥
    • 信号量
    • 经典同步问题
  • 死锁

大纲

进程与线程

进程的概念

在多道程序环境下,允许多个程序并发执行,此时它们将失去封闭性,并具有间断性及不可再现性的特征。为此引入了进程(Process)的概念,以便更好地描述和控制程序的并发执行,实现操作系统的并发性和共享性(最基本的两个特性)
为了使参与并发执行的每个程序(含数据)都能独立地运行,必须为之配置一个专门的数据结构,称为进程控制块(Process Control Block, PCB)
系统利用 PCB 来描述进程的基本情况和运行状态,进而控制和管理进程,PCB是进程存在的唯一标志。相应地,由程序段、相关数据段和 PCB 三部分构成了进程实体(又称进程映像)
所谓创建进程,实质上是创建进程实体中的PCB,而撤销进程,实质上是撤销进程的PCB。值得注意的是,进程映像是静态的,进程则是动态的。

进程的状态与转换

进程在其生命周期内,由于系统中各进程之间的相互制约及系统的运行环境的变化,使得进程的状态也在不断地发生变化。通常进程有以下5种状态,前3种是进程的基本状态。

  • 运行态。进程正在处理机上运行。在单处理机中,每个时刻只有一个进程处于运行态。
  • 就绪态。进程获得了除处理机外的一切所需资源,一旦得到处理机,便可立即运行。系统中处于就绪状态的进程可能有多个,通常将它们排成一个队列,称为就绪队列。
  • 阻塞态,又称等待态。进程正在等待某一事件而暂停运行。即使处理机空闲,该进程也不能运行。系统通常将处于阻塞态的进程也排成一个队列,甚至根据阻塞原因的不同,设置多个阻塞队列。
  • 创建态。进程正在被创建,尚未转到就绪态。创建进程需要多个步骤,如果进程所需的资源尚不能得到满足,如内存不足,则创建工作尚未完成,进程此时所处的状态称为创建态。
  • 终止态。进程正从系统中消失,可能是进程正常结束或其他原因退出运行。进程需要结束运行时,系统首先将该进程置为终止态,然后进一步处理资源释放和回收等工作。

注意区别就绪态和等待态:就绪态是指进程仅缺少处理器,只要获得处理机资源就立即运行;而等待态是指进程需要其他资源(除了处理机)或等待某一事件。

  • 进程的五态图

操作系统-进程-1

需要注意的是,一个进程从运行态变成阻塞态是主动的行为,而从阻塞态变成就绪态是被动的行为,需要其他相关进程的协助。

进程组成

  1. PCB
    • 进程创建时,操作系统为它新建一个PCB,该(数据)结构之后常驻内存,任意时刻都可以存取,并在进程结束时删除。PCB是进程实体的一部分,是进程存在的唯一标志。
    • 进程执行时,系统通过其PCB了解进程的现行状态信息,以便操作系统对其进行控制和管理;进程结束时,系统收回其PCB,该进程随之消亡。
  2. 程序段
  3. 数据段

进程控制

进程控制的主要功能是对系统中的所有进程实施有效的管理,它具有创建新进程、撤销己有进程、实现进程状态转换等功能。
在操作系统中,一般把进程控制用的程序段称为原语,原语的特点是执行期间不允许中断,它是一个不可分割的基本单位。
控制原语所作的事:

  1. 更新PCB中的信息
  2. 将PCB插入合适的队列
  3. 分配、回收资源

进程通信

  1. 共享存储
    在通信的进程之间存在一块可直接访问的共享空间,通过对这片共享空间进行写/读操作实现进程之间的信息交换。在对共享空间进行写/读操作时,需要使用同步互斥工具(如P操作、V操作),对共享空间的写/读进行控制。
    注意,进程空间一般都是独立的,进程运行期间一般不能访问其他进程的空间,想让两个进程共享空间,必须通过特殊的系统调用实现,而进程内的线程是自然共享进程空间的。
    操作系统-进程-2
  2. 信息传递
    在消息传递系统中,进程间的数据交换以格式化的消息 (Message) 为单位。
    进程通过系统提供的发送消息和接收消息两个原语进行数据交换。

    1. 直接通信方式。发送进程直接把消息发送给接收进程,并将它挂在接收进程的消息缓队列上,接收进程从消息缓冲队列中取得消息
    2. 间接通信方式。发送进程把消息发送到某个中间实体,接收进程从中间实体取得消息。这种中间实体一般称为信箱。该通信方式广泛应用于计算机网络中。

      简单理解就是,甲要告诉乙某些事情,就要写信,然后通过邮差送给乙。直接通信就是邮差把信直接送到乙的手上;间接通信就是乙家门口有一个邮箱,邮差把信放到邮箱里。

    操作系统-进程-3

  3. 管道通信
    管道通信允许两个进程按生产者一消费者方式进行通信,生产者向管道的一端写,消费者从管道的另一端读。数据在管道中是先进先出的。

    注意:从管道读数据是一次性操作,数据一旦被读取,就释放空间以便写更多数据。普通管道只允许单向通信,若要实现父子进程双向通信,则需要定义两个管道

线程和多线程模型

线程基本概念

引入进程的目的是更好地使多道程序并发执行,提高资源利用率和系统吞吐量;
而引入线程的目的则是减小程序在并发执行时所付出的时空开销,提高操作系统的并发性能。

线程最直接的理解就是“轻量级进程”,它是一个基本的 CPU 执行单元也是程序执行流的最小单位。

引入线程之后,不仅是进程之间可以并发,进程内的各线程之间也可以并发,从而进一步提升了系统的并发度,使得一个进程内也可以并发处理各种任务引入线程后,进程只作为除CPU之外的系统资源的分配单元(如打印机、内地址空间等都是分配给进程的)。线程则作为处理机的分配单元。

引入线程后的变化

  1. 资源分配、调度
    • 传统进程机制中,进程是资源分配、调度的基本单位
    • 引入线程后,线程是资源分配的基本单位、进程是调度的基本单位
  2. 并发性
    • 传统进程机制中,只能进程间并发
    • 引入线程后,线程间也能并发
  3. 系统开销
    • 传统进程机制中,并发需要切换进程的运行环境,系统开销大
    • 引入线程后,线程间并发,如果是在同一进程内的线程切换,不需要切换运行环境,系统开销小

线程的属性

  • 每个内核级线程都有一个线程ID、线程控制块(TCB)
  • 线程也有就绪、阻塞、运行三种基本状态
  • 线程几乎不拥有系统资源,同一进程的不同线程共享进程的资源
  • 同一进程内的线程切换,不会引起进程切换;不同进程内的线程切换,会引起进程切换

线程的实现方式

线程的实现分为两类:用户级线程和内核级线程

  1. 用户级线程
    • 用户级线程由应用程序通过线程库实现,所有的线程管理工作都由应用程序负责(包括线程切换)
    • 线程的切换在用户态下即可完成,不需要系统干预
    • 用户级线程就是从用户视角能看到的线程,操作系统内核意识不到线程的存在
  2. 内核级线程
    • 内核级线程的管理工作由操作系统内核完成,线程的调度、切换都由内核完成,所有内核级线程的切换必然需要在核心态下完成
    • 操作系统会为每个内核级线程建立相应的TCB,内核级线程就是从系统内核视角能看到的线程
  3. 组合方式
    有些系统使用组合方式的多线程实现。
    在组合实现方式中,内核支持多个内核级线程的建立、调度和管理,同时允许用户程序建立、调度和管理用户级线程。
    一些内核级线程对应多个用户级线程。

操作系统-进程-5

多线程模型

有些系统同时支持用户线程(“代码逻辑”的载体)和内核线程(“运行机会”的载体),由于用户级线程和内核级线程连接方式的不同,从而形成了下面三种不同的多线程模型。

  1. 一对一模型
    一个用户级线程映射到一个内核级线程。每个用户进程有与用户级线程同数量的内核级线程。

    优点:当一个线程被阻塞后,别的线程还可以继续执行,并发能力强。多线程可在多核处理机上并行执行。
    缺点:一个用户进程会占用多个内核级线程,线程切换由操作系统内核完成,需要切换到核心态,因此线程管理的成本高,开销大。

    操作系统-进程-6

  2. 多对一模型
    多个用户级线程映射到一个内核级线程。且一个进程只被分配一个内核级线程。

    优点:用户级线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高

    缺点:当一个用户级线程被阻塞后,整个进程都会被阻塞,并发度不高。多个线程不可在多核处理机上并行运行

    !!!重点:操作系统只“看得见”内核级线程,因此只有内核级线程才是处理机分配的单位。

    操作系统-进程-7

  3. 多对多模型
    n用户及线程映射到m个内核级线程(n>=m)。每个用户进程对应m个内核级线程。

    克服了多对一模型并发度不高的缺点(一个阻塞全体阻塞),又克服了一对一模型中一个用户进程占用太多内核级线程,开销大的缺点

    操作系统-进程-8

例题

  1. 线程:
    操作系统-进程-4

答:选B,操作系统会为每个内核级线程建立相应的TCB,意识不到用户级线程的存在

CPU调度

基本概念

  1. 概念
    处理机调度是对处理机进行分配,即从就绪队列中按照一定的算法(公平、高效的原则)选择一个进程并将处理机分配给它运行,以实现进程并发地执行
  2. 调度的层次
    作业:一个具体的任务
    用户向系统提交一个作业 ≈ 用户让操作系统启动一个程序(来处理一个具体的任务)

    一个作业从提交到完成,往往要经历以下三级调度

    1. 高级调度(作业调度) (外存->内存,发生频率最低)
      按一定的原则从外存的作业后备队列中挑选一个作业调入内存,并创建进程。每个作业只调入一次,调出一次。作业调入时会建立PCB,调出时才撤销PCB

      无->创建态->就绪态

    2. 中级调度(内存调度) (外存->内存,发生频率中等)
      按照某种策略决定将哪个处于挂起状态的进程重新调入内存

      挂起态->就绪态

    3. 低级调度(进程调度) (内存->CPU,发生频率最高)
      按照某种策略从就绪队列中选取一个进程,将处理机分配给它,进程调度是操作系统中最基本的一种调度

      就绪态->运行态

进程调度的时机、切换和过程

临界资源:一个时间段只允许一个进程使用的资源。各进程互斥地访问临界资源(例如:打印机)
临界区:访问临界资源的代码

进程调度的时机

  • 不能进行进程调度和切换的情况:
    1. 在处理中断的过程中。中断处理过程复杂,与硬件密切相关,很难做到在中断处理过程中进行进程切换。
    2. 进程在操作系统内核程序临界区中。
    3. 在原子操作过程中(原语)。原子操作不可中断,要一气呵成(如之修改PCB中进程状态标志,并把PCB放到相应队列)
  • 需要进行进程调度和切换的情况:
    1. 当前运行的进程主动放弃处理机(进程正常终止、发生异常终止、请求阻塞等)
    2. 当前运行的进程被动放弃处理机(进程时间片耗尽、有优先级更高的进程)

进程的切换与过程

进程切换是指一个进程让出处理机,由另一个进程占用处理机的过程

进程切换的过程主要完成了:

  1. 对原来运行进程各种数据的保存
  2. 对新的进程各种数据的恢复

注意:进程切换是有代价的,因此如果过于频繁的进行进程调度、切换,必然会使整个系统的效率降低,使系统大部分时间都花在了进程切换上,而真正用于执行进程的时间减少

进程调度的方式

  1. 非剥夺调度方式,又称非抢占方式。即只允许进程主动放弃处理机。运行过程中即便有更紧迫的任务到达,当前进程依然会继续使用处理机,直到该进程终止或主动要求进入阻塞态
  2. 剥夺调度方式,又称抢占方式。当一个进程正在处理机上执行时,如果有一个更重要或更紧迫的进程需要使用处理机,则立即暂停正在执行的进程,将处理机分配给更重要紧迫的那个进程

调度算法的评价指标

  1. CPU利用率。
    $CPU利用率=\frac{CPU有效工作时间}{CPU有效工作时间+CPU空闲等待时间} $
  2. 系统吞吐量。表示单位时间内CPU完成作业的数量
    $系统吞吐量=\frac{完成作业总数}{总共花费时间}$
  3. 周转时间。是指从作业被提交给系统开始,到作业完成为止的这段时间间隔

    • (作业)周转时间=作业完成时间-作业提交时间

    • 平均周转时间:所有作业周转时间的平均值
      $平均周转时间=\frac{各作业周转时间之和}{作业数}$

    • $带权周转时间=\frac{作业周转时间}{作业实际运行的时间}=\frac{作业完成时间-作业提交时间}{作业实际运行的时间}$

    • $平均带权周转时间=\frac{各作业带权周转时间之和}{作业数}$

  4. 等待时间。指进程/作业处于等待处理机状态时间之和
    等待时间=周转时间-运行时间
  5. 响应时间。指从用户提交请求到首次产生响应所用的时间

典型的调度算法

注意各个算法的优缺点、性能、概念、是否会导致饥饿(某进程/作业长期
得不到服务)、用于作业调度还是进程调度、抢占式还是非抢占式

  • 概念:
  • 用于作业调度还是进程调度:
  • 抢占式还是非抢占式:
  • 优点:
  • 缺点:
  • 是否会导致饥饿:
  • 性能:

先来先服务(FCFS)

  • 概念:按照作业/进程到达的先后顺序进行服务
  • 用于作业调度还是进程调度:既可用于作业调度,也可用于进程调度。
  • 抢占式还是非抢占式:非抢占式的算法
  • 优点:公平、算法实现简单
  • 缺点:对长作业有利,对短作业不利
  • 是否会导致饥饿:不会
  • 性能:

操作系统-进程-9

短作业优先(SJF)

  • 概念:最短的作业/进程优先得到服务(所谓“最短”,是指要求服务时间最短)
  • 用于作业调度还是进程调度:即可用于作业调度,也可用于进程调度。
  • 抢占式还是非抢占式:非抢占式(但也有抢占版本)
  • 优点:有“最短的”平均等待时间、平均周转时间
  • 缺点:对短作业有利,对长作业不利。可能产生饥饿现象
  • 是否会导致饥饿:会。如果源源不断地有短作业/进程到来,可能使长作业/进程长时间得不到服务,产生“饥饿”现象
  • 性能:
    • 非抢占式:
      操作系统-进程-10
    • 抢占式:
      操作系统-进程-11

注意:

  1. 如果题目中未特别说明,所提到的“短作业/进程优先算法”默认是非抢占式的
  2. 在在所有进程同时可运行时条件下,采用SJF调度算法的平均等待时间、平均周转时间最少(没有条件的表述是不严谨的)
  3. 如果选择题中遇到“SJF算法的平均等待时间、平均周转时间最少”的选项,那最好判断其他选项是不是有很明显的错误,如果没有更合适的选项,那应该选择该选项

最高相应比优先

  • 概念:在每次调度时先计算各个作业/进程的响应比,选择响应比最高的作业/进程为其服务
    $响应比=\frac{等待时间+要求服务时间}{要求服务时间}\geq{1}$
  • 用于作业调度还是进程调度:即可用于作业调度,也可用于进程调度
  • 抢占式还是非抢占式:非抢占式的算法
  • 优缺点:综合考虑了等待时间和运行时间,对于长作业来说,随着等待时间越来越久,其响应比也会越来越大,从而避免了长作业饥饿的问题
  • 是否会导致饥饿:不会
  • 性能:

操作系统-进程-12

这三种算法一般适合用于早期的批处理系统,并不区分任务的紧急程度,FCFS算法也常结合其他的算法使用,在现在也扮演着很重要的角色


时间片轮转

  • 概念:按照各进程到达就绪队列的顺序,轮流让各个进程执行一个时间片(如100ms)。若进程未在一个时间片内执行完,则剥夺处理机,将进程重新放到就绪队列队尾重新排队

  • 用于作业调度还是进程调度:用于进程调度

  • 抢占式还是非抢占式:抢占式,由时钟装置发出时钟中断来通知CPU时间片已到
  • 优点:公平;响应快,适用于分时操作系统
  • 缺点:由于高频率的进程切换,因此有一定开销;不区分任务的紧急程度
  • 是否会导致饥饿:不会
  • 性能:时间片的大小对系统的性能影响很大

优先级调度

  • 概念:调度时选择优先级最高的作业/进程
  • 用于作业调度还是进程调度:既可用于作业调度,也可用于进程调度
  • 抢占式还是非抢占式:抢占式、非抢占式都有
  • 优点:用优先级区分紧急程度、重要程度,适用于实时操作系统。可灵活地调整对各种作业/进程的偏好程度
  • 缺点:若源源不断地有高优先级进程到来,则可能导致饥饿
  • 是否会导致饥饿:会
  • 性能:

多级反馈队列

  • 概念:融合了前几种算法的优点
    • 设置多级就绪队列,各级队列优先级从高到低,时间片从小到大
    • 新进程到达时先进入第1级队列,按FCFS原则排队等待被分配时间片,若用完时间片进程还未结束,则进程进入下一级队列队尾。如果此时已经是在最下级的队列,则重新放回该队列队尾
    • 只有第 k 级队列为空时,才会为 k+1 级队头的进程分配时间片
  • 用于作业调度还是进程调度:用于进程调度
  • 抢占式还是非抢占式:抢占式的算法
  • 优点:
    • 对各类型进程相对公平(FCFS的优点);
    • 每个新到达的进程都可以很快就得到响应(RR的优点);
    • 短进程只用较少的时间就可完成(SPF的优点);
    • 不必实现估计进程的运行时间(避免用户作假);
    • 可灵活地调整对各类进程的偏好程度,比如CPU密集型进程、I/O密集型进程(拓展:可以将因I/O而阻塞的进程重新放回原队列,这样I/O型进程就可以保持较高优先级)
  • 缺点:
  • 是否会导致饥饿:会
  • 性能:

操作系统-进程-13

这三种算法适合用于交互式系统,更注重系统的响应时间、公平性、平衡性等指标


同步与互斥

基本概念

进程同步

因为进程具有异步性的特征。操作系统要提供“进程同步机制”来解决异步问题,以防止程序产生不可预知的错误

进程互斥

各个并发执行的进程不可避免的需要共享一些系统资源(比如内存,打印机、摄像头这样的I/O设备)

资源共享分为:互斥共享方式和同时共享方式

  • 互斥共享方式:一个时间段内只允许一个进程访问该资源
  • 同时共享方式:允许一个时间段内由多个进程“同时”对它们进行访问

我们把一个时间段内只允许一个进程使用的资源称为临界资源(如摄像头、打印机、一些变量和数据等)。对临界资源的访问,必须互斥地进行。可以把临界资源的访问过程分成四个部分:

  1. 进入区(可理解为“上锁”):负责检查是否可进入临界区,若可进入,则应设置正在访问临界资源的标志
  2. 临界区:访问临界资源的那段代码
  3. 退出区(可理解为“解锁”):负责解除正在访问临界资源的标志
  4. 剩余区:代码中剩余的其他部分

进入区和退出区是负责实现互斥的代码段;临界区是进程中访问临界资源的代码段

进程互斥:指当一个进程访问某临界资源时,另一个想要访问该临界资源的进程必须等待。当前访问临界资源的进程访问结束,释放该资源之后,另一个进程才能去访问临界资源。

为了实现对临界资源的互斥访问,同时保证系统整体性能,同步机制应该遵循以下原则:

  1. 空闲让进。临界区空闲时,可以允许一个请求进入临界区的进程立即进入临界区;
  2. 忙则等待。当已有进程进入临界区时,其他试图进入临界区的进程必须等待;
  3. 有限等待。对请求访问的进程,应保证能在有限时间内进入临界区(保证不会饥饿);
  4. 让权等待。当进程不能进入临界区时,应立即释放处理机,防止进程忙等待。

实现临界区互斥的基本方法

软件实现方法

算法一:单标志法

算法思想:两个进程在访问完临界区后会把使用临界区的权限转交给另一个进程。也就是说每个进程进入临界区的权限只能被另一个进程赋予

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int turn = 0; 
//turn表示当前运行进入临界区的进程号(turn 变量背后的逻辑:表达“谦让”)

P0进程:
while(turn!=0); //1 进入区
critial section; //2 临界区
turn = 1; //3 退出区
remainder section; //4 剩余区

P1进程:
while(turn!=1); //5 进入区
critial section; //6 临界区
turn = 0; //7 退出区
remainder section; //8 剩余区

运行逻辑:turn 的初值为0,即刚开始只允许0号进程进入临界区。若P1先上处理机运行,则会一直卡在5。直到P1的时间片用完,发生调度,切换P0上处理机运行。代码1不会卡住P0,P0可以正常访问临界区,在P0访问临界区期间即时切换回 P1,P1依然会卡在5。只有P0在退出区将 turn 改为 1 后,P1才能进入临界区。

缺点:两个进程必须交替的进入临界区,若某个进程不再进入临界区,则另一个进程也无法进入临界区(违背“空闲让进”原则)

算法二:双标志先检查法

算法思想:设置一个布尔型数组 flag[] ,数组中各个元素用来标记各进程想进入临界区的意愿,比如“flag[0] = ture”意味着 0 号进程 P0 现在想要进入临界区。每个进程在进入临界区之前先检查当前有没有别的进程想进入临界区,如果没有,则把自身对应的标志 flag[i]设为 true,之后开始访问临界区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bool flag[2];  //表示进入临界区的意愿(背后的含义:“表达意愿”)
flag[0] = false;
flag[1] = false;

P0进程:
while(flag[1]); //1 进入区,如果此时P1想要进入临界区,P0就一直循环等待
flag[0] = true; //2 进入区,标记为P0进程想要进入临界区

critial section; //3 临界区
flag[0] = false; //4 退出区,修改标记为P0不想进入临界区
remainder section; //剩余区

P1进程:
while(flag[0]); //5 进入区,如果此时P0想要进入临界区,P1就一直循环等待
flag[1] = true; //6 进入区,标记为P1进程想要进入临界区

critial section; //7 临界区
flag[1] = false; //8 退出区,修改标记为P1不想进入临界区
remainder section; //剩余区

算法问题:若按照 1,5,2,6,3,7….的顺序执行,P0 和 P1 将会同时访问临界区。
违反“忙则等待”原则,其原因在于进入区的“检查”和“上锁” ,两个处理不是一气呵成的。“检查”后,“上锁”前可能发生进程切换。

算法三:双标志后检查法

算法思想:双标志先检查法的改版。人们又想到先“上锁”后“检查”的方法,来避免上述问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bool flag[2];  //表示进入临界区的意愿(背后的含义:“表达意愿”)
flag[0] = false;
flag[1] = false;

P0进程:
flag[0] = true; //1 进入区,标记为P0进程想要进入临界区
while(flag[1]); //2 进入区,如果此时P1想要进入临界区,P0就一直循环等待

critial section; //3 临界区
flag[0] = false; //4 退出区,修改标记为P0不想进入临界区
remainder section; //剩余区

P1进程:
flag[1] = true; //5 进入区,标记为P1进程想要进入临界区
while(flag[0]); //6 进入区,如果此时P0想要进入临界区,P1就一直循环等待

critial section; //7 临界区
flag[1] = false; //8 退出区,修改标记为P1不想进入临界区
remainder section; //剩余区

算法问题:若按照 1,5,2,6….的顺序执行,P0 和 P1 将都无法进入临界区
因此,双标志后检查法虽然解决了“忙则等待”的问题,但是又违背了“空闲让进”和“有限等待”原则,会因各进程都长期无法访问临界资源而 产生“饥饿” 现象

算法四:Peterson算法

算法思想:结合双标志法、单标志法的思想。如果双方都争着想进入临界区,那可以让进程尝试谦让

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
bool flag[2];  //表示进入临界区的意愿(背后的含义:“表达意愿”)
int turn = 0; //turn表示优先让哪个进程进入临界区(背后的逻辑:表达“谦让”)
flag[0] = false;
flag[1] = false;

P0进程:
flag[0] = true; //1 进入区,标记为P0进程想要进入临界区
turn = 1 //可以优先让对方进入临界区
while(flag[1] && turn = 1); //2 进入区,如果此时P1想要进入临界区并且P0表示谦让,P0就一直循环等待

critial section; //3 临界区
flag[0] = false; //4 退出区,修改标记为P0不想进入临界区
remainder section; //剩余区

P1进程:
flag[1] = true; //5 进入区,标记为P1进程想要进入临界区
turn = 0 //可以优先让对方进入临界区
while(flag[0] && turn = 0); //6 进入区,如果此时P0想要进入临界区并且P1表示谦让,P1就一直循环等待

critial section; //7 临界区
flag[1] = false; //8 退出区,修改标记为P1不想进入临界区
remainder section; //剩余区

算法分析:谁最后表达了谦让,谁就失去了行动的优先权
算法问题:Peterson 算法用软件方法解决了进程互斥问题,遵循了空闲让进、忙则等待、有限等待 三个原则,但是依然未遵循让权等待的原则

硬件实现方法

中断屏蔽方法

利用“开/关中断指令”实现(与原语的实现思想相同,即在某进程开始访问临界区到结束访问为止都不允许被中断,也就不能发生进程切换,因此也不可能发生两个同时访问临界区的情况)

1
2
3
关中断;
临界区;
开中断;

优点:简单、高效
缺点:不适用于多处理机;只适用于操作系统内核进程,不适用于用户进程(因为开/关中断指令只能运行在内核态,这组指令如果能让用户随意使用会很危险)

硬件指令方法
  1. TestAndSetLock 指令(TSL指令)
    TSL指令是用硬件实现的,执行的过程不允许被中断,只能一气呵成。
    简而言之,相比软件实现方法,TSL 指令把“上锁”和“检查”操作用硬件的方式变成了一气呵成的原子操作
    • 优点:实现简单,无需像软件实现方法那样严格检查是否会有逻辑漏洞;适用于多处理机环境
    • 缺点:不满足“让权等待”原则,暂时无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致“忙等”
  2. Swap指令(也叫 Exchange 指令,或简称 XCHG 指令)
    Swap 指令是用硬件实现的,执行的过程不允许被中断,只能一气呵成
    逻辑上来看 Swap 和 TSL 并无太大区别
    优缺点同TSL指令

互斥锁

解决临界区最简单的工具就是互斥锁。一个进程在进入临界区时应获得锁;在退出临界区时释放锁。
函数 acquire()获得锁,而函数release()释放锁。 acquire()和release()的执行必须是原子操作,因此互斥锁通常使用硬件机制来实现
互斥锁的主要缺点是忙等待。需要连续循环忙等的互斥锁,都可称为自旋锁,如TSL指令、swap指令、单标志法

信号量

信号量S其实就是一个变量(可以是一个整数,也可以是更复杂的记录型变量),可以用一个信号量来表示系统中某种资源的数量,比如:系统中只有一台打印机,就可以设置一个初值为1的信号量

信号量只能被两个标准的原语wait(S)和signal(S)访问,其常简称为P、V操作,也即wait(S)称为P(S),signal(S)称为V(S),这对原语可用于实现系统资源的“申请”和“释放”。

整型信号量

用一个整数型的变量作为信号量,用来表示系统中某种资源的数量
整型信号量的缺陷是存在“忙等”问题

记录型信号量

1
2
3
4
5
// 记录型信号量的定义
typedef struct{
int value;//S.value 的初值表示系统中某种资源的数目
Struct process *L; //链表L用来链接等待该资源的进程
} semaphore;

对信号量 S 的一次P操作意味着进程请求一个单位的该类资源,当
S.value<0 时表示该类资源已分配完毕,因此进程应调用 block 原语进行自我阻塞,并插入该类资源的等待队列S.L中。

对信号量 S 的一次V操作意味着进程释放一个单位的该类资源,若加1后仍是S.value<=0,表示依然有进程在等待该类资源,因此应调用wakeup 原语唤醒等待队列中的第一个进程

注:若考试中出现 P(S)、V(S) 的操作,除非特别说明,否则默认 S 为记录型信号量

利用信号量实现同步

用信号量实现进程同步:

  1. 分析什么地方需要实现“同步关系”,即必须保证“一前一后”执行的两个操作(或两句代码)
  2. 设置同步信号量S, 初始为0
  3. 在“前操作”之后执行 V(S)
  4. 在“后操作”之前执行 P(S)

技巧口诀:前V后P
理解:信号量S代表“某种资源”,刚开始是没有这种资源的。P2需要使用这种资源,而又只能由P1产生这种资源

信号量机制实现前驱关系

其实每一对前驱关系都是一个进程同步问题(需要保证一前一后的操作)
因此,

  1. 要为每一对前驱关系各设置一个同步信号量
  2. 在“前操作”之后对相应的同步信号量执行 V 操作
  3. 在“后操作”之前对相应的同步信号量执行 P 操作

操作系统-进程-14

操作系统-进程-15

利用信号量实现进程互斥

  1. 分析并发进程的关键活动,划定临界区(如:对临界资源打印机的访问应放在临界区)
  2. 设置互斥信号量 mutex,初值为 1
  3. 在进入区 P(mutex)——申请资源
  4. 在退出区 V(mutex)——释放资源

注意:对不同的临界资源需要设置不同的互斥信号量。P、V操作必须成对出现。缺少P(mutex) 就不能保证临界资源的互斥访问。缺少 V(mutex) 会导致资源永不被释放,等待进程永不被唤醒

注意:同步和互斥之间的对比

互斥问题,信号量初值为1
同步问题,信号量初值为0

除了互斥、同步问题外,还会考察有多个资源的问题,有多少资源就把信号量初值设为多少。申请资源时进行P操作,释放资源时进行 V 操作即可

管程

为了解决信号量机制导致的编写程序困难、易出错问题,引入管程机制来实现进程同步。

管程是一种特殊的软件模块

  1. 局部于管程的共享数据结构说明;
  2. 对该数据结构进行操作的一组过程(即函数);
  3. 对局部于管程的共享数据设置初始值的语句;
  4. 管程有一个名字

管程的基本特征:

  1. 局部于管程的数据只能被局部于管程的过程所访问;
  2. 一个进程只有通过调用管程内的过程才能进入管程访问共享数据;
  3. 每次仅允许一个进程在管程内执行某个内部过程。

经典同步问题

PV操作题目分析步骤:

  1. 关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。
  2. 整理思路。根据各进程的操作流程确定P、V操作的大致顺序。
  3. 设置信号量。并根据题目条件确定信号量初值。(互斥信号量初值一般为1,同步信号量的初始值要看对应资源的初始值是多少)

生产者-消费者问题

问题描述:

系统中有一组生产者进程和一组消费者进程,生产者进程每次生产一个产品放入缓冲区,消费者进程每次从缓冲区中取出一个产品并使用(注:这里的“产品”理解为某种数据)
生产者、消费者共享一个初始为空、大小为n的缓冲区。
只有缓冲区没满时,生产者才能把产品放入缓冲区,否则必须等待。
只有缓冲区不空时,消费者才能从中取出产品,否则必须等待。
缓冲区是临界资源,各进程必须互斥地访问。

问题分析
  1. 关系分析。生产者和消费者对缓冲区的互斥访问是互斥关系,同时生产者和消费者也是一个相互协作问题,只有生产者生产后,消费者才能消费,他们是同步关系
  2. 整理思路。
  3. 信号量设置
    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
    semaphore mutex = 1;//互斥信号量,实现对缓冲区的互斥访问
    semaphore empty = n;//同步信号量,表示空闲缓冲区的数量
    semaphore full = 0;//同步信号量,表示产品的数量,也即非空缓冲区的数量


    producer (){
    while(1){
    生产一个产品;
    P(empty);//消耗一个空缓冲区

    P(mutex);
    把产品放入缓冲区; //实现互斥是在同一进程中进行一对PV操作
    V(mutex);

    V(full);//增加一个产品
    }
    }

    consumer (){
    while(1){
    P(full); //实现两个进程同步关系,是在其中一个进程中执行P另一个执行V,这里符合“前V后P”

    P(mutex);
    从缓冲区取出一个产品; //互斥的访问缓冲区,将缓冲区“夹紧”
    V(mutex);

    V(empty);//增加一个空闲缓冲区
    使用产品;
    }
    }

思考:能否改变相邻P、V操作的顺序?
若缓冲区中没有产品,即full=0,empty=n,此时将P(mutex)放在P(full)和P(empty)之前,若是按照P(mutex)、P(full)、P(mutex)的顺序执行就会导致死锁。
因此,实现互斥的P操作一定要在实现同步的P操作之后。
V操作不会导致进程阻塞,因此两个V操作顺序可以交换。

多生产者-多消费者问题

问题描述

桌子上有一只盘子,每次只能向其中放入一个水果。爸爸专向盘子中放苹果,妈妈专向盘子中放橘子,儿子专等着吃盘子中的橘子,女儿专等着吃盘子中的苹果。只有盘子空时,爸爸或妈妈才可向盘子中放一个水果。仅当盘子中有自己需要的水果时,儿子或女儿可以从盘子中取出水果。
用PV操作实现上述过程

问题分析

互斥关系:对缓冲区(盘子)的访问要互斥地进行
同步关系(一前一后):

  1. 父亲将苹果放入盘子后,女儿才能取苹果
  2. 母亲将橘子放入盘子后,儿子才能取橘子
  3. 只有盘子为空时,父亲或母亲才能放入水果
    • (“盘子为空”这个事件可以由儿子或女儿触发,事件发生后才允许父亲或母亲放水果)

信号量设置:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
semaphore mutex = 1;//实现互斥访问盘子(缓冲区)
semaphore apple = 0;//盘子中有几个苹果
semaphore orange = 0;//盘子中有几个橘子
semaphore plate = 1;//盘子中还可以放多少个水果

dad (){
while(1){
准备一个苹果;
P(plate);

P(mutex);
把苹果放入盘子;
V(mutex);

V(apple);
}
}
mom (){
while(1){
准备一个橘子;
P(plate);

P(mutex);
把橘子放入盘子;
V(mutex);

V(orange);
}
}
daughter (){
while(1){
P(apple);

P(mutex);
从盘中取出苹果;
V(mutex);

V(plate);
吃掉苹果;
}
}
son (){
while(1){
P(orange);

P(mutex);
从盘中取出橘子;
V(mutex);

V(plate);
吃掉橘子;
}
}


要点分析

在分析同步问题(一前一后问题)的时候不能从单个进程行为的角度来分析,要把“一前一后”发生的事看做是两种“事件”的前后关系

比如,如果从单个进程行为的角度来考虑的话,我们会有以下结论:
如果盘子里装有苹果,那么一定要女儿取走苹果后父亲或母亲才能再放入水果
如果盘子里装有橘子,那么一定要儿子取走橘子后父亲或母亲才能再放入水果
这么看是否就意味着要设置四个同步信号量分别实现这四个“一前一后”的关系了?
正确的分析方法应该从“事件”的角度来考虑,我们可以把上述四对“进程行为的前后关系”抽象为一对“事件的前后关系”

盘子变空事件->放入水果事件。
“盘子变空事件”既可由儿子引发,也可由女儿引发;
“放水果事件”既可能是父亲执行,也可能是母亲执行。
这样的话,就可以用一个同步信号量解决问题了

吸烟者问题(可以生产多个产品的单生产者问题)

问题描述

假设一个系统有三个抽烟者进程和一个供应者进程。每个抽烟者不停地卷烟并抽掉它,但是要卷起并抽掉一支烟,抽烟者需要有三种材料:烟草、纸和胶水。三个抽烟者中,第一个拥有烟草、第二个拥有纸、第三个拥有胶水。供应者进程无限地提供三种材料,供应者每次将两种材料放桌子上,拥有剩下那种材料的抽烟者卷一根烟并抽掉它,并给供应者进程一个信号告诉完成了,供应者就会放另外两种材料再桌上,这个过程一直重复(让三个抽烟者轮流地抽烟)

问题分析
  1. 关系分析:供应者与三个吸烟者分别是同步关系,三个吸烟者对吸烟这个动作互斥(不需要专门再设置一个互斥信号量表示对桌子的使用(桌子可以抽象为容量为1的缓冲区),因为吸烟只能是轮流进行的)
  2. 整理思路:显然有四个进程
    操作系统-进程-16
  3. 信号量设置
    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
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    semaphore offer1 = 0;//桌上组合一的数量
    semaphore offer2 = 0;//桌上组合二的数量
    semaphore offer3 = 0;//桌上组合三的数量
    semaphore finish = 0;//抽烟是否完成
    int i = 0;//用于实现“三个抽烟者轮流抽烟”

    provider (){
    while(1){
    if(i==0) {
    将组合一放桌上;
    V(offer1);
    } else if(i==1){
    将组合二放桌上;
    V(offer2);
    } else if(i==2){
    将组合三放桌上;
    V(offer3);
    }
    i = (i+1)%3; //实现轮流
    P(finish);
    }
    }

    smoker1 (){
    while(1){
    P(offer1);
    从桌上拿走组合一;卷烟;抽掉;

    V(finish);
    }
    }
    smoker2 (){
    while(1){
    P(offer2);
    从桌上拿走组合二;卷烟;抽掉;

    V(finish);
    }
    }
    smoker3 (){
    while(1){
    P(offer3);
    从桌上拿走组合三;卷烟;抽掉;

    V(finish);
    }
    }
操作系统进程线程
123…6
avatar
Hiyoung
若无闲事挂心头,便是人间好时节
文章
51
标签
75
分类
31
Follow Me
公告
网站破后重生,变得更加花哨!
最新文章
飞牛NAS备忘录2025-10-20
安卓实用软件记录2025-10-15
github pages 使用自定义域名2025-05-21
hexo引用站内文章2025-05-21
MacOS上配置Hexo教程2025-05-21
分类
  • 二次元1
    • 《路人女主的养成方法》1
  • 前端11
    • CSS2
    • 网站建设9
  • 学习笔记8
    • C++2
    • Linux2
标签
nas飞牛OS路人女主的养成方法霞之丘诗羽完结读评加藤惠完结后有感丸户史明游戏制作,游戏叙事CppC++CcppSTLstring字符串HexohexoMacOSMarkdown
归档
  • 十月 20252
  • 五月 20253
  • 一月 20251
  • 十二月 20246
  • 十月 20241
  • 七月 20241
  • 三月 20242
  • 一月 20241
网站资讯
文章数目 :
51
已运行时间 :
本站总字数 :
96.2k
最后更新时间 :
©2020 - 2026 By Hiyoung
Love from nobody!
搜索
数据库加载中