CppCon 2018 学习:THE MOST VALUABLE VALUES

这段文本进一步探讨了 值语义编程语言 中的相关概念,尤其是在 HaskellC 编程中如何使用语言来交换和操作"值"。我将逐一解释其中的每个部分。

值语义 (Value Semantics)

值语义 强调了值本身的独立性。在编程中,值是可以传递的,并且修改这些值不会影响其他地方的值。值通常是不可变的。在这部分中提到的 42blue"Juanpe Bolívar"f(x) = x⁴(自然数集)都是典型的值,它们在不同上下文中是独立的,不会因为其他值的变化而改变。

Haskell 示例

接下来,给出了一个 Haskell 中的例子:

haskell 复制代码
let x = 42 
    y = x + 2 
    z = [ 1, 2, 3, 4 ] 
    f = λ x -> x ^ 4 
    w = map f z
  • let x = 42 定义了一个值 x,其值为 42
  • y = x + 2 通过 x 来定义 y ,这表示 yx 加 2 的结果。由于 值不可变y 是一个独立的值,它的创建和 x 的值没有直接关系。
  • z = [1, 2, 3, 4] 定义了一个列表 z
  • f = λ x -> x ^ 4 定义了一个函数 f,它接受一个参数 x,并返回 x 的四次方。
  • w = map f z 使用 map 函数将 f 应用到列表 z 中的每个元素,生成一个新列表 w
    这段 Haskell 代码展示了值语义的特点:值的不可变性和通过函数式编程进行操作。

C 示例

紧接着,给出了一个 C 编程的例子:

c 复制代码
int foo = 42; 
int* p = &foo; 
*p = foo + 1;
  • int foo = 42; 定义了一个整数变量 foo,并将其值初始化为 42
  • int* p = &foo; 定义了一个指针 p,它指向 foo 的内存位置。
  • *p = foo + 1; 通过指针 p 修改了 foo 的值,将其设置为 foo + 1(即 43 )。
    与 Haskell 中的值语义不同,C 使用 引用(指针) 来直接修改内存中的值,这种方式更接近 对象语义,即对象的值可以直接修改和改变。

Haskell 与 C 的对比:

  • Haskell 中的 值语义 强调的是不可变的值,每个值是独立的,并且不会随着其他值的改变而变化。你传递的是值的副本,不会修改原始值。
  • C 中的 引用语义(通过指针操作)允许你直接操作内存中的地址,改变原始值。

对象、位置、类型、生命周期

  • 对象 (Object): 在编程中,对象 通常指代一种具有 状态行为 的实体,特别是在面向对象编程中。对象可以在程序的不同地方传递,并在不同的上下文中表现出不同的行为。
  • 位置 (Location): 这指的是值或对象在内存中的存储位置。在 C 中,指针变量存储的是对象在内存中的 地址,因此可以通过该地址修改对象的值。
  • 类型 (Type): 每个值或对象都有一个类型,它决定了该值或对象的合法操作及其存储方式。在 C 中,int foo 的类型是 整数 ,在 Haskell 中,x = 42 的类型是 整数 类型。
  • 生命周期 (Life-time): 这是指对象或变量在程序中的有效期。例如,在 C 中,局部变量的生命周期通常由其作用域决定,而在 Haskell 中,值的生命周期通常由垃圾回收机制管理。

值、指针与内存地址:

在 C 中,指针操作是值的传递的核心机制。通过指针,我们不仅传递数据的 副本 ,还可以直接操作数据在内存中的 位置 ,这与 值语义 在 Haskell 中的概念是不同的。通过指针,C 程序能够更灵活地修改数据,甚至能够改变数据的 类型生命周期

总结:

  • 值语义 (Value Semantics) 强调的是数据的独立性和不可变性,尤其在 Haskell 中非常典型。每次操作都会产生一个新的值,而不修改原始数据。
  • C 语言中的指针和引用 允许对数据进行修改,强调 内存地址和位置,这使得数据的修改直接影响原始数据。
  • Haskell 中,数据的类型和生命周期更容易受到控制,因为它遵循不可变性原则;而在 C 中,程序员需要明确地管理内存,并处理类型和生命周期的变化。
    通过对比这两种编程方式,可以更好地理解 值语义引用语义 (或称为 对象语义)之间的差异。

涉及了很多编程概念,特别是 面向对象编程函数式编程 中的术语。它还探讨了 抽象值语义引用语义可变状态 等概念,并提到了几种不同的设计范式和工具。我们来逐个理解这些概念和代码。

对象崇拜 (Object Fetishism)

"对象崇拜"这一术语指的是,当所有你能命名和操作的事物都被视为对象时 ,你会将一切都看作是"对象"。这意味着在面向对象编程(OOP)中,我们倾向于将现实世界的事物建模为对象,而忽视了它们可能只是 或其他更抽象的结构。

代码示例:

cpp 复制代码
struct gesichtbuch { 
    struct person { 
        id_t id; 
        std::string name; 
        std::string email; 
        std::string phone; 
        int birth_year; 
    }; 
    std::unordered_map<id_t, person> people; 
    boost::multi_index<std::pair<id_t, id_t>, ...> friendships; 
};

这里定义了一个 gesichtbuch 结构体,它包含了一个 person 结构体和两个数据成员:

  • people 是一个哈希表,用来存储 person 对象,每个 person 对象通过一个 id_t 唯一标识。
  • friendships 是一个多索引容器,用来存储人之间的友谊关系。
    这种设计是典型的面向对象编程(OOP)的风格,其中数据被封装在 对象 中,并通过不同的 方法 进行操作。

操作规范 (Operational Specs)

"操作规范"是对程序行为的详细描述,通常用于定义程序的操作和它们如何实现。它包括以下几种不同的描述方法:

  • 纯函数 (Pure Functions) : 纯函数是指不改变外部状态、并且相同的输入总是返回相同的输出的函数。例如:f(x) = x^2
  • 语义规范 (Denotational Specs): 通过对程序行为的数学定义或符号描述,来指定程序的行为。
  • 过程 (Procedures): 描述程序的步骤和算法。
  • 共享可变状态 (Shared Mutable State): 程序中多个部分可以同时修改的状态,通常在多线程环境中很重要。

程序设计的不同范式:

  1. 值和引用 (Reference and Value) :
    • 值传递 (Pass by Value):传递的是数据的副本,每次传递的值独立于其他地方的值。
    • 引用传递 (Pass by Reference):传递的是数据的引用,允许在函数内修改外部变量。
  2. 抽象 (Abstraction) :
    • 抽象是通过隐藏复杂性并提供简洁接口来简化编程的过程。它允许我们专注于问题的核心,而不必关心实现的细节。
  3. 语义 (Semantic) :
    • 语义指的是程序中各种元素的意义,比如数据类型、操作、控制结构等。程序的语义决定了它的执行效果。

OOP vs FP:

  1. 面向对象编程 (Object-Oriented Programming) :
    • 关注将数据和操作封装在对象中,通过对象与对象之间的交互来实现功能。
    • 是封装数据和行为的基本单元。
    • 继承多态 是 OOP 的核心特性,它们允许我们基于现有的对象创建新对象并重用行为。
  2. 函数式编程 (Functional Programming) :
    • 侧重于使用纯函数不可变值进行编程,避免副作用。
    • 函数是程序的核心,通过组合函数来解决问题。

编程工具与特性:

  • shared_ptr : 这是 C++ 中的智能指针之一,负责自动管理内存,避免内存泄漏。
  • mutex: 用于在多线程环境中同步对共享资源的访问,防止数据竞争。
  • constexpr : 一个 C++ 关键字,表示常量表达式,它可以在编译时计算出值,而不是在运行时计算。

总结:

  • 值和引用:编程语言中的值语义和引用语义,分别处理数据的不可变性和可变性。
  • 面向对象与函数式编程:面向对象编程 (OOP) 聚焦于对象和它们的交互,而函数式编程 (FP) 聚焦于函数的组合和不可变性。
  • 抽象与语义:编程中的抽象帮助简化问题的复杂性,而语义决定了程序的行为。

反思

这段代码展示了 C++ 中一些复杂的编程技巧和设计模式,涉及到了 std::shared_ptrstd::vector右值引用拷贝控制引用折叠等高级特性。我们逐一解析:

std::vector<int> push_back(std::vector<int> vec, int x)

cpp 复制代码
std::vector<int> push_back(std::vector<int> vec, int x) 
{ 
    vec.push_back(x); 
    return vec; 
}

这是一个简单的函数,接受一个 std::vector<int> 和一个整数 x,然后将 x 添加到向量的末尾,并返回更新后的向量。

  • 参数传递方式 :这里使用的是按值传递的方式,即复制了传入的 vec 向量。这意味着修改 vec 不会影响原始的向量(因为 vec 是按值传递的)。
  • 返回 :函数返回更新后的向量。由于返回值是一个局部变量,编译器通常会通过 返回值优化 (RVO) 来避免不必要的拷贝。

class foostd::shared_ptr

cpp 复制代码
class foo { 
    std::shared_ptr<impl> impl_;  // 使用std::shared_ptr来管理impl对象,确保impl对象的生命周期得到自动管理
public: 
    // const&版本的modified函数:该函数仅修改对象的内部状态(通过调用clone和mutate),而不改变对象本身
    foo modified(int arg) const& { 
        // 克隆impl对象,确保修改不影响原始的impl
        auto p = impl_->clone(); 
        // 修改克隆出来的impl对象
        p->mutate(arg); 
        // 返回一个新的foo对象,新的foo对象持有修改后的impl副本
        return foo{p}; 
    } 
    // &&版本的modified函数:该函数在对象是右值时被调用,允许原地修改当前对象
    foo&& modified(int arg) && { 
        // 检查impl是否是唯一的所有者(即当前对象是唯一拥有impl资源的)
        if (impl_->unique()) {  // unique()检查是否是唯一拥有者,不支持多线程安全
            // 如果是唯一所有者,直接修改impl对象
            impl_->mutate(arg); 
        }
        else {
            // 如果不是唯一所有者,表示还有其他shared_ptr引用impl对象
            // 这时通过克隆一个impl副本来避免共享修改的问题
            *this = modified();  // 使用左值版本的modified来创建新对象,并赋值给当前对象
        }
        // 返回当前对象的右值(std::move(*this)),允许调用者接管当前对象
        return std::move(*this); 
    } 
};

这个类展示了 C++11 中的 右值引用std::shared_ptr 的用法,特别是 "资源管理""拷贝与移动构造" 的设计。

1. std::shared_ptr<impl> impl_
  • impl_ 是一个 std::shared_ptr ,用来管理 impl 对象的生命周期。这里采用了 抽象实现 模式(即 pImpl ),在类 foo 中没有暴露具体的实现细节,只有一个指向 impl 的共享指针。
2. modified(int arg) const&
  • 这是一个 左值引用成员函数 ,表示它不会修改对象本身(const& 表示只读)。
  • 在该函数内部,调用了 impl_->clone() 来复制 impl 对象,然后通过 p->mutate(arg) 来修改复制出来的 impl
  • 最终,函数返回了一个新的 foo 对象,它持有这个新克隆的 impl,因此返回的是一个新对象而非修改现有对象。
3. modified(int arg) &&
  • 这是一个 右值引用成员函数 ,表示该函数是为了移动语义 设计的。只有当 foo 对象是右值时,才会调用这个函数。
  • impl_->unique() 判断 std::shared_ptr 是否是唯一的所有者。若是唯一所有者(即没有其他共享指针指向这个对象),则直接修改原始 impl_
  • 若不是唯一所有者,则通过重新调用 modified() 来克隆 impl,并返回更新后的对象。这种情况下,当前对象被赋值为一个新值,并最终返回右值(通过 std::move)。
cpp 复制代码
#include <iostream>
#include <memory>
// 假设impl是一个内部实现类
class impl {
public:
    int data;  // 模拟的内部数据
    impl(int val) : data(val) {}
    // 克隆方法,返回impl对象的副本
    std::shared_ptr<impl> clone() const { return std::make_shared<impl>(*this); }
    // 修改内部数据
    void mutate(int arg) { data += arg; }
    // 打印impl的数据
    void print() const { std::cout << "impl data: " << data << std::endl; }
};
// foo类管理一个impl对象
class foo {
    std::shared_ptr<impl>
        impl_;  // 使用std::shared_ptr来管理impl对象,确保impl对象的生命周期得到自动管理
public:
    // 构造函数,初始化impl对象
    explicit foo(std::shared_ptr<impl> impl) : impl_(std::move(impl)) {}
    // const&版本的modified函数:该函数仅修改对象的内部状态(通过调用clone和mutate),而不改变对象本身
    foo modified(int arg) const& {
        // 克隆impl对象,确保修改不影响原始的impl
        auto p = impl_->clone();
        // 修改克隆出来的impl对象
        p->mutate(arg);
        // 返回一个新的foo对象,新的foo对象持有修改后的impl副本
        return foo{p};
    }
    // &&版本的modified函数:该函数在对象是右值时被调用,允许原地修改当前对象
    foo&& modified(int arg) && {
        // 检查impl是否是唯一的所有者(即当前对象是唯一拥有impl资源的)
        if (impl_.use_count() == 1) {  // use_count()检查是否是唯一拥有者
            // 如果是唯一所有者,直接修改impl对象
            impl_->mutate(arg);
        } else {
            // 如果不是唯一所有者,表示还有其他shared_ptr引用impl对象
            // 这时通过克隆一个impl副本来避免共享修改的问题
            *this = modified(arg);  // 使用左值版本的modified来创建新对象,并赋值给当前对象
        }
        // 返回当前对象的右值(std::move(*this)),允许调用者接管当前对象
        return std::move(*this);
    }
    // 打印foo对象的impl数据
    void print() const { impl_->print(); }
};
int main() {
    foo f1(std::make_shared<impl>(10));  // 创建foo对象,impl的数据初始化为10
    f1.print();                          // 输出:impl data: 10
    // 使用左值版本修改foo对象的impl数据
    foo f2 = f1.modified(5);
    f2.print();  // 输出:impl data: 15
    // 使用右值版本修改foo对象的impl数据
    foo f3 = std::move(f1).modified(3);
    f3.print();  // 输出:impl data: 13
    // f1此时已被移动,不再可用(如果访问会导致未定义行为)
    return 0;
}

**class screen右值引用

cpp 复制代码
class screen { 
    screen_handle handle_; 
    screen(const screen&) = delete; 
public: 
    screen&& draw(...) && { 
        do_draw(handle_, ...); 
    } 
    auto draw(...) const { 
        return [hdl=handle_] (context ctx) { 
            do_draw(hdl, ...); 
        }; 
    } 
};

这个 screen 类展示了如何利用 右值引用 来控制对象的生命周期,同时也展示了 不可复制 的类设计模式。

1. screen_handle handle_;
  • handle_ 是类的核心成员,表示屏幕的句柄。它的类型是 screen_handle,可以是一个类或者指针,表示对某些资源(如屏幕)的引用。
2. screen(const screen&) = delete;
  • 这一行明确禁止了 拷贝构造函数 ,即不能复制 screen 对象。这是为了防止多个 screen 对象同时管理相同的资源(screen_handle),避免资源冲突或重复释放。
3. screen&& draw(...) &&
  • 这是一个 右值引用成员函数 ,表示该方法只能通过右值调用。这个函数执行某些 绘制操作 ,并且可能会修改或处理 handle_ 资源。
  • 右值引用允许将当前对象的状态传递给函数,通常用于当对象的生命周期即将结束时,允许它执行最后的操作(例如,绘制)。
4. auto draw(...) const
  • 这是一个 常量成员函数 ,它不会修改类的状态。它通过 闭包lambda)来返回一个可调用的函数,这个函数会在未来的某个时刻执行绘制操作。
  • hdl=handle_ 语法表示捕获 handle_ 的值,并且 do_draw(hdl, ...) 会使用它来绘制屏幕。

总结

  1. 右值引用 (&&) 在 C++ 中用于实现 移动语义 和优化资源管理。在 fooscreen 类中,右值引用被用来优化对象的移动和避免不必要的复制。
  2. std::shared_ptr 提供了智能指针,帮助管理资源生命周期。通过 unique() 方法,能够判断是否是唯一所有者,从而选择是否进行修改或复制。
  3. 禁用拷贝构造函数 (= delete) 用于防止对象复制,通常在资源管理类中使用,确保资源不会被不恰当地共享或重复释放。
  4. const 和右值引用的结合screen 类中的 const 函数返回了一个闭包,它允许我们不改变类的状态,但仍然能够按需执行某些操作。
    通过这些技巧,可以创建具有高效资源管理、灵活语义和优化性能的 C++ 代码。
cpp 复制代码
#include <iostream>
// 假设 screen_handle 和 context 是一些平台相关的类型
using screen_handle = int;  // 假设为整数类型,代表屏幕句柄
using context = int;        // 假设为整数类型,代表上下文(可以是绘图相关的对象)
// 假设 do_draw 是一个全局函数,用于在给定的屏幕句柄上绘制
void do_draw(screen_handle hdl, context ctx) {
    std::cout << "Drawing on screen with handle " << hdl << " in context " << ctx << std::endl;
}
// screen 类定义
class screen {
    screen_handle handle_;  // 屏幕句柄
    // 禁止拷贝构造函数
    screen(const screen&) = delete;
public:
    // 构造函数:初始化 screen_handle
    screen(screen_handle handle) : handle_(handle) {}
    // 右值引用版本的 draw
    screen&& draw(int ctx) && {
        // 在右值对象上直接执行绘制操作
        do_draw(handle_, ctx);    // 传递绘制上下文
        return std::move(*this);  // 返回右值
    }
    // 常量版本的 draw,返回一个 lambda 延迟执行绘制
    auto draw(int ctx) const& {  // 使用 & 来表示它是左值的
        return [hdl = handle_](context c) {
            // 使用屏幕句柄进行绘制
            do_draw(hdl, c);  // ctx 是由外部提供的上下文参数
        };
    }
};
// 测试代码
int main() {
    screen s(1);  // 创建一个 screen 对象,假设 1 是屏幕的句柄
    // 使用右值引用版本的 draw 函数
    std::move(s).draw(42);  // 42 作为绘制上下文
    // 使用常量版本的 draw 返回的 lambda
    auto lambda = s.draw(0);  // 0 作为绘制上下文
    lambda(42);               // 调用 lambda 并传递绘制上下文
    return 0;
}

"值语义(Value Semantics)" 以及 "对象(Object)" 的选择,通常与 宏观设计(Macro Design)微观设计(Micro Design) 的决策息息相关。我们可以从这些角度来理解何时使用 值语义对象

1. 对象 ⟶ 宏观设计(Macro Design)

宏观设计涉及系统架构的整体设计。对象通常用于 有状态 的实体,它们的状态会随时间变化。

  • 对象 是有状态的实体(例如:用户、文件、窗口等),你可以对它们的状态进行修改。
  • 何时使用对象(宏观设计)
    • 当需要 可变性:对象可以随时间改变其内部状态。
    • 当需要 复杂交互:多个对象之间需要进行交互,代表不同的概念(例如:用户、文件、任务等)。
    • 当需要管理 现实世界实体,这些实体的属性会随时间改变。
    • 当你需要使用 身份 来标识对象,而不仅仅是其数据。
      例子:
  • 在游戏中,玩家对象会有一个不断变化的得分、位置和生命值。
  • 在数据库管理中,每个记录对象可能具有不同的状态,随着数据库的更新其状态会变化。

2. 值 ⟶ 微观设计(Micro Design)

微观设计关注的是更小范围的设计决策,例如具体的数据结构和算法选择。值是 不可变 的,它代表不随时间变化的数据。值没有身份,通常用于不需要标识的数据处理。

  • 是不可变的。它们本身不具备身份(例如:一个数值、一段字符串)。
  • 何时使用值(微观设计)
    • 当需要 不可变性:值在创建后不可更改,避免了共享数据修改带来的副作用。
    • 当你需要 无状态的 组件,或者在 函数式编程 中更常见。
    • 当你只关心数据的 内容,而非其身份或是否发生改变(例如:一组不可变的数字、一个配置对象)。
    • 当你希望使用 值相等 比较(即数据相同的两个值视为相等,而不关心它们是否是同一个对象)。
      例子:
  • 数学向量:它的值是不可变的,两个相同的向量可以看作是相等的。
  • 字符串:如果我们把字符串作为值处理,它是不可变的,每次修改会返回一个新的字符串。

3. 值 ⟶ 宏观设计对象 ⟶ 微观设计

这些表达也可以理解为:

  • 值 ⟶ 宏观设计 :强调在系统设计的 大范围 结构中,使用不可变的值来传递数据和保持一致性。
    • 何时使用
      • 如果你的系统设计是 声明式的 ,或者偏向 函数式编程
      • 如果系统的主要关心点是 共享数据的安全性程序行为的可预测性
      • 当系统中主要是 抽象的数据传递 ,而不需要关注对象的状态和身份。
        例子:
    • 在函数式编程中,使用 持久化数据结构不可变数据结构 来表示系统的整体状态,而不是基于对象的状态。
    • 一个全局的 配置对象,它包含一些设置数据,整个系统中它是不可变的。
  • 对象 ⟶ 微观设计 :在 小范围 内部,使用 对象 来管理状态和操作。
    • 何时使用
      • 当你需要创建 自包含的单位,它们持有自己的状态并对其进行操作。
      • 当你需要利用 多态性继承 来定义不同类型对象的共享行为。
      • 当你关注 对象之间的细粒度交互 和对象的 状态管理
        例子:
    • 一个 银行账户对象,它有存款和取款的方法,以及当前余额的状态,操作这些方法会改变账户的状态。

4. 值 ⟶ 宏观设计对象 ⟶ 微观设计 的关系

这两者其实是 设计选择,你可以选择根据不同的设计目标来决定使用值还是对象。

  • 值 ⟶ 宏观设计:可以在系统的架构层面使用不可变数据结构(例如:数据流、函数式编程),适合那些需要高度声明性、避免副作用的场景。
  • 对象 ⟶ 微观设计:在小范围内,使用具有内部状态的对象来处理和管理数据。

总结:什么时候使用值语义(Value Semantics)?

  • 当你需要 不可变性,并希望通过不变数据来避免副作用时。
  • 当你在使用 函数式编程 ,尤其是需要 无状态的 组件时。
  • 当你关心 数据传递,而不关心数据的身份(即值相等,而不是对象相等)。
  • 当系统中的数据应保持一致性,避免修改操作时。
    例如:
  • 使用 不可变的集合持久化数据结构 来代表一些数据(例如:队列、栈、树等)。
  • 在一些需要 并发安全不可变对象 的系统中,使用 值语义 来确保数据不会被多个线程修改。

第二部分:面向值的交互软件架构

这一部分讨论的是一种 面向值的架构 ,用于 交互式软件 。在这种架构中,对象(Objects)引用(Reference)间接引用(Indirect Reference)模型(Model)控制器(CTRL)视图(View) 是关键的组成部分,帮助我们理解软件组件如何互相交互。

我们逐一分析这些术语,并解释它们如何适应 面向值的架构

1. 对象(Objects)

在面向对象设计(OOD)中,对象是基本的封装单元。它包含了:

  • 数据(属性或字段)
  • 行为 (方法或函数)
    面向值的架构 中,对象通常是不可变的 ,意味着一旦创建对象,它的状态就不能被改变。如果需要更改状态,将会创建一个新的对象实例,而不是直接修改原对象,这体现了 值语义(value semantics)。
示例:
  • 在一个游戏中,玩家(Player) 对象表示玩家的得分、生命值和名字。每当玩家得分时,不会修改原来的玩家对象,而是创建一个新对象,表示更新后的玩家状态。

2. 引用(Reference)

引用 指向对象的内存地址或位置,允许你访问或修改该对象。在 面向值的架构 中,引用的使用需要小心,以避免意外修改共享数据。

例如,当在 面向值的架构 中传递对象时,通常会使用 引用指针 ,但尽可能使用不可变对象。这有助于保持数据的 完整性,避免多线程环境下的修改冲突。

示例:
  • 如果你传递一个 Player 对象作为引用,接收方可以访问它,但如果对象是不可变的,就无法修改它,除非显式创建一个新的对象来表示更新。

3. 间接引用(Indirect Reference)

间接引用 指的是通过其他对象(通常是通过指针或智能指针)访问目标对象。这使得对象的内存地址或位置不会被直接暴露。

面向值的架构 中,通常会使用 间接引用 来避免直接修改原始对象的状态。通过智能指针(如 C++ 的 std::shared_ptr),可以在不同地方共享对象,但确保数据的一致性和不可变性。

示例:
  • 使用 shared_ptr 可以将同一个玩家对象传递给多个组件,但只有某个组件能够修改它。如果另一个组件需要修改它,就会通过克隆该对象来避免直接共享修改。

4. 模型(Model)

模型 表示应用程序的核心数据和业务逻辑。它封装了软件的主要概念(如数据实体或业务规则)。模型通常聚焦于表示现实世界的概念 (如用户、游戏状态、配置信息等)。

面向值的架构 中,模型通常采用 不可变的数据结构。当模型发生变化时,通常会创建新实例,而不是直接修改现有数据。

示例:
  • 在一个 待办事项(Todo) 应用中,模型可能代表一个任务列表,每个任务有名称、描述和状态。每个任务对象是不可变的,添加、完成或删除任务时,会生成一个新的任务列表实例,而不是直接修改原有列表。

5. 控制器(CTRL)

控制器 是架构中的部分,负责处理 用户输入 和管理 视图模型 之间的交互。控制器接收来自 视图 的事件(如点击、键盘输入),并根据这些事件更新 模型

面向值的架构 中,控制器通常充当 视图模型 之间的中介。由于模型是不可变的,控制器可能需要通过创建新的模型实例来更新模型,而不是直接修改现有模型。

示例:
  • 在一个游戏中,当玩家执行某个动作(如点击按钮)时,控制器会:
    • 接收输入事件
    • 创建一个新的游戏状态(表示该动作后的结果)
    • 更新视图,显示新的游戏状态。

6. 视图(View)

视图 表示应用程序的 用户界面(UI) 。它负责将 模型 中的数据呈现给用户,并根据模型的变化更新显示。

面向值的架构 中,视图通常会监听模型的变化,并根据新的模型状态重新绘制界面。由于模型是不可变的,每次模型变化时,视图会接收到新的模型实例来更新显示。

示例:
  • 视图 可能显示 玩家(Player) 的分数和生命值。当 控制器 更新玩家状态时,视图 会基于新的玩家对象来更新显示,而这个新对象代表的是玩家状态的更新版。

这些概念如何适应面向值的架构

面向值的架构 中,主要目标是保持 数据不可变性 (通过值语义)并清晰地划分 模型控制器视图 的责任:

  • 对象 存储不可变的数据。
  • 引用间接引用 允许访问对象,但由于对象是不可变的,因此避免了无意的修改。
  • 模型 以干净的、不可变的方式封装数据,便于状态管理和变更跟踪。
  • 控制器 处理业务逻辑和用户输入,通过创建新的模型实例而非修改现有模型来更新模型。
  • 视图 根据新的模型状态更新 UI,总是显示最新的、不可变的模型数据。

总结

面向值的架构 中,不可变数据 (值语义)和 关注分离 的设计原则紧密结合,有助于避免因共享可变状态所导致的复杂性和潜在错误。这种架构使得软件更加 可预测易于维护,尤其在复杂的交互式软件中,可以显著提高可靠性和可扩展性。

单向数据流架构(Unidirectional Data Flow Architecture)

单向数据流架构 (Unidirectional Data Flow Architecture) - 对象引用与间接引用

在你提供的 Mermaid 图中,我们看到了 单向数据流架构 的典型模式。这个图展示了 Action (动作)到 Model (模型),然后再到 View (视图)的数据流动过程,并且在 ModelView 之间还有一定的交互。

让我们来一步步解释图中的内容:
update(model, action) → model draw(model)
render(model) → view dispatch(action) ACTION MODEL VIEW

复制代码
  graph TD
      A[ACTION] -->|"update(model, action) → model"| B[MODEL]
      B -->|"draw(model) <br> render(model) → view"| C[VIEW]
      B -.-> B
      C -.->|"dispatch(action)"| A
      style A fill:#552299,stroke:#333,stroke-width:2px
      style B fill:#225522,stroke:#333,stroke-width:2px
      style C fill:#882211,stroke:#333,stroke-width:2px
图解:
  1. ACTION → MODEL :
    • Action 是触发的用户行为或外部事件,作用是影响 Model(模型)的状态。
    • Action 发出后,模型被更新。图中表示为 update(model, action) → model。这通常是通过 Action Creator 或类似机制进行的,模型在这一步骤中发生变化。
  2. MODEL → VIEW :
    • Model 更新后,视图需要渲染这些变化。图中 draw(model) <br> render(model) → view 表示从 Model 获取数据并渲染到 View(视图)上。这个步骤通常是 UI 更新的一部分,例如 React 中的重新渲染。
  3. 间接引用 - B -.-> B :
    • 这里的 B -.-> B 代表 Model 的状态可能会发生 间接引用 ,例如 Model 可能会根据某些条件内部更新(自我修改)或自我触发某些操作。这是 间接引用 的一种形式,其中 Model 根据其内部状态或外部信号进行自我更新。
  4. VIEW → ACTION :
    • ViewAction 之间的 间接引用 :图中显示了 C -.->|"dispatch(action)"| A,意味着 View 可以通过与用户的交互,发出新的 Action ,然后再次通过 Action 更新 Model,形成一个新的循环。这个动作可以是用户点击按钮、输入文本等。

对象引用与间接引用:

单向数据流架构 中,提到的 对象引用间接引用 主要是指在架构中的对象(如 ModelView)之间的关系。

  1. 对象引用(Reference)
    • 对象引用意味着我们直接持有对某个对象的引用或指针。例如,如果 View 直接引用了 Model ,那么视图可能会直接操作模型的数据或结构。在图中,如果 View 直接访问 Model ,就表现为一个直接的引用(A → B)。
  2. 间接引用(Indirect Reference)
    • 间接引用 表示我们并没有直接持有对象的引用,而是通过某种中介或者间接方式来操作对象。例如,Model 内部的自我更新或 View 通过触发 Action 来间接影响 Model 。在图中,B -.-> BC -.-> A 就代表了间接引用的行为,说明对象之间并不是通过直接的引用进行交互,而是通过某些中介或触发器来达成影响。

总结:

  1. 单向数据流架构 通过明确的数据流动方向,确保了数据和操作的清晰和一致性。数据总是从 ActionModel ,再从 ModelView,避免了不必要的复杂交互和混乱。
  2. 对象引用间接引用 是该架构中的两个重要概念。直接引用指的是对象之间直接访问,而间接引用则是通过某些中介或事件触发来间接影响对象。
    这种架构使得开发者能够更好地理解数据是如何在应用中流动的,并减少了因复杂交互带来的调试难题。

这个例子展示了一个 交互式计数器 (Interactive Counter)模型。通过用户输入的事件(例如 "+"、"-"、".")来更新计数器的值,并且每次更新后会显示当前值。这个例子展示了如何使用 单向数据流架构 来设计和实现交互式应用。

代码结构解析

  1. 数据模型 (model) :

    cpp 复制代码
    struct model { 
        int value = 0; 
    };
    • 这是一个简单的 model 结构体,表示计数器的状态,它只有一个成员 value 来存储当前计数器的值,初始化为 0
  2. 动作类型 (increment_action, decrement_action, reset_action) :

    cpp 复制代码
    struct increment_action {}; 
    struct decrement_action {}; 
    struct reset_action { int value = 0; }; 
    • increment_action:表示增加计数的动作。
    • decrement_action:表示减少计数的动作。
    • reset_action :表示重置计数器的动作,带有一个可选的 value,可以指定重置后的值(默认为0)。
  3. 更新函数 (update) :

    cpp 复制代码
    model update(model c, action action) { 
        return std::visit(lager::visitor{ 
            [&] (increment_action) { 
                return model{ c.value + 1 }; 
            }, 
            [&] (decrement_action) { 
                return model{ c.value - 1 }; 
            }, 
            [&] (reset_action a) { 
                return model{ a.value }; 
            }
        }, action); 
    }
    • update 函数根据传入的 action 类型来更新 model 。它使用了 std::visitvisitor 模式来对不同的动作(increment_actiondecrement_actionreset_action)进行处理。
    • 如果是 increment_action,则将当前 value 增加1;如果是 decrement_action,则将当前 value 减少1;如果是 reset_action,则将 value 设置为指定的值(如果没有指定值则默认为 0)。
  4. 绘制函数 (draw) :

    cpp 复制代码
    void draw(counter::model c) { 
        std::cout << "current value: " << c.value << '\n'; 
    }
    • draw 函数用于在终端输出当前计数器的值。每次计数器值变化后都会调用此函数来显示新的状态。
  5. 事件解析函数 (intent) :

    cpp 复制代码
    std::optional<action> intent(char ev) { 
        switch (ev) { 
        case '+': 
            return increment_action{}; 
        case '-': 
            return decrement_action{}; 
        case '.': 
            return reset_action{}; 
        default: 
            return std::nullopt; 
        } 
    }
    • intent 函数根据用户输入的字符 ev 返回相应的 action 类型。
    • 如果用户输入 +,返回 increment_action;如果输入 -,返回 decrement_action;如果输入 .,返回 reset_action;如果输入的是其他字符,则返回 std::nullopt 表示无效输入。
  6. main 函数 :

    cpp 复制代码
    int main() { 
        auto state = counter{0}; 
        auto event = char{}; 
        while (std::cin >> event) { 
            if (auto act = intent(event)) { 
                state = update(state, *act);
                draw(state); 
            } 
        } 
    }
    • state :表示当前计数器的状态,初始值为 0
    • event:表示用户输入的事件(字符)。
    • while 循环中,程序不断从标准输入读取字符 event,并根据输入解析成相应的 action 。如果有有效的 action ,则调用 update 函数来更新 model ,然后调用 draw 函数显示当前的计数器状态。

总结

这个交互式计数器应用演示了一个简单的 单向数据流架构

  1. 用户通过输入触发 Action (例如 + 增加,- 减少,. 重置)。
  2. Action 会传递给 Model ,并通过 update 函数更新 Model 的状态。
  3. Model 更新后,视图通过 draw 函数显示新的计数值。
核心设计:
  • 状态(Model) 只通过 Action 更新,而不是直接从视图中修改。
  • Action 是用户交互的触发器(例如按键),通过 intent 函数解析。
  • 更新和绘制 是分开的,符合 单向数据流 的设计模式。

交互流

  • 用户输入 → 解析事件 → 生成 Action → 更新 Model → 绘制更新后的 Model

优点

  • 使代码易于理解、测试和维护。
  • 清晰的 数据流事件处理 机制。
cpp 复制代码
#include <iostream>
#include <variant>
#include <optional>
// 定义计数器的状态模型
struct model {
    int value = 0;  // 当前计数器的值
};
// 定义所有的操作(Action)
struct increment_action {};  // 增加计数
struct decrement_action {};  // 减少计数
struct reset_action {        // 重置计数
    int value = 0;           // 重置后的值
};
// 定义一个`action`类型,它可以是上面定义的任何一种动作
using action = std::variant<increment_action, decrement_action, reset_action>;
// 更新函数,根据动作更新模型
model update(model c, action act) {
    return std::visit(
        [&](auto&& action) -> model {
            if constexpr (std::is_same_v<std::decay_t<decltype(action)>, increment_action>) {
                return model{c.value + 1};  // 增加计数
            } else if constexpr (std::is_same_v<std::decay_t<decltype(action)>, decrement_action>) {
                return model{c.value - 1};  // 减少计数
            } else if constexpr (std::is_same_v<std::decay_t<decltype(action)>, reset_action>) {
                return model{action.value};  // 重置计数
            }
        },
        act);  // 使用std::visit处理variant
}
// 绘制函数,输出当前计数器值
void draw(const model& m) { std::cout << "current value: " << m.value << '\n'; }
// 事件解析函数,根据输入的字符生成相应的Action
std::optional<action> intent(char ev) {
    switch (ev) {
        case '+':
            return increment_action{};  // 增加
        case '-':
            return decrement_action{};  // 减少
        case '.':
            return reset_action{};  // 重置
        default:
            return std::nullopt;  // 无效输入
    }
}
// 主函数
int main() {
    model state;  // 初始模型,默认值为0
    char event;   // 用于存储输入的事件字符
    std::cout << "Interactive Counter\n";
    std::cout << "Press '+', '-', or '.' to interact.\n";
    // 无限循环,等待用户输入
    while (std::cin >> event) {
        // 解析输入的事件
        if (auto act = intent(event)) {
            // 更新模型并绘制结果
            state = update(state, *act);
            draw(state);  // 输出当前计数器值
        } else {
            std::cout << "Invalid action. Try again.\n";
        }
    }
    return 0;
}

根据提供的代码,我将为你绘制一个 Mermaid 模型图。此图展示了 交互式计数器 的整体架构,包括模型(model)、动作(action)、更新(update)和视图(draw)之间的关系。

Mermaid 图:

intent(event) → action update(model, action) → model draw(model) → output event input (e.g., '+', '-', '.') ACTION MODEL VIEW

Mermaid 图的解释

  1. ACTION :
    • 用户输入的字符(如 '+', '-', '.')通过 intent(event) 被转换为对应的动作(increment_actiondecrement_actionreset_action)。intent 函数解析事件并返回一个可选的动作类型。
  2. MODEL :
    • model 存储当前计数器的值,并根据不同的 ACTION 更新其状态。update(model, action) 函数会根据 action 类型来更新模型的值(加 1、减 1 或重置为指定的值)。这种方式通过 std::visit 动态调度来根据不同的 action 类型执行不同的更新。
  3. VIEW :
    • VIEW 是输出层,负责展示当前的 model 值。draw(model) 函数会将 model 中存储的 value 输出到屏幕上。
  4. 单向数据流 :
    • 数据流从 ACTION 开始,由用户的输入触发,通过 intent(event) 生成 ACTION
    • 然后 MODEL 被更新,通过 update(model, action) 修改。
    • VIEW 负责将模型中的数据绘制到输出中(即输出当前计数器值)。
    • 最后,视图通过用户的输入触发新的 ACTION,从而进入一个循环。

颜色和样式说明:

  • ACTION(浅粉色)表示用户输入的操作。
  • MODEL(浅绿色)表示存储计数器状态的模型。
  • VIEW (浅蓝色)表示将模型数据渲染到屏幕上的输出层。
    这种设计体现了 单向数据流 架构,确保了程序的清晰和可维护性。
    你提供的代码和内容涉及了多种编程范式和概念,尤其是在 函数式编程响应式编程 上。下面是对代码片段和相关概念的解释:

1. Elm, Redux 和 Lager

  • Elm : 这是一个函数式编程框架,用于构建前端应用。它基于 Haskell ,并使用 单向数据流不可变数据结构 来管理状态更新和渲染。
  • Redux: 是 JavaScript 中用于管理应用状态的一个库,采用单向数据流的架构。它通过一个"store"来保存应用状态,并通过"action"来描述如何更新这个状态。
  • Lager: 类似于 Redux,但它是为 C++ 语言设计的。它使得 C++ 应用能够像 Redux 一样管理状态,使用类似的架构和理念。

2. draw, intent, main 函数中的工作原理

  • draw(model c):绘制函数,它用于将当前模型的状态(例如计数器的值)输出到屏幕。
  • intent(event_t ev):处理事件的函数,它将来自用户输入(例如按键)转换成相应的"动作"(Action)。
  • main() :是程序的入口点,其中设置了一个 Lager store 来管理应用状态,并通过事件监听来更新模型,最后调用 draw 函数来渲染新的状态。

3. 代码分析

cpp 复制代码
int main() { 
    auto debug = lager::http_debug_server{8080}; 
    auto serv  = boost::asio::io_service{}; 
    auto store = lager::make_store<action>( 
        counter{0}, 
        update, 
        draw, 
        lager::boost_asio_event_loop{serv}, 
        lager::enable_debug(debug)); 
    auto term  = ncurses::terminal{serv}; 
    term.listen([&] (event_t ev) { 
        store.dispatch(intent(ev)); 
    }); 
    serv.run(); 
}
  • lager::make_store :创建了一个类似 Redux 的"store",它包含了模型的初始状态 counter{0},状态更新函数 update,渲染函数 draw,以及一个事件循环(基于 Boost Asio)。
  • term.listen() :监听终端输入事件,用户的输入(例如按键)会被转换为 action ,然后 store.dispatch() 将此 action 分发给 store 来更新模型。
  • serv.run():启动事件循环,监听输入并进行处理。

4. debugger 模型

cpp 复制代码
template <typename Action, typename Model> 
struct debugger { 
  struct goto_action { std::size_t cursor; }; 
  struct undo_action {}; 
  struct redo_action {}; 
  using action = std::variant< 
      Action, 
      goto_action, 
      undo_action, 
      redo_action>;  // 动作类型包括常规动作和调试相关动作(跳转,撤销,重做)
  struct model { 
    Model init; 
    std::size_t cursor = {};  // 当前操作指针
    immer::vector<std::pair<Action, Model>> 
        history = {};  // 操作历史
    model(Model i) : init{i} {} 
    operator const Model& () const { 
        return cursor == 0 
          ? init : history[cursor - 1].model;  // 返回当前模型的状态(包括历史)
    }
  };
}
  • debugger 模型 :用于记录和调试应用的历史状态。它不仅保存当前模型的状态(init),还维护一个历史记录(history)以便进行撤销(undo)、重做(redo)等操作。
  • goto_action, undo_action, redo_action:这些是调试功能相关的动作。通过它们可以在历史状态中跳转、撤销或重做。
  • model :这个结构包含了应用的当前状态、操作指针(cursor)和历史记录(history)。它通过 operator const Model& 实现了历史记录的访问。

5. update 函数

cpp 复制代码
model update(model m, action act, std::function<Model(Model, Action)> reducer) { 
    return std::visit(visitor{ 
        [&] (Action act) { 
            ... reducer(m, act) 
        }, 
        [&] (goto_action act) { 
            ... 
        }, 
        [&] (undo_action) { 
            ... 
        }, 
        [&] (redo_action) { 
            ... 
        }, 
    }, act); 
}
  • update 函数 :这个函数接收一个模型和一个动作(action),并使用 std::visit 来调度处理不同类型的动作(例如 Action, goto_action, undo_action, redo_action)。对于每个动作,执行不同的更新逻辑。

6. effectupdate

cpp 复制代码
template <typename Action> 
using effect = std::function<void(context<Action>)>; 
pair<model, effect<action>> update(model, action);
  • effect :它是一个函数类型,用于描述模型更新时的副作用。effect<Action> 接受一个 context<Action>,并返回一个没有返回值的函数(即副作用函数)。副作用通常用于异步操作、I/O 操作等场景。
  • update :更新函数返回一个 model 和一个 effect。这表明更新不仅可以改变模型的状态,还可能执行一些副作用。

7. Postmodern Immutable Data Structures

  • Postmodern Immutable Data Structures (后现代不可变数据结构)指的是使用不可变数据结构的编程范式。不可变数据结构具有以下特点:
    • 不可修改:一旦创建,数据结构的内容不能被修改。
    • 高效的更新:通过结构共享和惰性复制,可以高效地更新不可变数据结构。
    • 函数式编程的核心:不可变数据结构使得函数式编程成为可能,它通过消除副作用和共享状态来简化程序的理解和调试。

总结:

这个代码实现了一个基于 LagerBoost Asio 的交互式计数器应用,它结合了函数式编程和响应式编程的思想,使用了 不可变数据结构单向数据流 的架构。同时,它也展示了如何处理撤销和重做等调试功能,通过对历史操作的管理来实现。

cpp 复制代码
#include <iostream>
#include <variant>
#include <optional>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
// 定义计数器的状态模型
struct model {
    int value = 0;  // 当前计数器的值,初始为0
};
// 定义所有的操作(Action)
struct increment_action {};  // 增加计数操作
struct decrement_action {};  // 减少计数操作
struct reset_action {        // 重置计数操作
    int value = 0;           // 重置后的值
};
// 定义一个`action`类型,它可以是上面定义的任何一种动作
using action = std::variant<increment_action, decrement_action, reset_action>;
// 更新函数,根据动作更新模型
model update(model c, action act) {
    // 使用std::visit来处理不同类型的action
    return std::visit(
        [&](auto&& action) -> model {
            if constexpr (std::is_same_v<std::decay_t<decltype(action)>, increment_action>) {
                return model{c.value + 1};  // 增加计数
            } else if constexpr (std::is_same_v<std::decay_t<decltype(action)>, decrement_action>) {
                return model{c.value - 1};  // 减少计数
            } else if constexpr (std::is_same_v<std::decay_t<decltype(action)>, reset_action>) {
                return model{action.value};  // 重置计数
            }
        },
        act);  // 使用std::visit处理variant
}
// 绘制函数,输出当前计数器值
void draw(const model& m) { 
    std::cout << "current value: " << m.value << '\n'; 
}
// 事件解析函数,根据输入的字符生成相应的Action
std::optional<action> intent(char ev) {
    switch (ev) {
        case '+':
            return increment_action{};  // 增加
        case '-':
            return decrement_action{};  // 减少
        case '.':
            return reset_action{};  // 重置
        default:
            return std::nullopt;  // 无效输入,返回空
    }
}
// 简单的事件循环类事件调度
class event_loop {
public:
    event_loop() : running(true) {}
    // 启动事件循环
    void run() {
        while (running) {
            // 在条件变量上等待,直到队列不为空或事件循环停止
            std::unique_lock<std::mutex> lock(mutex);
            cond_var.wait(lock, [this]() { return !event_queue.empty() || !running; });
            if (!event_queue.empty()) {
                // 从队列中获取事件
                char ev = event_queue.front();
                event_queue.pop();
                lock.unlock();  // 解锁,允许其他线程操作队列
                process_event(ev);  // 处理事件
            }
        }
    }
    // 添加事件到队列
    void dispatch(char ev) {
        std::lock_guard<std::mutex> lock(mutex);  // 确保线程安全
        event_queue.push(ev);  // 将事件加入队列
        cond_var.notify_one();  // 通知等待中的事件循环线程
    }
    // 停止事件循环
    void stop() {
        running = false;
        cond_var.notify_all();  // 唤醒所有线程,结束事件循环
    }
private:
    // 处理队列中的事件
    void process_event(char ev) {
        if (auto act = intent(ev)) {  // 判断输入是否为有效事件
            // 更新模型并绘制结果
            state = update(state, *act);
            draw(state);  // 输出当前计数器值
        } else {
            std::cout << "Invalid action. Try again.\n";  // 输入无效,提示用户
        }
    }
    bool running;  // 控制事件循环是否运行
    std::queue<char> event_queue;      // 事件队列,存储待处理的事件
    std::mutex mutex;                  // 互斥锁,确保线程安全
    std::condition_variable cond_var;  // 条件变量,用于线程间通信
    model state;                       // 当前模型,存储计数器的状态
};
// 主函数
int main() {
    event_loop loop;  // 创建事件循环对象
    // 启动事件循环的线程
    std::thread loop_thread([&]() { 
        loop.run();  // 在新线程中运行事件循环
    });
    // 模拟监听终端输入
    char event;
    std::cout << "Interactive Counter\n";
    std::cout << "Press '+', '-', or '.' to interact.\n";
    // 无限循环,等待用户输入并将事件传递给事件循环
    while (std::cin >> event) {
        loop.dispatch(event);  // 将事件添加到事件队列中
    }
    loop.stop();         // 停止事件循环
    loop_thread.join();  // 等待事件循环线程结束
    return 0;
}

注释说明:

  1. 计数器模型(model :
    • 定义了一个 model 结构体,表示当前计数器的状态,包含一个 int value,其初始值为 0。
  2. Action 类型(action :
    • 定义了三种操作(increment_action, decrement_action, reset_action),它们分别代表增加、减少和重置计数器值。
    • 使用 std::variant 将这三种操作封装成一个 action 类型。
  3. 更新模型(update :
    • update 函数根据传入的 action 更新计数器的值。它使用 std::visit 来处理不同类型的 action
  4. 绘制函数(draw :
    • draw 函数用于输出当前计数器的值。
  5. 事件解析(intent :
    • intent 函数根据用户输入的字符返回对应的 action。若输入无效,返回 std::nullopt
  6. 事件循环(event_loop :
    • event_loop 类负责管理事件队列,并在后台运行一个事件循环,等待并处理事件。
    • 通过 std::mutexstd::condition_variable 来实现线程间同步,保证事件循环的线程安全。
  7. 主函数 :
    • main 函数中,创建一个 event_loop 对象,并启动一个独立的线程来执行事件循环。
    • 主线程用于监听用户输入,将事件通过 loop.dispatch 方法传递到事件循环中。
    • 当用户输入字符后,事件被处理并更新计数器状态,最后输出当前值。

关键点:

  • 事件驱动: 使用 event_loop 类来模拟事件循环,处理用户输入并更新模型状态。
  • 线程和同步: 使用 std::mutexstd::condition_variable 来同步多个线程之间的事件处理。
相关推荐
Scott9999HH7 小时前
【IIoT流量实战】蒸汽管道阀门全关却仍有流量?用 Python 实现涡街信号 FFT 频谱分析与温压全补偿积算网关,深度拆解靠谱的涡街流量计厂家硬核技术标准
开发语言·python
2401_841495647 小时前
【操作系统】进程同步与互斥实验报告
c++·算法·操作系统·进程·并发·同步·互斥
fqbqrr8 小时前
2607C++,soui与安卓
c++·soui
码智社8 小时前
AES加密原理详解及Java实现加解密实战
java·开发语言
AI云海8 小时前
python 列表、元组、集合和字典
开发语言·python
萧瑟余晖9 小时前
JDK 26 新特性详解
java·开发语言
马优晨9 小时前
Freemarker 完整讲解(后端 Java 模板引擎)
java·开发语言·freemarker·freemarker 完整讲解·freemarker模板引擎
a11177611 小时前
2FA 验证码生成器(github登录验证 app)
笔记·学习
我的xiaodoujiao11 小时前
API 接口自动化测试详细图文教程学习系列32--Allure测试报告2
python·学习·测试工具·pytest
fqbqrr11 小时前
2607C++,使用微软detours勾挂工具
c++