各位好!欢迎来到“代码地狱与天堂”的交界处。
今天我们不聊那些虚头巴脑的架构图,也不谈那些花里胡哨的前端动画。今天我们要聊的是一种古老、强大,甚至有点“脾气暴躁”,但绝对能让你的业务逻辑坚如磐石的武器——C++ 强类型系统。
在领域驱动设计(DDD)里,我们经常听到“不变式”、“生命周期”、“充血模型”这些词。在那些“鸭子类型”泛滥的动态语言世界里,这些概念往往变成了运行时的笑话。但在 C++ 里,这些概念是写在编译器里的铁律。
想象一下,你的业务规则是什么?是“不能卖负数的钱”,是“订单只有在确认后才能发货”,是“用户不能在未登录状态下下单”。在 Java 或 Python 里,这些是 if (money < 0) throw Error,或者是 if (user.isLoggedIn)。但在 C++ 里,我们把这些规则编译进类型系统里。如果有人试图违反规则,编译器会直接把代码变成废铁,而不是等到上线那一刻才让数据库崩溃。
今天,我们就来聊聊怎么用 C++ 的嘴脸,去管理那些复杂的业务逻辑。
一、 值对象:不可变宇宙的基石
在 DDD 中,值对象 是最基础的砖块。什么是值对象?它没有唯一标识,但有一堆属性。比如“金额”、“日期”、“地址”。
在 C++ 里,如果你想让值对象“神圣不可侵犯”,你必须利用 const 和 private 成员变量,以及构造函数的约束。
1. 不仅仅是数据,是契约
很多新手写 C++,喜欢把结构体敞开,然后到处赋值。但在 DDD 里,一旦对象被创建,它的业务状态就锁死了。
看看这个例子。我们要表示“钱”。
#include <string>
#include <stdexcept>
#include <fmt/format.h> // 这里用 fmt 只是方便演示,实际可用 std::format
// 这是一个值对象
struct Money {
int cents; // 我们用分为单位,避免浮点数精度问题
std::string currency;
private:
// 私有构造函数,防止外部瞎搞
Money(int c, std::string cur) : cents(c), currency(std::move(cur)) {}
public:
// 默认构造函数,通常用于数据库反序列化或默认值
// 注意:这里给了一个默认值,但在业务逻辑中,你可能希望禁止 0 元的存在
Money() : cents(0), currency("USD") {}
// 静态工厂方法:这是创建值对象的标准姿势
static Money fromDollars(double dollars, const std::string& cur) {
if (dollars < 0) {
throw std::invalid_argument("Money cannot be negative!");
}
// 将浮点数转换为整数,避免精度丢失
int cents = static_cast<int>(dollars * 100);
return Money(cents, cur);
}
// 不可变操作:加法应该返回一个新的对象,而不是修改自己
// 这就是 DDD 里的“不可变对象”模式
Money add(const Money& other) const {
if (this->currency != other.currency) {
throw std::runtime_error("Cannot add different currencies!");
}
return Money(this->cents + other.cents, this->currency);
}
// 打印一下,看看我们有多严谨
std::string toString() const {
double dollars = static_cast<double>(cents) / 100.0;
return fmt::format("${:.2f} {}", dollars, currency);
}
};
看到这个 add 函数了吗?它返回了一个新的 Money 对象。为什么?因为在业务上,金钱是不能被篡改的。你不能修改一张旧钞票的面值,你也应该不能修改一个对象的状态。所有的状态变更都通过“创建新对象”来表达。
如果你试图这样做:
Money a = Money::fromDollars(10.0);
a.cents = -100; // 哪怕你加了 private,如果你不小心写了 friend 或者用了非 const 引用,也能改
// 但在 DDD 里,我们要的是:一旦对象生成,它就是永恒的。
在真正的 DDD C++ 实践中,我们通常更进一步,使用 constexpr 和模板来确保这些操作在编译期就能被检查。
二、 生命周期与状态机:std::variant 的魔法
业务对象是有“脾气”的。订单有状态:未确认、已确认、已发货、已送达。用户有状态:未激活、已激活、被封禁。
以前我们怎么写?用 int state 或者 enum class,然后到处都是 if (state == 1) ... else if (state == 2)。这叫“面条代码”,是软件维护者的噩梦。
在 C++17 以后,我们有了神器——std::variant。它本质上是一个类型安全的联合体。它允许一个变量同时持有多种类型,但只能持有其中一种。
1. 把“状态”变成“类型”
让我们用 C++ 来表达一个“订单”的生命周期。
#include <variant>
#include <optional>
#include <iostream>
// 定义订单状态
enum class OrderStatus {
Created, // 已创建
Confirmed, // 已确认
Shipped, // 已发货
Delivered, // 已送达
Cancelled // 已取消
};
// 以前的做法:混乱
// Order o; o.status = OrderStatus::Created;
// if (o.status == OrderStatus::Created) { ... }
// DDD 做法:类型即状态
class Order {
public:
// 这是一个类型安全的联合体,它要么是 Created,要么是 Confirmed...
using State = std::variant<
std::monostate, // 用于默认构造或重置
struct Created,
struct Confirmed,
struct Shipped,
struct Delivered,
struct Cancelled
>;
private:
State state;
public:
Order() : state(Created{}) {}
// 核心逻辑:状态转换
// 只有在特定状态下才能调用特定方法
void confirm() {
// std::visit 是 C++17 的模式匹配神器
// 它会根据 state 的实际类型,执行对应的 lambda
std::visit([](auto& arg) {
// 使用 C++20 的 requires 约束,或者在这里写死逻辑
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, Created>) {
// 只有 Created 状态才能确认
std::cout << "Order confirmed!n";
state = Confirmed{};
} else {
// 如果不是 Created,那就是非法操作
std::cout << "Error: Cannot confirm an already confirmed order!n";
// 在这里,我们可以抛出异常,或者直接编译报错(如果用模板元编程)
}
}, state);
}
void ship() {
std::visit([](auto& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, Confirmed>) {
std::cout << "Order shipped!n";
state = Shipped{};
} else {
std::cout << "Error: Cannot ship an unconfirmed order!n";
}
}, state);
}
// 查看当前状态
void printStatus() const {
std::visit([](auto&& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, std::monostate>) {
std::cout << "Status: Unknownn";
} else if constexpr (std::is_same_v<T, Created>) {
std::cout << "Status: Createdn";
} else if constexpr (std::is_same_v<T, Confirmed>) {
std::cout << "Status: Confirmedn";
}
// ... 其他状态省略
}, state);
}
};
看懂了吗?
在这里,state 变量不仅仅是一个整数。它是 Created、Confirmed 等结构体的容器。
当你调用 confirm() 时,std::visit 会检查当前容器里装的是不是 Created。如果是,它就替换成 Confirmed。
如果不是,它就报错。
这就是编译时检查的雏形。虽然 std::visit 是运行时执行的,但它强制你处理了所有的分支。你没法在 Created 状态下调用 ship(),因为编译器不会让你通过(或者说,你的逻辑代码会阻止它)。这完美地表达了业务的生命周期规则:状态是连续的,不可逆跳变。
三、 约束与不变式:requires 与 static_assert
C++20 引入了 concept(概念)。这玩意儿简直是 DDD 的福音。
在 DDD 中,我们经常遇到“可处理”的对象。比如,一个“支付网关”只能处理“有效”的支付请求。以前,我们可能写一个 bool isValid(PaymentRequest req) 函数。但如果这个函数被漏掉了怎么办?或者传错了参数怎么办?
现在,我们用 concept 把这个检查变成类型系统的约束。
#include <concepts>
// 定义一个概念:必须是一个 Money 对象,并且金额大于 0
template<typename T>
concept PositiveMoney = requires(T money) {
{ money.cents } -> std::convertible_to<int>; // 必须有 cents 属性
{ money.cents } > 0; // 必须大于 0
};
// 定义一个概念:必须是一个 Order,并且必须是 Created 状态
template<typename T>
concept CreatedOrder = requires(T order) {
// 我们可以假设 Order 有一个 isCreated() 方法,或者通过 std::visit 检查
// 这里为了演示,假设有方法
{ order.isCreated() } -> std::same_as<bool>;
};
// 业务服务:只有正数的钱才能被处理
template<PositiveMoney T>
void processPayment(T amount) {
std::cout << "Processing " << amount.toString() << "n";
}
// 业务服务:只有 Created 状态的订单才能开始结算
template<CreatedOrder T>
void startSettlement(T order) {
std::cout << "Starting settlement for order...n";
}
// 测试代码
int main() {
Money validMoney = Money::fromDollars(50.0);
Money invalidMoney; // 默认是 0
// 这行代码会编译通过
processPayment(validMoney);
// 这行代码会编译报错!
// processPayment(invalidMoney);
Order o;
o.confirm(); // 变成 Confirmed
// o.ship(); // 这行代码本身没问题,但 startSettlement 需要的是 CreatedOrder
// startSettlement(o); // 编译错误!因为 o 不是 Created 状态
}
看懂了吗? 这就是强类型的威力。processPayment 函数的签名直接锁死了参数类型。如果你传了一个 int,或者一个 std::string,编译器直接报错。你根本不需要在函数体里写 if (amount < 0),因为 PositiveMoney 这个概念已经帮你过滤掉了所有非法类型。
这就是业务不变式的编译时保障。不变式一旦违反,程序根本编译不过去。
四、 所有权与生命周期:智能指针的契约
DDD 强调“充血模型”,也就是业务逻辑在实体内部。这意味着,实体可能会持有其他对象的引用。
在 C++ 中,处理所有权是噩梦。谁拥有这个对象?谁负责释放它?如果两个实体都指向同一个对象,它是 shared_ptr 还是 weak_ptr?
在 DDD 中,我们通常遵循“谁创建,谁负责销毁”的原则。这正好对应了 std::unique_ptr。
1. 聚合根与值对象的所有权
假设我们有一个“订单”实体,它包含一个“金额”值对象。通常,值对象是轻量级的,可以被复制(拷贝语义)。但如果是“用户画像”,它可能包含复杂的属性,我们就需要管理它的生命周期。
#include <memory>
class User {
std::string name;
int age;
public:
User(std::string n, int a) : name(std::move(n)), age(a) {}
// ... getters ...
};
class Order {
// 这里我们使用 unique_ptr,明确表示:Order 实体拥有这个 User 对象
// 一旦 Order 被销毁,User 也会被销毁(除非有其他引用)
std::unique_ptr<User> buyer;
// 业务规则:只有拥有用户,才能下单
Order(std::unique_ptr<User> user) : buyer(std::move(user)) {
if (!user) {
throw std::runtime_error("Order must have a buyer!");
}
}
// 订单确认逻辑
void confirm() {
// 这里我们可以安全地访问 buyer
std::cout << "Order confirmed for " << buyer->name << "n";
// 业务规则:只有年满 18 岁才能下单
if (buyer->age < 18) {
throw std::runtime_error("User is too young to order!");
}
}
};
// 使用示例
int main() {
// 创建用户
auto user = std::make_unique<User>("Alice", 25);
// 创建订单,所有权转移给 Order
Order order(std::move(user));
try {
order.confirm();
} catch (const std::exception& e) {
std::cerr << e.what();
}
// 注意:此时 user 已经失效了!
// std::cout << user->name; // 编译错误!
}
通过 std::unique_ptr,我们在代码层面强制执行了生命周期规则。你没法在不拥有资源的情况下访问它,你也没法在拥有资源之后继续持有它。这消除了“悬垂指针”和“内存泄漏”的可能性。
如果业务需要共享所有权(比如两个订单引用同一个用户),那我们用 std::shared_ptr,但必须非常小心,因为这意味着生命周期变得复杂了。
五、 深入实战:一个复杂的电商结算系统
好了,理论讲多了,我们来点干货。假设我们要设计一个电商系统的结算模块。
需求:
- 购物车:包含多个商品。
- 商品:有价格、库存。
- 结算:检查库存,计算总价,扣减库存,生成订单。
- 约束:库存不足不能结算;总价不能为负;只能结算当前有效的商品。
1. 定义商品(值对象)
struct ProductId {
std::string value;
// 为了演示方便,重载 ==,让 ProductId 可以作为 map 的 key
bool operator==(const ProductId& other) const = default;
};
// 商品实体,拥有自己的库存逻辑
class Product {
ProductId id;
std::string name;
int priceCents; // 价格
int stock;
public:
Product(ProductId pid, std::string n, int p, int s)
: id(pid), name(std::move(n)), priceCents(p), stock(s) {}
// 检查库存,返回一个 Result 类型(可选值)
std::optional<int> getStock() const {
return stock;
}
// 扣减库存:这是充血模型的核心!
// 业务逻辑:如果库存不够,返回 false;否则扣减并返回 true
bool reserveStock(int quantity) {
if (stock < quantity) {
return false; // 业务规则:库存不足
}
stock -= quantity;
return true;
}
};
2. 定义购物车(值对象 + 集合)
#include <vector>
#include <unordered_map>
// 购物车条目:商品 + 数量
struct CartItem {
ProductId productId;
int quantity;
bool operator==(const CartItem& other) const = default;
};
class ShoppingCart {
std::vector<CartItem> items;
public:
void addItem(const ProductId& pid, int qty) {
// 业务规则:数量至少为 1
if (qty <= 0) return;
// 检查是否已存在,存在则增加数量
for (auto& item : items) {
if (item.productId == pid) {
item.quantity += qty;
return;
}
}
items.push_back({pid, qty});
}
void removeItem(const ProductId& pid) {
items.erase(std::remove_if(items.begin(), items.end(),
[pid](const CartItem& item) { return item.productId == pid; }),
items.end());
}
// 获取所有条目
const std::vector<CartItem>& getItems() const {
return items;
}
};
3. 定义结算服务(聚合根逻辑)
这是最关键的部分。结算服务不仅仅是计算价格,它还要验证业务规则。
#include <numeric> // for accumulate
class CheckoutService {
public:
// 复杂类型别名,为了可读性
using Money = ::Money; // 假设上面定义了 Money
using ProductMap = std::unordered_map<ProductId, Product>;
// 结算函数:输入购物车和产品库,输出订单
// 注意返回值类型:std::optional<Order>,因为结算可能失败
std::optional<Order> checkout(const ShoppingCart& cart, const ProductMap& catalog) {
// 1. 检查购物车是否为空
if (cart.getItems().empty()) {
std::cerr << "Checkout failed: Cart is empty.n";
return std::nullopt;
}
int totalCents = 0;
std::vector<ProductId> reservedProducts;
// 2. 遍历购物车,验证库存并计算总价
for (const auto& item : cart.getItems()) {
// 在 catalog 中查找商品
auto it = catalog.find(item.productId);
if (it == catalog.end()) {
std::cerr << "Checkout failed: Product not found.n";
return std::nullopt;
}
const Product& product = it->second;
// 3. 检查库存
if (!product.getStock().has_value() || product.getStock().value() < item.quantity) {
std::cerr << "Checkout failed: Insufficient stock for product: " << product.name << "n";
return std::nullopt;
}
// 4. 扣减库存(这里是核心:在计算总价的同时,修改了 Product 的状态!)
// 如果这里返回 false,说明库存被其他线程抢光了(虽然单线程演示看不出,但逻辑上是这样的)
if (!product.reserveStock(item.quantity)) {
std::cerr << "Checkout failed: Concurrent modification or logic error.n";
return std::nullopt;
}
totalCents += product.priceCents * item.quantity;
reservedProducts.push_back(item.productId);
}
// 5. 创建订单
// 注意:这里我们返回的是 Order 对象,而不是 void
// 业务逻辑已经全部在函数体内执行完毕
return Order(totalCents, reservedProducts);
}
};
4. 定义订单(最终产物)
class Order {
Money totalAmount;
std::vector<ProductId> productIds;
public:
Order(int cents, const std::vector<ProductId>& ids)
: totalAmount(Money(cents, "USD")), productIds(ids) {}
Money getTotalAmount() const {
return totalAmount;
}
void printReceipt() const {
std::cout << "=== Receipt ===n";
std::cout << "Total: " << totalAmount.toString() << "n";
std::cout << "Items: " << productIds.size() << "n";
}
};
这段代码的精髓在哪里?
- 充血模型:
CheckoutService包含了所有的业务逻辑(库存检查、价格计算、状态变更)。它不是一个简单的数据传输对象(DTO),而是一个真正的领域服务。 - 不变式保护:
Product::reserveStock修改了库存,这改变了对象的状态。如果检查失败,整个结算流程回滚(在这个简单例子中,我们直接返回 nullopt,实际上应该有事务机制)。 - 类型安全:
Money对象确保了金额的计算是安全的。std::optional确保了操作失败时,调用者必须显式处理错误,而不是得到一个 null 值然后继续计算。
六、 模板元编程:终极的约束
如果你觉得上面的 concept 还不够劲爆,我们还可以用模板元编程(TMP)来做到极致。
假设我们定义了一个“银行账户”的概念,它必须满足“余额不能为负”这个不变式。
template<typename T>
concept BankAccount = requires(T account) {
{ account.getBalance() } -> std::convertible_to<int>;
// 这是一个静态断言,在编译时检查
{ account.getBalance() >= 0 } -> std::convertible_to<bool>;
};
// 一个不满足条件的类(故意写的坏代码)
struct BadAccount {
int getBalance() const { return -100; }
};
// 一个满足条件的类
struct GoodAccount {
int getBalance() const { return 1000; }
};
// 业务逻辑:只有银行账户才能被转账
template<BankAccount T>
void transferMoney(T& account, int amount) {
if (account.getBalance() >= amount) {
std::cout << "Transfer successful.n";
} else {
std::cout << "Insufficient funds.n";
}
}
int main() {
GoodAccount good;
transferMoney(good, 50); // 编译通过
BadAccount bad;
// transferMoney(bad, 50); // 编译报错!
// 错误信息大概是:static assertion failed: constraint expression is not satisfied
// 因为 BadAccount 的 getBalance() >= 0 永远是 false,不满足 BankAccount 概念
}
你看,BadAccount 甚至不需要有 transferMoney 这个函数,只要它不满足 BankAccount 的约束,它就被系统排除了。
七、 错误处理与异常:业务规则的最后防线
虽然我们用类型系统锁住了大部分错误,但总有一些错误是运行时才知道的(比如数据库连接断了,或者网络请求超时)。这时候,C++ 的异常机制就是我们的救命稻草。
在 DDD 中,异常应该是业务错误的直接表达。
class DomainException : public std::runtime_error {
public:
explicit DomainException(const std::string& msg) : std::runtime_error(msg) {}
};
// 在业务逻辑中使用
class Warehouse {
public:
void shipItem(const Item& item) {
if (!item.isAvailable()) {
// 直接抛出业务异常,而不是通用的 std::runtime_error
throw DomainException("Item is out of stock!");
}
// 执行发货逻辑...
}
};
使用自定义的异常类,可以方便地在上层架构中进行统一的捕获和处理。
八、 总结:为什么这很重要?
各位,我们讲了这么多代码,讲了 std::variant,讲了 concept,讲了 unique_ptr。这有什么用?
在传统的“贫血模型”里,业务逻辑散落在各个 Service 层,数据对象(Entity/DTO)只是数据的搬运工。你想改一个业务规则,得满世界找 Service 方法,还得担心有没有遗漏。
而在 C++ 的 DDD 里,类型就是文档。
当你看到一个 std::variant<OrderState> 时,你一眼就知道这个对象有几种状态,而且不需要看注释。
当你看到一个 template<PositiveMoney T> 时,你一眼就知道这个函数只能处理合法的钱。
当你看到一个 std::unique_ptr 时,你一眼就知道谁拥有谁。
强类型系统是业务规则的编译器。
它强制你在写代码的时候,就要思考业务的边界。它消除了 90% 的“如果…怎么办”的猜测。它让代码在编译通过的那一刻,就已经通过了业务规则的审查。
这就是 C++ 的魅力。它不原谅你,但如果你遵守规则,它会为你遮风挡雨,构建出坚不可摧的系统。
记住,代码是写给机器看的,但架构是写给人类看的。用 C++ 的强类型系统来表达 DDD,就是在用最精确的语言,去描述最复杂的业务世界。
好了,今天的讲座就到这里。希望下次你们写代码的时候,能感受到编译器那严厉的目光,并在那目光下,写出完美的业务逻辑。下课!