码农日记 · 贰

Last updated on August 23, 2024 pm

C++与C语言的区别

C++是C语言的超集(但网上很多文章说这是不对的),这意味着几乎所有的C程序都可以在C++编译器中编译和运行。然而,C++引入了许多新的概念和特性,使得两种语言在一些关键点上有显著的区别。以下是C和C++的一些主要区别。

面向对象编程:C++支持面向对象编程(OOP),包括类、对象、继承、封装、多态等特性。这使得C++更适合大型软件项目,因为OOP可以提高代码的重用性和可读性。C语言是一种过程性语言,没有这些特性。

STL(Standard Template Library):C++提供了STL,这是一套强大的模板类和模板函数的库,包括列表、向量、队列、栈、关联数组等。这些可以大大提高开发效率。C语言没有内置的数据结构库。

异常处理:C++提供了异常处理机制,可以更优雅地处理错误情况。C语言处理错误通常依赖于函数返回值。

构造函数和析构函数:C++支持构造函数和析构函数,这些特殊的函数允许对象在创建和销毁时执行特定的代码。C语言没有这个概念。

运算符重载:C++允许运算符重载(本质上仍是函数的重载),这意味着开发者可以更改已有运算符的行为,或者为用户自定义类型添加新的运算符。C语言不支持运算符重载。

例如,如果我们要创建一个复数类并对其进行算术运算,C++的面向对象和运算符重载特性就非常有用。我们可以定义一个复数类,然后重载+、-、*运算符以执行复数的加法、减法和乘法。这样,我们就可以像处理内置类型一样处理复数对象。反观C语言,我们需要定义结构体来存储复数,并且需要写一堆函数来处理复数的加法、减法和乘法,很不方便。

C++和Java的核心区别

C++和Java都是广泛使用的编程语言,但它们在设计理念、功能和用途上有很大的不同。以下是C++和Java的几个核心区别:

运行环境:Java是一种解释型语言,它的代码在JVM(Java虚拟机)上运行,这使得Java程序可以在任何安装有JVM的平台上运行,实现了“一次编写,到处运行”的理念。而C++是一种编译型语言,其代码直接编译成目标机器的机器码运行,因此需要针对特定平台编译。

内存管理:Java有自动内存管理和垃圾回收机制,程序员不需要直接管理内存。而在C++中,程序员需要手动进行内存的分配和释放,这提供了更大的控制力,但同时也增加了内存泄漏的风险。

面向对象编程:Java是一种纯面向对象的编程语言,所有的代码都需要包含在类中。与此不同,C++支持面向对象编程,但它也允许过程式编程(兼容C)。

错误处理:Java使用异常处理机制进行错误处理,而C++既支持异常处理,也支持通过返回值进行错误处理(C的机制)。

多线程:Java内置了对多线程的支持,而C++在C++11标准之后才引入了对多线程的支持。

性能:因为C++的代码直接编译为机器码,所以它通常比Java程序运行得更快。但是,Java的跨平台能力和内置的垃圾回收机制使其在开发大型企业级应用时更具优势。

例如,如果你正在开发一个需要直接访问硬件,或者需要高性能数学计算的应用(比如游戏、图形渲染、科学计算),C++可能是一个更好的选择。而如果你正在开发一个大型的企业级web应用,Java的跨平台能力、内置的垃圾回收和强大的类库可能会更有优势。

编译型和解释型的区别

原文链接:一文了解编译型语言和解释型语言之区别

编译型语言的定义:编译型语言首先是将源代码编译生成机器指令,再由机器运行机器码(二进制)。

解释型语言的定义:解释型语言的源代码不是直接翻译成机器指令,而是先翻译成中间代码,再由解释器对中间代码进行解释运行。

打个比方:

编译型相当于用中英文词典(翻译器)将一本英文书一次性翻译(编译)成一本中文书,以后查看直接就是中文了。可想而知,以后读书(运行)会非常非常方便。C、C++都是编译型语言,在执行前一定要先编译一下,通过编译器去生成一个类似 .exe 的二进制文件。如果发现语法错误,就会发出编译不通过的提示。

解释型相当于用中英文词典(翻译器)将一本英文书读一段翻译一段(解释)变成中文。以后查看时还是需要重新翻译。这样效率会低一些,必须依赖解释器,但是跨平台性好,只要有解释器就可以运行代码。像Matlab、Python等都是一边执行一边解释的语言,解释是一句一句的翻译,从而不需要预先进行编译,所以称之为解释型语言。

扩展:编译型和解释型的定义是对立存在的,但也可以在一个语言中同时存在。比如 java 语言同时兼有编译型和解释型两种特点。整个流程如下:

将源代码(.java 文件)编译生成字节码(.class 文件),再通过 JVM(java 虚拟机)运行生成机器指令,由机器运行机器码。注意,此处生成机器语言前的操作是解释型,每次运行都要重新解释。因此,此处表明 java 是解释型语言。

但是,部分 JVM(java 虚拟机)有一种 JIT(Just in time)机制,能够将部分已经解释(“翻译”)的常用机器指令保存起来。下次不需要重新解释这部分了,直接运行即可。此时 java 是编译型语言。

关于移植性:

编译性语言例如C语言:用C语言开发了程序后,需要通过编译器把程序编译成机器语言(即计算机识别的二进制文件,因为不同的计算机操作系统可能不同,识别的二进制文件也是不同的),所以C语言程序进行移植后,要重新编译。

解释性语言例如Java语言:Java程序首先通过编译器编译成.class文件,如果在Windows平台上运行,则通过Windows平台上的Java虚拟机(JVM)进行解释。如果运行在Linux平台上,则通过Linux平台上的Java虚拟机进行解释执行。所以说能跨平台,前提是平台上必须要有相匹配的JVM。如果没有JVM,则不能进行跨平台。

C++变量的声明和定义

在C++中,变量的声明和定义是两个不同的概念。

声明是告诉编译器某个变量的存在,以及它的类型。声明并不分配存储空间。例如,外部变量的声明extern int a;,这里只是告诉编译器有一个类型为int的变量a存在,具体的a在哪里定义的,编译器此时并不知道。

定义是声明的延伸,除了声明变量的存在和类型以外,还分配了存储空间。例如,int a;就是一个定义,编译器在这里为a分配了足够的存储空间来存储一个整数。

在C++中,一个变量可以被声明多次,但只能被定义一次。例如,我们可以在多个文件中声明同一个变量,但只能在一个文件中定义它。如果在多个地方定义同一个变量,编译器会报错。

举个例子,假设我们正在编写一个大型程序,这个程序有一个全局变量需要在多个文件中使用。我们可以在一个文件中定义这个变量,然后在其他需要使用这个变量的文件中声明它。这样,所有的文件都可以访问到这个变量,但只有一个文件(定义它的那个文件)负责管理它的存储空间。

静态链接和动态链接

静态链接和动态链接是两种不同的程序链接方式,它们主要的区别在于链接的时间和方式。

静态链接:在静态链接中,所有代码(包括程序本身的代码和它依赖的库的代码)都会在编译时期被合并为一个可执行文件。这个可执行文件包含了程序运行所需的所有信息,因此它不依赖于任何外部的库文件。静态链接的优点是部署简单,因为不需要额外的依赖,只需要一个文件就可以运行。缺点是可执行文件通常会比动态链接的大,因为它包含了所有需要的代码,而且如果库更新,程序需要重新编译和链接。

动态链接:在动态链接中,程序的代码和它依赖的库的代码被分开。程序的可执行文件只包含了程序本身的代码和一些标记,这些标记表示程序在运行时需要链接到哪些库。当程序运行时,操作系统会负责加载这些库并进行链接。动态链接的优点是可执行文件更小,因为它不包含库的代码,而且多个程序可以共享同一份库,节省内存。此外,如果库更新,只需要替换库文件,程序无需重新编译和链接。缺点是部署稍微复杂一些,因为需要确保运行环境中有所需的库文件。

例如,假设我们有一个程序,它使用了一个数学库。如果我们静态链接这个库,那么所有的数学函数都会被包含在我们的可执行文件中,我们可以将这个文件复制到任何地方运行。如果我们动态链接这个库,那么我们的可执行文件就会小得多,但如果我们想在另一台机器上运行这个程序,我们就需要确保那台机器上也安装了这个数学库。

宏定义、函数和typedef

宏定义和函数

宏定义(#define)和函数是两种常见的在C++中编写代码的方式,但它们有一些重要的区别:

编译阶段:宏定义是在预处理阶段展开的,而函数是在编译阶段处理的。这意味着使用宏定义的代码在编译前就已经被预处理器替换掉了,而函数在编译阶段会生成对应的函数调用。

类型检查:函数在编译时会进行类型检查,而宏定义不会。这可能会导致宏定义在使用时出现错误,而在编译阶段并不会被发现。

效率:由于宏定义在预处理阶段就被替换,因此它没有函数调用的开销(如堆栈操作),所以在某些情况下可能更快。然而,过度使用宏定义可能会导致编译后的代码体积增大,因为每次使用宏都会插入一份宏的代码副本。

封装:函数提供了更好的封装,使得代码更易于阅读和维护。而宏定义由于其替换性质,可能会在复杂的表达式中产生不易察觉的错误。

宏定义和typedef

宏定义 #define 在编译期间将宏展开,并替换宏定义中的代码。预处理器只进行简单的文本替换,不涉及类型检查。宏定义可以用来替换数据类型、值或者代码。宏定义没有作用域限制,只要在宏定义之后的地方,就可以使用宏,通常用于定义常量、简单的表达式或简单的代码片段。宏定义不支持模板,因此不能用于定义模板类型别名。

typedef 是一种类型定义关键字,用于为现有类型创建新的名称(别名)。与宏定义不同,typedef 是在编译阶段处理的,有更严格的类型检查。typedef 遵循 C++ 的作用域规则,可以受到命名空间、类等结构的作用域限制,通常用于定义复杂类型的别名,使代码更易读和易于维护。typedef 可以与模板结合使用,但在 C++11 之后,推荐使用 using 关键字定义模板类型别名。

C++文件操作

文件和文件流对象

文件和文件流对象

文本文件和二进制文件的读写

文本文件和二进制文件的读写操作

文件定位、EOF和字符串流

文件定位、EOF和字符串流

二进制和文本的区别

用文本码形式输出的数据是与字符一一对应的,一个字节代表一个字符,可以直接在屏幕上显示或打印出来。这种方式使用方便,比较直观,便于阅读,便于对字符逐个进行输入输出。但一般占用存储空间较多,而且要花费时间转换。

用二进制形式输出数值,可以节省外存空间,而且不需要转换时间,但一个字节并不对应一个字符,不能直接显示文件中的内容。如果在程序运行过程中有些中间结果数据暂时保存在磁盘文件中,以后又需要输入到内存,这时用二进制文件保存是最合适的。如果是为了能显示和打印以供阅读,则应按文本形式输出。此时得到的是文本文件,它的内容可以直接在显示屏上观看。

综合应用

学校要输入5位学生的信息,将他们的信息存到磁盘文件中,并将第1、3、5位学生数据读入程序显示。第3位学生录入信息有误,需要对其进行修改后存入磁盘文件的原来位置。最后,从磁盘文件读取5位学生的信息并显示。

#include <iostream>
#include <fstream>
#include <string.h>
using namespace std;
struct student {  // 结构体
    int num;
    char name[10];
    float score;
};

int main() {
    // 定义结构体数组存入学生的信息
    student stu[5] = {
        1001, "张三", 97,
        1002, "李四", 85.5,
        1008, "王六", 54.5,
        1004, "三体人", 73,
        1005, "秦始皇", 99.3
    };
    // 用 fstream 类定义输入输出二进制文件流对象 iofile
    fstream iofile("D:/Others/Afly/Temporary/file.txt", ios::in|ios::out|ios::binary);
    if(!iofile) {
        // 标准错误流 cerr 不经过缓冲区直接向显示器输出错误信息
        cerr << "open error!" << endl;
        abort();  // 不进行任何清理工作,直接终止程序
    }

    // 向磁盘文件写入5个学生的信息
    for(int i=0; i<5; i++) {
        // 将 &stu[i] 作为开头指向的 sizeof(stu[i]) 字节的信息写入 iofile 流对象对应的磁盘文件中
        iofile.write((char*)&stu[i], sizeof(stu[i]));
    }
    student stud[5];  // 存放从磁盘文件中读入的数据
    for(int i=0; i<5; i+=2) {
        // 利用 seekg 函数将文件指针从开头向后移动 i*sizeof(stud[i]) 个字节,定位到索引i的位置
        iofile.seekg(i*sizeof(stud[i]), ios::beg);
        // 将 iofile 对应文件中大小为 sizeof(stud[0]) 的(3个)数据读入 &stud 指向的内存中
        iofile.read((char*)&stud[i/2], sizeof(stud[0]));
        // 输出第1、3、5位学生的信息
        cout << "第" << i+1 << "个学生: " << stud[i].num << " " << stud[i].name << " "
        << stud[i].score << endl;
    }
    cout << endl;

    // 修改第3位学生的信息
    stud[2].num = 1003;
    stud[2].name = "王者荣耀";  // 会报错,字符数组不允许这样赋值
    strcpy(stud[2].name, "王者荣耀");  // 正确
    stud[2].score = 60;

    // 定位第3位学生的信息存储位置
    iofile.seekp(2*sizeof(stud[2]), ios::beg);
    // 把第3位学生修改后的信息存入磁盘文件中(更新数据)
    iofile.write((char*)&stud[2], sizeof(stud[2]));
    iofile.seekg(0, ios::beg);  // 重新定位文件开头
    
    for(int i=0; i<5; i++) {
        // 读取5个学生的信息
        iofile.read((char*)&stu[i], sizeof(stu[i]));
        // 打印输出
        cout << "第" << i+1 << "个学生: " << stud[i].num << " " << stud[i].name << " "
        << stud[i].score << endl;
    }
    iofile.close();  // 关闭文件
    return 0;
}

注意:编码方式不同可能会导致乱码问题。

C++泛型编程: 模板

泛型(generic type)就是不使用具体数据类型(如int、double、float等),而是使用一种通用类型来进行程序设计,泛泛的描述一下数据,相当于一种占位符,这个方法可以大规模的减少程序代码的编写量。使用泛型进行编程就叫泛型编程。总之,泛型也是一种数据类型,是用来代替所有类型的“通用类型”。在C++中,泛型的实现就是模板(Template)。

模板的基础

模板是C++实现参数化多态的工具,是一个比较新的重要特性。模板有两类:函数模板、类模板。使用模板可以为函数或类声明一种“一般模式”,使得类中的某些数据成员或成员函数的参数、返回值可取任意类型。具体的类型在调用函数或者定义对象时确定。模板可以实现类型的参数化,即把类型定义为参数,从而实现代码的可重用性。如果类的数据成员类型不能确定时,就必须将此类声明为模板,代表这一类的类。

模板就是把功能相似、仅数据类型不同的函数或类设计为通用的函数模板或类模板(蓝图),提供给用户。像函数重载,需要写很多个不同参数类型或个数的函数,而定义模板,只需要写一个,大大提高代码重用率。而且函数模板也可以重载。

传入不同的模板参数,函数模板会生成不同的模板函数,类模板则生成不同的模板类。

模板定义以关键字 template 开始,后接模板参数列表,用 "<>" 括起来。template 标志着模板声明的开始,告诉C++编译器要声明模板了,不能随便报错。

模板形参类型的关键字有两个:typename、class。这两个类型关键字的作用完全一样,typename 的引入主要是想区分函数模板和类模板,一般约定:typename 用于函数模板的创建,class 用于类模板的创建。实际上,二者完全可以相互替换。

函数模板

函数模板就是建立一个通用函数,其函数类型和形参类型不具体指定,用一个虚拟类型(通用类型参数)来代表。

函数模板是一种模板,是模板函数的抽象,定义中要用到通用类型参数;模板函数是实实在在的函数,是函数模板的实例,具有程序代码,占用内存空间,是实际存在的。在调用函数模板时,编译系统会根据实参的类型取代模板中的虚拟类型,从而生产相应的具体模板函数,然后实现功能。

函数模板不允许自动类型转换,而普通函数可以,比如在普通函数中,若存在较小的数据类型和较大的数据类型同时参与运算时,小的会自动(隐式的)转为大的,例如 bool、char 可以转为 int。C++编译器优先考虑普通函数,如果普通函数不匹配,或者说函数模板更匹配实参数据类型,那么才选择模板。函数模板可以隐式实例化,也可以显式实例化。如果要限定编译器使用模板而不使用普通函数,那么需要显式实例化。

使用示例:

#include <iostream>
using namespace std;
template <typename T>  // 定义模板
void fswap(T& x, T& y) {
    T temp;
    temp = x; x = y; y = temp;
}
template <typename T>  // 参数名 T 在不同模板间可以重复使用
T fadd(T a, T b) {
    return a+b;
}
template <typename T1, typename T2>
double fadd(T1 a, T2 b) {  // 模板的重载
    return a+b;
}
int main() {
    int x = 5, y = 6; char z = 'a';
    float m = 3.5, n = 6.7;

    fadd(x, y);  // 11
    fadd(m, n);  // 10.2

    // 已经重载了混合类型数据计算的加法函数
    fadd(x, m);  // 若没有 fadd(T1 a, T2 b) 函数会报错: 不允许为同一个模板类型形参指定两种不同的类型
    fadd(z, y);  // 若没有 fadd(T1 a, T2 b) 函数会报错: 因为函数模板不能实现自动类型转换
    fswap(&x, &y);  // 错误: 实参赋给形参时, 左右值类型不匹配, 赋值失败

    fswap(x, y);  // 隐式调用, 自动数据类型推导
    fswap<int> (x, y);  // 显式类型调用 (实例化)
    fswap<> (x, y);  // 显式调用模板
    return 0;
}

/* 还可以这样定义函数模板 */
template <typename T>
T fadd(T a, int b) {  // 形参类型可由用户自定义
    return a+b;
}

类模板的使用

类模板的定义和使用与函数模板类似。

类模板的参数可以是类型参数(由关键字typename或class修饰的参数,如 T),也可以是非类型参数(某些内置类型,如int a)。注意:非类型形参在模板定义的内部是常量,且只能是整型、指针和引用。比如 int、double&、float* 是可以的,但 double、string 等不行。调用非类型模板形参的实参必须是常量表达式(能在编译时计算出结果),全局对象的地址、const 类型变量、sizeof() 结果都是常量表达式,可以作为实参。

若全局域有变量名与类模板中的模板参数名相同,那么该变量会被隐藏。模板参数名不能是类中成员的名字,同一个模板参数名在模板参数表中只能出现一次。不同的类模板声明中,模板参数名可以重复使用。

类模板形参列表中 class T 是形参,实参值被提供给 T 后,系统将会根据实参的具体类型创建一个模板类,该类是真实存在的,这个过程叫类模板的实例化。函数模板的实例化是编译程序在处理函数调用时自动完成的,而类模板的实例化须由程序员在程序中显式的指定。

#include <iostream>
using namespace std;
template <class T>  // 定义模板
class Compare {
public:
    Compare(T a, T b) {  // 模板内定义数据成员
        c_x = a; c_y = b;
    }
    T max() {
        return (c_x > c_y) ? c_x : c_y;
    }
    T min();
private:
    T c_x, c_y;
};
template <class T>  // 在类模板外定义成员函数
T Compare<T>::min() {
    return (c_x < c_y) ? c_x : c_y;
}
template <class T, int size = 1>  // 缺省参数 (指定缺省值/默认值, 是常量)
class Myclass1 {
public:  // 无构造函数, 仅作为例子
    T func(T a) {
        return a + size;
    }
};
template <class T, int size>
class Myclass2 {  // size 在该类中是个常量
public:
    T func(T a) {
        return a + size;
    }
};
int main() {
    Compare<int> cmp(5, 7);  // 必须显式指定形参类型进行类模板的实例化
    cmp.max();  // 返回: 7
    cmp.min();  // 返回: 5
    Myclass1<int> mc1;  // size 取默认值: 1
    cout << mc1.func(5) << endl;  // 输出: 6 (5 + 1)
    int x = 2; const int y = 2;
    Myclass2<int, x> mc2;  // 错误: x 不是常量
    Myclass2<int, y> mc2;  // 正确, y 是常量
    Myclass2<int, 2> mc2;  // 正确, 2 是常量 (字面量)
    cout << mc2.func(8) << endl;  // 输出: 10 (8 + 2)
    return 0; 
}

模板的特化

模板参数在某种特定类型下的具体实现叫模板的特化,有时也叫模板的具体化(我个人认为这个名字不好,模板实例化又何尝不是一种具体化呢?)。

模板特化分别包括:函数模板的特化和类模板的特化。

函数模板的特化,发生在函数模板需要对某些类型进行特别处理的时候。看下面这个例子:

#include <iostream>
#include <cstring>
using namespace std;
template <typename T>
/* 常见类型如 int char double 等数据的比较 */
bool comp(T a, T b) {
    return (a == b);
}
/* 对 char* 类型字符串进行比较, 就需要对函数模板进行特化 */
template <>  // 特化标志
bool comp(char* t1, char* t2) {  // 写法 1
bool comp<char*>(char* t1, char* t2) {  // 写法 2
    return (strcmp(t1, t2) == 0);  // 等于 0 表示两字符串相同
}
int main() {
    /* 字符串比较测试 */
    string s1 = "123", s2 = "123";
    cout << (s1 == s2) << endl;  // 1
    char str1[] = "hello", str2[] = "hello";
    // str1、str2 代表数组地址, 下面写法表示将两个地址进行比较, 而不是对数组中字符串比较
    cout << (str1 == str2) << endl;  // 0

    // 利用特化的函数模板进行实例化, 以实现特殊的要求
    cout << comp(str1, str2) << endl;  // 1
    return 0;
}

同理,类模板中需要对某些类型进行特别处理时,可以使用类模板的特化。需要注意的是,进行类模板的特化时,需要特化所有的(涉及通用类型参数的)成员变量和成员函数。

#include <iostream>
#include <cstring>
using namespace std;
template <class T>
class Comp {
public:
    bool fun(T a, T b) {
        return (a == b);
    }
};

/* 下面这种定义, 是比较规范的, 不会漏掉某些数据成员 */
template <>  // 特化标志
class Comp<char*> {
public:
    bool fun(char *t1, char *t2) {
        return (strcmp(t1, t2) == 0);
    } 
};
/* 下面这种定义是简化写法, 涉及通用类型参数(T)的数据成员已被全部特化, 也是可行的 */
template <>  // 特化标志
bool Comp<char*>::fun(char* t1, char* t2) {
    return (strcmp(t1, t2) == 0);
}

int main() {
    char str1[] = "hello";
    char str2[] = "hello";
    Comp<int> c1; Comp<char*> c2;
    c1.fun(1, 2);  // 返回: 0
    c2.fun(str1, str2);  // 返回: 1
    return 0;
}

如果只想对类模板中的某些通用类型参数进行特化,并非对其全部的通用类型参数进行特化,那么可以使用类模板的偏特化,也叫部分特化。例如:

template <class T1, class T2>
class A { ... };

/* 仅对 T2 进行特化 */
template <class T1>  // 这里必须要列出未特化的模板参数: T1
class A<T1, int>  // A 的后面要列出全部模板参数, 并指定特化的类型: 指定 int 为 T2 的特化类型
{ ... };

Java: 接口vs抽象类

抽象类(abstract class)

类,由 class 定义。类是对事物的抽象,表示这个对象是什么。

由于多态的存在,每个子类都可以覆写父类的方法,例如:

class Person {
    public void run() { … }
}

class Student extends Person {
    @Override
    public void run() { … }
}

class Teacher extends Person {
    @Override
    public void run() { … }
}

从 Person 类派生的 Student 和 Teacher 都可以重写 run() 方法。如果父类 Person 的 run() 方法没有实际意义,能否去掉该方法的方法体?

答案是:可以。如果父类的方法本身不需要实现任何功能,仅仅是为了定义方法签名,目的是让子类去覆写(重写)它,那么,可以把父类的方法声明为抽象方法。

如果一个 class 定义了方法,但没有具体执行代码,这个方法就是抽象方法,抽象方法用 abstract 修饰。因为无法执行抽象方法,因此这个类也必须申明为抽象类(abstract class)。使用 abstract 修饰的类就是抽象类。

抽象类是类的抽象。我们无法实例化一个抽象类(因为它太过于抽象了,没有“实例”,只有“概念”)。因此,抽象类只能被继承。抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错(抽象类中的抽象方法没有方法体,子类如果还不去重写实现这个方法,那抽象类不就成了摆设么)。因此,抽象方法实际上相当于定义了某种“规范”。

例如,Person 类定义了抽象方法 run(),那么,在实现子类 Student 的时候,就必须覆写 run() 方法:

public class Main {
    public static void main(String[] args) {
        Person p = new Student();
        p.run();
    }
}

// 抽象类
abstract class Person {
    public abstract void run();
}

class Student extends Person {
    @Override
    public void run() {
        System.out.println("Student.run");
    }
}

当我们定义了抽象类 Person,以及具体的 Student、Teacher 子类的时候,我们可以通过抽象类 Person 类型去引用具体的子类的实例,即父类型引用指向子类型对象(这不就是多态么),其好处不言自明。

Person s = new Student();
Person t = new Teacher();

// 我们对其进行方法调用,并不关心Person类型变量的具体子类型
s.run();
t.run();

// 若引用的是一个新的子类,我们仍然不关心新的子类是如何实现run()方法的
Person e = new Employee();
e.run();

这种尽量引用高层类型,避免引用实际子类型的方式,称之为面向抽象编程

面向抽象编程的本质就是:上层代码只定义规范(例如:abstract class Person),不需要子类就可以实现业务逻辑(正常编译),具体的业务逻辑由不同的子类实现,调用者并不关心子类是如何实现的。

有抽象方法的类叫抽象类,抽象类中也可以存在非抽象方法(有具体的实现)。

接口(Interface)

接口,由 interface 定义。接口是对动作/行为的抽象,表示这个对象能做什么。

接口是抽象类的变体,是比抽象类还要抽象之物。接口中所有的方法都是抽象方法。因为接口定义的所有方法默认都是 public abstract 的,所以这两个修饰符不需要写出来,故而也称作隐式抽象。

接口当然也无法实例化,接口中定义的方法需要由类去实现,当一个具体的类去实现一个接口时,需要使用 implements 关键字。一个类可以实现多个接口。

class Student implements Person {
    private String name;

    public Student(String name) {
        this.name = name;
    }

    @Override
    public void run() {
        System.out.println(this.name + " run");
    }

    @Override
    public String getName() {
        return this.name;
    }
}

// 接口
interface Person {
    void run();
    String getName();
}

一个 interface 可以继承自另一个 interface。interface 继承自 interface 使用 extends,它相当于扩展了接口的方法。

interface Hello {
    void hello();
}

interface Person extends Hello {
    void run();
    String getName();
}

此时,Person 接口继承自 Hello 接口,因此,Person 接口现在实际上有3个抽象方法签名,其中一个来自继承的 Hello 接口。

在接口中,可以定义 default 方法,实现类可以不必重写 default 方法。

public class Main {
    public static void main(String[] args) {
        Person p = new Student("Xiao Ming");
        p.run();
    }
}

interface Person {
    String getName();
    default void run() {
        System.out.println(getName() + " run");
    }
}

class Student implements Person {
    private String name;

    public Student(String name) {
        this.name = name;
    }

    public String getName() {
        return this.name;
    }
}

当我们需要给接口新增一个方法时,会涉及到修改全部子类,因为所有子类都要重写接口中的方法。如果新增的是 default 方法,那么子类就不必全部修改,只需要在需要重写的地方去重写新增方法即可。这就是使用 default 方法的目的所在。

需要注意的是,接口的 default 方法和抽象类的普通方法(非抽象方法)是有所不同的。接口没有实例字段,故 default 方法无法访问实例字段,而抽象类的普通方法可以访问实例字段。

接口中可以定义的字段默认且只能是 public static final 类型,即静态常量。

扩展:从 Java 8 开始,接口中可以包含默认方法和静态方法,这些方法具有方法体。从 Java 9 开始,接口允许包含私有方法。

接口和抽象类的区别

接口可以多继承,而且一个类可以实现多个接口。但类(抽象类当然也是类)不能多继承,类只能单继承,不过可以取巧:多重继承。

接口用来表示行为、动作,类多用来表示属性。比如,男人、女人是两个类,抽象类可以描述为:人。男人、女人(人)总要吃东西的,狗、鸡也要吃东西,“吃东西”这个行为可以定义成接口,由类去实现。

一个子类继承了某个父类,那么就具备了其父类的属性(继承过来的),也包括一些方法。该子类也可以拥有新的方法(行为能力),此时可以通过接口进行新功能的实现。

抽象类的功能比接口多一些,但是抽象类定义的代价也较高,每个类只能继承一个抽象类。因此在这个父类中,必须包含其所有子类的大部分共性。对于接口而言,接口只针对某一个动作的描述,且可以在一个类中实现多个接口,因此扩展性极强。

字段

何为字段

在 Java 类中,字段(Fields)是用于存储对象状态的变量。它们描述了对象的特征或属性。

字段可以是基本数据类型(如整数、字符、布尔值等)或其他类的对象(引用数据类型)。通过定义字段,类可以持有和操作对象的数据。

Java中的字段可以分为两类:

  • 实例字段(Instance Fields),每个对象都有自己独有的实例字段副本,用于存储对象特定的状态和属性。
  • 静态字段(Static Fields),静态字段属于类本身,被该类的所有实例共享,用于存储类级别的共享数据。

字段通过访问修饰符控制访问权限,用于数据存储、对象间通信和数据封装。字段是一种变量,用于存储数据。它们可以在类的任何方法中使用,并在对象的整个生命周期中保持存在。

字段用于描述和记录对象的状态,通过修改字段的值,我们可以改变对象的状态,从而影响对象的行为和属性(数据成员)。

static and final

static 修饰符用于将类的成员变量或方法与类本身相关联,而不是与类的实例相关联,所有该类的对象共享同一个静态变量。

final 修饰符用于将类、方法或变量的值锁定,防止它们被更改。

  • 用 final 修饰的变量一旦赋值后,不能再更改。这种变量通常被称为常量。如果是对象类型的 final 变量,引用本身不能改变,但对象的内部状态可以改变。
  • 用 final 修饰的方法不能被子类重写(override)。这在需要确保方法逻辑不被修改时非常有用。
  • 用 final 修饰的类不能被继承。这可用于防止类的设计被改变。

Python: 模块和包

模块(Module)是最基本的代码组织单元,它是一个包含 Python 代码的文件,通常以 .py 扩展名结尾。模块可以包含函数、类、变量以及其他模块可以使用的任何东西,即一个 Python 文件中包含的东西。使用 import 导入模块,如:import math

包(Package)是模块的容器,用于组织相关的模块。包通常是一个目录,其中包含一个特殊的 init.py 文件,这个文件可以为空,也可以包含初始化代码。包内可以放入一些模块(.py文件)。包也可以包含子包。若要导入包 settings 中的 size 模块,可用:import settings.size

from xxx import zzz  # zzz与xxx位于同一层级目录
from .xxx import zzz  # 同上
from ..xxx import zzz  # xxx在zzz的上级目录中
from ...xxx import zzz  # xxx在zzz的上上级目录中

第三方库(Library)是由外部开发者创建的一组相关的模块或包,用于扩展 Python 的功能,如:NumPy、Pandas 等。第三方库通常需要单独安装,可以使用 pip(Python 的包管理器)来安装,也可以用 conda(Anaconda项目开发的开源包管理系统和环境管理器)安装。安装后,可以通过导入相应的模块或包来使用库的功能。

OS篇:进程的并发与同步

操作系统(Operating System,OS)是整个计算机系统的管理和控制中心。

操作系统(OS)的含义

从计算机系统设计者的角度看,OS 是由一系列程序模块组成的一个大的系统管理程序,它依据设计者设计的各种管理和调度策略,合理地组织计算机的工作流程,从而提高计算机资源(处理器CPU、缓存Cache、内存Memory、数据通路等)的利用效率。由此可认为,OS 是计算机软硬件资源的管理和控制程序。

调度策略包括先来先服务调度算法(First Come First Service, FCFS)、最短作业优先调度算法(Shortest Job First, SJF)、高响应比优先调度算法(Highest Response Ratio Next, HRN)、优先级调度算法(Priority Scheduling / Highest Priority First, HPF)、时间片轮转调度法(Round Robin, RR)等。

从用户角度看,配上 OS 的计算机是一台比裸机功能更强、使用更方便简单的智能机。也即,OS 是用户与计算机系统之间的一个接口,用户通过它来使用计算机。它向用户及其程序提供了一个良好的使用计算机的环境,通过用户态和核心态(内核态)两种模式的切换保护自身免受应用程序的损害,同时使得计算机系统变得容易维护、安全可靠、容错能力强、更加有效。

所谓裸机,是只包括冯诺依曼模型中的基本结构:中央处理机(即CPU,包括运算器和控制器)、存储器、输入设备和输出设备,不增加任何扩充的计算机。

从上面两个角度可以总结出,设计 OS 的目的有两个:一个是使用户方便、简单地使用计算机系统,另一个是使计算机系统能高效可靠地运转。

进程和线程

进程是为了描述操作系统中各种并发活动而引入的。进程(Process)是操作系统最基本、最重要的概念之一,是 OS 进行系统资源分配的基本单位。进程是程序在某个数据集合上的一次运行活动,也是操作系统进行资源分配和保护的基本单位。

通俗来说,进程就是程序的一次执行过程。程序是静态的概念,可以作为一种软件资源长期保存。而进程是动态的,它把程序作为自己的运行实体,没有程序也就没有进程。进程是动态的产生、变化和消亡的,拥有自己的生命周期。进程控制块(Process Control Block, PCB)或进程描述符(Process Descriptor, PD)是进程存在的唯一标识,它包含系统管理进程所需的全部信息。

进程的生命周期:

现代 OS 为了提高系统的并发程度,引入了线程的概念。线程(Thread)是处理机调度的基本对象。线程又称为迷你进程,或者说轻型进程。由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O设备等,需要较大的时空开销,限制了并发程度的进一步提高。为减少进程切换的开销,把进程作为资源分配单位和调度单位这两个属性分开处理(这是进程刚被提出时的定位),即进程还是作为资源分配的基本单位,但是不作为调度的基本单位(很少调度或切换),把调度执行与切换的责任交给线程,即线程成为独立调度的基本单位。线程比进程更容易(更快)创建,也更容易撤销。

举个例子:进程是拥有资源的基本单位,而且还能够进行独立调度,这就犹如一个背着粮草的士兵,这必然会造成士兵执行命令(战斗)的速度。所以一个简单想法就是:分配两个士兵执行同一个命令(携带粮草战斗):一个负责携带所需粮草随时供给,另一个负责执行战斗。这就是线程的思想。一个线程也由一个线程控制块描述,它包含系统管理线程所需的全部信息。

区别:进程在逻辑上表示 OS 必须做的一个任务,线程表示完成这个任务的许多可能的子任务。线程是进程中的一个可执行实体,一个进程至少有一个线程,也可以有多个线程。每个进程拥有一个独立的存储空间,用来装入由若干代码段和数据段组成的实体,此外它还包含一些打开的文件。进程之间一般互不影响。一个进程的多个线程共享该进程拥有的所有资源,因此线程的上下文切换要比进程快得多。但由于资源共享,安全性就会降低,多进程比多线程可能更加安全。

进程间的并发控制:信号量机制

进程间的互斥是由资源共享导致的,比如两个异步进程都要执行文件打印,但打印机只有一台。

引入两个概念:临界资源(Critical Resource),是一次仅允许一个进程使用的系统中的一些共享资源,包括慢速的硬设备(如打印机等),也包括软件资源(如共享变量、共享文件、各种队列等)。临界区(Critical Section),是并发进程访问临界资源的那段必须互斥执行的程序段。

要保证程序在临界区的互斥执行,就不能让两个(或多个)进程同时在他们的临界区内执行,并且临界区之外的进程不可以阻止其他进程进入临界区。信号量(Semaphore)机制是荷兰学者 Dijkstra 于1965年提出的一种可以正确管理各并发进程对计算机系统中的共享资源的互斥使用和协作同步关系的同步机制。

信号量(用 s 表示)表示系统共享资源的物理实体,操作系统利用信号量的状态对进程和资源进行管理。信号量 s 用一个数据结构表示:

typedef struct semaphore {
    int value;
    struct process *list;
}s;

其中,value 表示该类资源的可用数量,list 是等待使用该类资源的进程排队队列的头指针。对信号量 s 的操作只允许执行 P、V 原语操作(原子操作,表示不可分割(不可中断)的操作,即要么全做,要么全不做)。P、V 源自荷兰语,翻译过来分别是 test、increment,分别表示测试和增加之意。

P 操作:P(s),表示将信号量 s.value 的值减1。若减1后 s.value 的值为0,说明临界区内无进程,则执行 P 操作的进程继续执行。若减1后为负,则执行 P 操作的进程变为阻塞状态,并进入与该信号量有关的 list 所指队列中等待,之后转处理机调度。P 操作相当于申请资源。

V 操作:V(s),表示将信号量 s.value 的值加1。若加1后 s.value 的值大于0,说明执行 V 操作的进程在临界区内正常完成了程序的执行过程,本进程继续前进。若加1后仍小于或等于0,则执行 V 操作的进程从与该信号量有关的 list 所指队列中释放一个进程,使其由阻塞态变为就绪态,之后本进程继续执行或者转处理机调度。V 操作相当于释放资源。

注:有些资料用了 wait(s) 和 signal(s) 分别取代 P(s) 和 V(s) 操作。

在实际操作中,引入互斥信号量 mutex 并赋初值:1。任何欲进入临界区执行的进程,必须先对 mutex 执行 P 操作,即将 mutex 的值减1。若减1后 mutex 为0,表示临界资源空闲,执行 P 操作的进程可以进入临界区执行。若减1后 mutex 为负值,说明已有进程正在临界区执行,那么本进程(执行 P 操作的进程)必须等待,直到临界区空闲为止。这样,利用信号量就可以方便解决临界区的互斥执行,从而让进程更好的同步协作。

信号量案例展示

本节由计算机中的两个经典问题为引,用伪代码的形式对进程之间并发、同步产生的互斥问题进行解读。

案例1:生产者和消费者问题(The Producer-Consumer Problem)

计算机中进程之间的同步问题可以一般化为生产者和消费者问题。运行中的进程释放一个资源时,可将其看作生产者。而当其申请使用一个资源时,可看作消费者。例如对数据计算后打印的任务,计算进程可看作被打印数据的生产者,又可看作空缓冲区的消费者;而打印进程可看作被打印数据的消费者,也可看作空缓冲区的生产者。

用信号量解决这个问题,需要定义以下信号量:

  • 互斥信号量 mutex,控制生产者和消费者互斥使用缓冲区(避免打印混乱)
  • 同步信号量 empty,指示空缓冲区的可用数量,制约生产者进程输送数据
  • 同步信号量 full,指示装满数据的缓冲区个数,制约消费者进程取用数据
  • 送、取数据(产品)的指针变量 i、j
  • 临时变量 x、y,表示送、取的数据(产品)

共享环形缓冲区问题描述:

int mutex, empty, full;
mutex=1; empty=k; full=0;  // 赋初值,缓冲区大小为k
int array[k];  // 定义缓冲区
int i=0, j=0;  // 定义缓冲区送取产品的指针
int x, y;
parbegin
    producer: begin
        produce a product to x;
        P(empty);  // 申请一个空缓冲 (empty -= 1)
        P(mutex);  // 申请进入缓冲区
        array[i] = x;  // add the product to buffer
        i = (i+1) mod k;  // 以环形方式更新送产品的指针
        V(full);  // 释放一个产品 (full += 1)
        V(mutex);  // 退出缓冲区
        ...
    end
    consumer: begin
        P(full);  // 申请一个产品,产品数-1
        P(mutex);  // 申请进入缓冲区
        y = array[j];  // take a product from buffer
        j = (j+1) mod k;  // 更新取产品的指针
        V(empty);  // 释放一个空缓冲
        V(mutex);  // 退出缓冲区
        ...
    end
parend

无论是生产者进程还是消费者进程,P 操作的顺序相当重要。如果把生产者进程中的两个 P 操作的次序交换,那么当缓冲区满时,生产者欲向缓冲区放产品时,将在 P(empty) 上等待,但它已获得了使用缓冲区的权力。若此后,消费者欲取产品,申请使用缓冲区会失败,便会在 P(mutex) 上等待。这会导致生产者等待消费者取走产品,但消费者却在等待生产者释放缓冲区的相互等待状况,造成系统发生死锁现象。

案例2:读者与写者问题(The Readers-Writers Problem)

一个文件可能要被多个进程共享。为了保证读写的正确性和文件的一致性,要求当有读者进程读文件时,允许多读者同时读,但不允许任何写者进程写;当有写者进程写时,既不允许其他任何写者进程写,也不允许任何读者进程读。

设置信号量:

  • 互斥信号量 rw_ww_mutex,用于实现读写进程互斥和写写进程互斥地访问共享文件
  • 计数器变量 readcount,记录同时进行读的读者进程数
  • 互斥信号量 rr_mutex,用于控制读者进程互斥地修改计数器 readcount

实现读者优先的问题描述:

int rw_ww_mutex=1, rr_mutex=1;
int readcount=0;
parbegin
    reader: begin  // 读者进程
        /* 开启阅读 */
        P(rr_mutex);  // 申请对读者计数+1
        if(readcount==0) {
            // 第一个读者申请文件的阅读使用权,此后若有其他的写文件进程则必须等待
            P(rw_ww_mutex);
        }
        readcount += 1;
        V(rr_mutex);  // 第一个读者已经计数完成,就要释放对 readcount 的使用权
        ...
        /* 完成阅读,准备退出 */
        P(rr_mutex);  // 读完退出时,申请对读者计数-1
        readcount -= 1;
        if(readcount==0) {
            // 若退出的是最后一位读者,应开放该文件的使用权(写者方能使用)
            V(rw_ww_mutex);
        }
        V(rr_mutex);
        ...
    end
    writer: begin  // 写者进程
        ...
        P(rw_ww_mutex);  // 申请写文件
        <-- 向文件写数据 -->
        V(rw_ww_mutex);  // 释放文件的使用权
        ...
    end
parend

并发进程新的同步机制:管程

使用信号量往往涉及大量的 P、V 操作,这会给用户编程造成较大负担,很容易出错,且程序的易读性差、查错纠错困难,不利于修改和维护。

管程(Monitor)提供了一种高级的同步原语,它将共享资源和对资源的操作封装在一个单元中,并提供了对这个单元的访问控制机制。使用管程进行编程更加简单,使用方便,且容易控制。

Hansan 和 Hoare 在1973年提出了管程,基本思想是将共享变量以及对共享变量进行的所有操作过程集中在一个模块中。Hansan 对管程的定义是:管程是关于共享资源的数据结构及一组针对该资源的操作过程所构成的软件模块。管程与C++中的类相似,它隐含了代表资源的内部表示,向外提供的只是为各方法规定的操作特性。管程保证任何时候最多只有一个进程执行管程中的代码。并发进程在请求和释放共享资源时调用管程,从而提供了互斥机制,保证管程数据的一致性。

一个管程包括以下四个主要部分:

  • 共享变量:管程中包含了共享的变量或数据结构,多个线程或进程需要通过管程来访问和修改这些共享资源。
  • 互斥锁(Mutex):互斥锁是管程中的一个关键组成部分,用于确保在同一时间只有一个线程或进程可以进入管程。一旦一个线程或进程进入管程,其他线程或进程必须等待,直到当前线程或进程退出管程。
  • 条件变量(Condition Variables):条件变量用于实现线程或进程之间的等待和通知机制。当一个线程或进程需要等待某个条件满足时(比如某个共享资源的状态),它可以通过条件变量进入等待状态。当其他线程或进程满足了这个条件时,它们可以通过条件变量发送信号来唤醒等待的线程或进程。
  • 管程接口(对管程进行操作的函数):这是一组操作共享资源的接口或方法。这些接口定义了对共享资源的操作,并且在内部实现中包含了互斥锁和条件变量的管理逻辑。其他线程或进程通过调用这些接口来访问共享资源,从而确保了对共享资源的有序访问。

管程的数据只能由该管程的过程存取,不允许进程和其他管程直接存取。当一个进程进入管程执行它的一个过程时,如果因某种原因而被阻塞,应立即退出该管程,否则就会阻塞其他进程进入管程,从而导致系统死锁。管程内的条件变量 c 和两个操作条件变量的同步原语 wait(c) 和 signal(c) 提供了同步机制,可以防止系统死锁,保证各进程间的有序协作。

wait(c):执行 wait(c) 的进程将自己阻塞在条件变量 c 的相应等待队列中。在阻塞前,检查有无等待进入该管程的进程。若无,阻塞自己并释放管程的互斥使用权;若有,则唤醒第一个等待者进程,以便其进入该管程。

signal(c):执行 signal(c) 的进程检查条件变量 c 的相应等待队列。如果队列为空,则该进程继续;如果不为空,唤醒 c 队列中第一个等待者进程,以便其重新进入该管程。

需要注意的是,不管是信号量机制,还是管程机制,都属于进程间的低级通信。除了低级通信,还有进程的高级通信,即进程采用操作系统提供的多种通信方式,如消息缓冲、信箱、管道和共享主存区等实现的通信。当进程通过消息缓冲进行数据交换时,以消息为单位把通信内容直接或间接地发送给对方,这是进程利用系统提供的一组命令实现的。操作系统隐藏了通信的实现细节,大大简化了通信编程的复杂性,因而得到广泛应用。

管程案例展示

下面是用管程描述生产者和消费者共享环形缓冲区(循环队列)问题的一个示例。

Monitor prod_conshow {  // Producer-Consumer Demonstration
    char buffer[N];  // 具有N个元素的环形缓冲区
    int k;  // 缓冲区中产品的个数
    int next_empty, next_full;  // 送、取产品的指针
    condition non_empty, non_full;  // 条件变量
    define put, get;  // 本管程内定义的过程(或函数)说明(即:名字表)
    use wait(), signal();  // 本管程内引用的外部模块的过程说明
    /* 向缓冲区送产品的过程 */
    procedure put(char product) {
        if(k==N) {
            // 缓冲区已满,调用者(生产者)进程阻塞等待(退出至 non_full 队列)
            wait(non_full);
        }
        buffer[next_empty] = product;
        k += 1;  // 可用产品数+1
        next_empty = (next_empty + 1) % N;  // 环形更新
        // 唤醒等待取产品的消费者进程(在 non_empty 队列中)
        signal(non_empty);
    }
    /* 从缓冲区取产品的过程 */
    procedure get(char goods) {
        if(k==0) {
            // 缓冲区空,调用者(消费者)进程阻塞等待(退出至 non_empty 队列)
            wait(non_empty);
        }
        goods = buffer[next_full];
        k -= 1;  // 可用产品数-1
        next_full = (next_full + 1) % N;
        // 唤醒等待送产品的生产者进程(在 non_full 队列中)
        signal(non_full);
    }
    /* 初始化 */
    {
        k = 0; next_empty = 0; next_full = 0;
    }
}

/* 调用管程的相应过程实现进程同步 */
producer: {
    char item;
    <-- produce an item -->
    prod_conshow.put(item);
}
consumer: {
    char item;
    prod_conshow.get(item);
    <-- consume the item -->
}

C++代码实现:

#include <iostream>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <thread>
using namespace std;
 
class Monitor {
private:
    queue<int> buffer;               // 共享的缓冲区
    int maxSize;                     // 缓冲区的最大容量
    mutex mtx;                       // 互斥锁
    condition_variable bufferFull;   // 缓冲区满的条件变量
    condition_variable bufferEmpty;  // 缓冲区空的条件变量
public:
    // 构造函数,初始化缓冲区大小
    Monitor(int size) : maxSize(size) {}

    // 生产者进程的方法
    void produce(int item) {
        unique_lock<mutex> lock(mtx);  // 锁定互斥锁
        // 如果缓冲区已满,则生产者等待
        while (buffer.size() == maxSize) {
            bufferFull.wait(lock);  // 释放锁并等待,直到被通知(唤醒)
        }
        buffer.push(item);  // 将数据添加到缓冲区
        cout << "Produced item: " << item << endl;  // 给用户提示
        bufferEmpty.notify_one();  // 通知一个消费者,缓冲区中有产品可拿走消费
    }
 
    // 消费者进程的方法
    int consume() {
        unique_lock<mutex> lock(mtx);  // 锁定互斥锁
        // 如果缓冲区为空,则消费者等待
        while (buffer.empty()) {
            bufferEmpty.wait(lock);  // 释放锁并等待,直到被通知(唤醒)
        }
        int item = buffer.front();  // 从缓冲区取出数据
        buffer.pop();
        cout << "Consumed item: " << item << endl;
        bufferFull.notify_one();  // 通知一个生产者,缓冲区中有空位可添加数据
        return item;
    }
};
 
Monitor monitor(5);  // 创建 Monitor 类的实例并指定缓冲区大小为5
// 生产者线程函数
void producer() {
    for (int i = 1; i <= 10; ++i) {
        monitor.produce(i);
        // 模拟耗时:生产间隔是500ms,即线程休眠500毫秒
        this_thread::sleep_for(chrono::milliseconds(500));
    }
}
// 消费者线程函数
void consumer() {
    for (int i = 1; i <= 10; ++i) {
        int item = monitor.consume();
        // 模拟耗时:消费间隔是800ms,即线程休眠800毫秒
        this_thread::sleep_for(chrono::milliseconds(800));
    }
}
// 在主函数中,创建并启动生产者线程和消费者线程,然后等待它们完成
int main() {
    // 创建新线程,这个新线程与主线程(通常是 main 函数所在的线程)会并行执行
    thread producerThread(producer);
    thread consumerThread(consumer);
    // 等待线程完成,确保 producerThread 线程执行完毕后才能执行下一个线程,否则各个线程执行程度不一比较混乱
    // 换句话说,join() 方法会阻塞当前线程(通常是主线程),保证主线程在所有子线程执行完毕后再继续执行(不会提前结束)
    producerThread.join();
    consumerThread.join();
    return 0;
}

PS:在上面的C++例子中,不需要手动对互斥锁解锁,当进程的方法结束时,lock 对象离开其作用域后,若互斥锁 mutex 还处于锁定状态,那么在 unique_lock 对象的析构之时会自动解锁(对象被销毁了)。在某些循环中,用户或许需要使用 lock.unlock() 进行手动解锁。

设计模式

设计模式是在软件工程中被反复使用的一种解决方案的范式,它提供了一种通用且可重用的方法来解决常见的设计问题。以下是根据目的和范围对设计模式进行的分类:

创建型模式(Creational Patterns)

这些模式提供了一种在创建对象时控制对象创建过程的方法。它们隐藏了创建逻辑的复杂性,以便于使用者可以更加专注于逻辑的实现。

  • 工厂模式 (Factory Method)
  • 抽象工厂模式 (Abstract Factory)
  • 单例模式 (Singleton)
  • 建造者模式 (Builder)
  • 原型模式 (Prototype)

结构型模式(Structural Patterns)

这些模式关注类和对象的组合,以及如何通过继承机制来扩展这些结构。

  • 适配器模式 (Adapter)
  • 桥接模式 (Bridge)
  • 组合模式 (Composite)
  • 装饰器模式 (Decorator)
  • 外观模式 (Facade)
  • 享元模式 (Flyweight)
  • 代理模式 (Proxy)

行为型模式(Behavioral Patterns)

这些模式关注对象之间的通信,以及如何通过交互来实现更大的功能。

  • 责任链模式 (Chain of Responsibility)
  • 命令模式 (Command)
  • 解释器模式 (Interpreter)
  • 迭代器模式 (Iterator)
  • 中介者模式 (Mediator)
  • 备忘录模式 (Memento)
  • 观察者模式 (Observer)
  • 状态模式 (State)
  • 策略模式 (Strategy)
  • 模板方法模式 (Template Method)
  • 访问者模式 (Visitor)

并发模式(Concurrency Patterns)

这些模式关注如何管理多个线程或进程,以提高程序的性能和响应能力。

  • 生产者-消费者模式 (Producer-Consumer)
  • 读写锁模式 (Read-Write Lock)
  • 线程池模式 (Thread Pool)
  • 双缓冲模式 (Double Buffer)

其他模式

这些模式不属于上述四大类别,但也非常重要。

  • 空对象模式 (Null Object)
  • 标记接口模式 (Marker Interface)
  • 类型对象模式 (Type Object)
  • 依赖注入模式 (Dependency Injection)
  • 服务定位器模式 (Service Locator)

每种模式都有其特定的用途和适用场景,选择合适的模式可以帮助开发者构建出更加灵活、可维护和可扩展的软件系统。


码农日记 · 贰
https://afly36-swordsman.github.io/2024/04/10/Programming02/
Author
Zenitsu
Posted on
April 10, 2024
Updated on
August 23, 2024