mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
5198 字
14 分钟
CPP重学(五)
2026-10-25

CPP重学(五)#

一、optional#

为啥需要optional

看一个经典场景:从配置里按 key 取整数值,key 可能不存在:

// 老办法 1:特殊值哨兵
int get_port() {
if (config_has("port")) return config_port();
return -1; // 用 -1 表示"没有"——万一配置真给了 -1 呢?
}
// 老办法 2:输出参数
bool get_port(int& out) {
if (!config_has("port")) return false;
out = config_port();
return true;
}
// C++17:optional
std::optional<int> get_port() {
if (!config_has("port")) return std::nullopt; // 明确表示"无值"
return config_port();
}

哨兵值的毛病:特殊值可能恰好是合法值(配置里真写了 -1 怎么办),而且读代码的人不知道”哪个值是特殊值”。输出参数把语义藏在 bool 里,啰嗦。optional 把”有没有值”变成类型的一部分——用类型表达”可能没有”,这正是现代 C++ 的一贯风格。

胖狗ps:py里也有这个,果然语言都是相通的(

基本用法#

#include <optional>
#include <string>
std::optional<std::string> find_user(int id); // 可能查不到
// 构造
std::optional<int> a = 42; // 有值
std::optional<int> b; // 无值(默认构造)
std::optional<int> c = std::nullopt; // 无值(显式)
// 判空
if (a.has_value()) { /* 有值 */ }
if (b) { /* 无值时不进入 */ } // 隐式 bool 转换
// 取值
int v1 = a.value(); // 有值返回,无值抛 std::bad_optional_access
int v2 = a.value_or(0); // 有值返回,无值返回 0(推荐)
int v3 = *a; // 解引用(不检查!无值时是 UB)

三个取值方式,优先级排列:

方式行为无值时适用
value()有值返回抛异常确定有值 / 想报错
value_or(x)有值返回,无值返回 x返回默认值最常用
* 解引用直接取未定义行为已判空后的热点路径

日常代码里 value_or 用得最多——“有就用,没有用默认值”一步到位。* 要在 if (opt) 判过空之后再用,和裸指针 *p 的风险一样。

optional对比指针#

std::optional<T> 和”指向 T 的指针”都能表达”可能没有”,差别在哪:

维度T*std::optional<T>
值存储对象在别处(指针指向)值内嵌在 optional 里
生命周期指针不拥有对象optional 拥有值
空值nullptrnullopt
误用风险悬垂、忘记释放无所有权问题
大小一个指针大小T 的大小 + 标志位

关键区别:optional 是”值语义”——它自己持有 T(内存里就是 T + 有没有 的标记),不需要额外分配堆内存,不用管释放。指针是”引用语义”——指向别处的对象,所有权要操心。所以”返回一个可能没有的小对象”用 optional,“返回一个可能没有的大对象(不想拷贝)“才考虑指针或引用。

26bc2eb4f857d6487fdab5d82e6f88c987b7a4b7

放在c语言里表示成一个结构体

typedef struct {
int value;
int has_value; // 0 或 1
} Optional

应用场景#

  • 查找函数返回值:查表、查配置、查数据库——“可能查不到”
  • 解析函数的可选参数:std::optional<size_t> 表示”长度参数,可不传”
  • 异常敏感的旧代码迁移:不想抛异常(性能/接口约束),又不想用哨兵值
  • 成员字段”可有可无”:std::optional<std::string> nickname; 比空字符串更有语义

注意一个细节:optional<T> 里装的是 T 的副本。如果 T 很大(比如 std::vector),返回 optional 会有拷贝成本——现代 C++ 的返回值优化(NRVO)通常能消掉,但心里要有数。装大对象时,optional<std::unique_ptr<T>> 之类的组合要小心:unique_ptr 本身是移动的,倒没大问题,只是”optional 套指针”和裸指针语义又接近了,想清楚再选。

总结:std::optional<T> 是”可能没有值”的值语义容器——自己持有 T、无堆分配、has_value() 判空、value_or(x) 取默认值一步到位;它替代了哨兵值、输出参数和”可选指针”,用类型把”可能没有”表达清楚。

二、variant#

在c里有个叫联合体的东西union,存在一个大坑

union Data {
int i;
float f;
char buf[4];
};
union Data d;
d.i = 42; // 现在 d 按 int 解释
// 但如果后面有人读 d.f —— 得到的是按 float 解释的 42 的位模式,垃圾值

union 只分配”最大成员”的内存,不记录当前存的是哪个类型。读错类型不会报错,只会得到无意义的值。这是 C 里一堆诡异 bug 的来源。C++ 传统上用”union + 标志位”补救,但要程序员自己保证标志和实际类型一致。

#include <variant>
#include <iostream>
std::variant<int, float, std::string> v;
v = 42; // 当前是 int
v = std::string("hi"); // 当前是 string(自动切换,旧的 int 被销毁)
// 安全取值:get<T>,类型不对会抛 std::bad_variant_access
try {
int x = std::get<int>(v); // 现在 v 装的是 string,抛异常
} catch (const std::bad_variant_access&) {
std::cout << "不是 int!\n";
}
// 先看 index 再取
switch (v.index()) {
case 0: /* int */ break;
case 1: /* float */ break;
case 2: /* string */ break;
}

三种访问方式#

variant 本身只是”存”,读取是另一门学问。三种方式从粗到细:

1. index() 查询当前是第几个类型

std::size_t idx = v.index(); // 0、1、2……

2. get<T> 按类型取 / get<idx> 按下标取

int a = std::get<int>(v); // 按类型
std::string s = std::get<2>(v); // 按下标
// 类型/下标不匹配 → 抛 std::bad_variant_access

3. std::visit:把”访问器”应用到当前值上(最优雅)

std::variant<int, float, std::string> v;
std::visit([](const auto& value) {
// 泛型 lambda:不管当前是什么类型,都能处理
std::cout << value << "\n";
}, v);

std::visit 配合泛型 lambda 是 variant 的黄金组合:lambda 的参数写 auto,编译器为每个可能类型都生成一个分支,当前是哪个类型就走哪个分支,且编译期就能检查所有分支都合法(类型列表是编译期已知的)。

啥时候用?#

该用的场景:

  • 一个值可能是几种类型之一:解析器的 token(int/double/string/bool)、配置文件的值
  • 错误处理:variant<Value, Error> 表达”要么成功要么失败”(std::expected 的简化形态)
  • 替代”继承 + 虚函数”的轻量多态:类型不多、行为不复杂时,variant + visit 比类层级更直白

不该用的场景:

  • 类型很多/会扩展:visit 的分支会爆炸,不如继承
  • 大部分时间只用一种类型:variant 的大小是”最大成员 + 标记”,如果各类型大小悬殊,内存浪费明显
  • 递归类型(variant 里有 variant 的引用)需要 std::variant + std::unique_ptr 的组合包装,属于进阶

总结:std::variant<T1, T2, ...> 是类型安全的联合体——内部记录当前类型,get 取错会抛异常,visit + 泛型 lambda 优雅分派;它取代 C union”自己记类型”的危险模式,适合”一个值是多种类型之一”的场景,轻量替代继承多态。

三、string_view#

一句话:string_view 是”字符串的只读窗口”——它存了一个 const char* 指针和一个长度,指向”别人拥有的字符序列”,自己不拥有、不复制、也不能改。

#include <string_view>
#include <iostream>
std::string_view sv("hello world"); // 指向字符串字面量
std::cout << sv.substr(0, 5); // hello
std::string s = "cpp";
std::string_view sv2 = s; // 指向 s 的数据,不复制

胖狗ps:相当于用一个指针指向已有的字符串?

7a7caed1395b6ecd22e6a87b2356f28b22927a88

创建 string_view 是 O(1):就存一个指针 + 一个长度。对比 std::string 的拷贝构造——要分配内存、逐个字符复制,是 O(n)。这就是 string_view 的卖点:看字符串,不搬字符串。

为什么函数参数推荐 string_view#

#include <string>
#include <string_view>
// 老写法:const std::string&
size_t count_a(const std::string& s) {
size_t n = 0;
for (char c : s) if (c == 'a') ++n;
return n;
}
// 问题:传入 "hello"(字面量)时,会先构造一个临时 std::string(拷贝!)
count_a("hello"); // 隐式构造临时 string,有开销
// 新写法:std::string_view
size_t count_a(std::string_view s) { // 零拷贝
size_t n = 0;
for (char c : s) if (c == 'a') ++n;
return n;
}
count_a("hello"); // 直接指向字面量,不拷贝
std::string str = "world";
count_a(str); // 也直接指向 str 的数据

传 const std::string& 时,如果调用方给的是字面量或 char*,编译器要隐式构造一个临时 std::string——分配内存 + 拷贝字符。传 string_view 则完全不拷贝,字面量也好、std::string 也好、char* 也好,一视同仁地”只看不搬”。

胖狗ps:这么调!?

核心原则:只读借用用 string_view,要持有才用 string。不过要注意,string_view 只能用于”处理”的接口;如果函数内部要存下来(成员变量、放进容器),必须转成 std::string。

string_view#

std::string_view sv = "hello world";
sv.size(); // 11
sv.length(); // 同上
sv.empty(); // 是否为空
sv[0]; // 'h'(可读不可写)
sv.at(1); // 'e'(越界抛 out_of_range)
sv.substr(6, 5); // "world"(注意:返回的也是 string_view,零拷贝)
sv.find('o'); // 4
sv.compare("hello"); // 比较
sv.data(); // const char*(不一定以 \0 结尾!)

重点提醒:

  1. substr 返回的也是 string_view——零拷贝,但也意味着它仍然指向原数据。把 substr 结果存到函数外面用,前提是原字符串还活着
  2. data() 不保证以 \0 结尾——string_view 用”指针+长度”表示,可能指向字符串中间。把它当 const char* 传给 C 接口(期待 \0 结尾)会踩坑。真需要,先 std::string(sv).c_str()
  3. 不能修改:sv[0] = 'x' 编译错误(只有 const 访问)

悬垂陷阱#

string_view 不拥有数据,所以指向的数据死了,它就成了悬垂视图——和裸指针的 use-after-free 同性质:

#include <string>
#include <string_view>
std::string_view dangling() {
std::string local = "temporary";
return local; // 危险!local 销毁后,返回的 view 悬垂
}
// 更隐蔽的:
std::string make_string() { return "abc"; }
std::string_view sv = make_string(); // 临时 string 销毁 → sv 悬垂!
// 用 sv → 未定义行为

后一个例子特别阴:make_string() 返回的临时 std::string 在语句结束后就销毁了,sv 指向的字符序列随之消失。编译器在部分场景能警告(-Wdangling-reference),但最好自己记住规则:

string_view 的生命周期不能超过它指向的数据。

判断方法很简单:问自己”它指着谁?那个人还活着吗?“——指着函数局部变量/临时对象,就是高危;指着字面量或生命周期明确的成员/参数,安全。

总结:std::string_view 是”指针 + 长度”的零拷贝只读字符串视图:创建 O(1)、不拥有数据、能直接接收字面量和 string;函数参数”只读借用”优先用它,但记住两条红线——data() 不保证 \0 结尾、视图生命周期不能超过它指向的数据。

四、异常处理#

#include <iostream>
#include <stdexcept>
double divide(double a, double b) {
if (b == 0) {
throw std::runtime_error("除以零"); // 抛出异常
}
return a / b;
}
int main() {
try {
double r = divide(10, 0); // 这里会抛异常
std::cout << r << "\n"; // 不会执行到这
} catch (const std::runtime_error& e) {
std::cout << "捕获: " << e.what() << "\n";
}
}

三个关键字的角色:

关键字作用
throw抛出异常(可以抛任何类型,惯例抛异常对象)
try标记”可能抛异常”的代码块
catch捕获并处理特定类型的异常

执行流程:divide 里 throw 一执行,函数立刻停止,控制权直接跳到最近的、能匹配这个异常类型的 catch 块。中间的代码(包括 divide 里 throw 之后的语句)全部跳过。

捕获规则#

catch有多个,会从上到下按照类型匹配

try {
// 可能抛出不同类型的异常
} catch (const std::invalid_argument& e) {
// 处理 invalid_argument
} catch (const std::runtime_error& e) {
// 处理 runtime_error
} catch (const std::exception& e) {
// 兜底:任何标准异常
} catch (...) {
// 兜底:任何异常(包括非标准类型的)
}

这里得遵循先具体后一般,子类在前,基类在后的原则。

catch 的类型匹配比函数重载宽容——引用、const、值,都可以匹配:

catch (const std::exception& e) // 推荐:按引用捕获,不复制、不切片
catch (std::exception e) // 会切片!丢失派生类型信息
catch (...) // 捕获一切

惯例是按 const 引用捕获:const SomeException&。按值捕获会把派生类”切”成基类,丢失 .what() 的具体信息。

如果没被捕获,进程会崩溃,所以我们工程上最好加一个catch兜底

异常传播与栈展开#

throw 之后,控制权往回跳:一层层退出函数,直到找到匹配的 catch。这个过程叫栈展开(stack unwinding)——每退出一层函数,该层作用域内的局部对象都会执行析构。

胖狗ps:这个厉害了,RAll这么牛批,资源管理大师啊。

五、异常类型与标准异常#

4fd4193d598222519c0c4e11945db3a7d6a81b35

再往下展开常用的子类:

类别子类触发场景
逻辑错误(程序 bug)std::invalid_argument参数非法(如 stoi 传非数字)
std::out_of_range越界(vector::at、string::at)
std::length_error长度超限(string 过长)
运行期错误(环境/输入)std::runtime_error通用运行期错误
std::overflow_error算术溢出
std::underflow_error算术下溢
std::system_error系统调用错误(含 errno 包装)
分配/转换std::bad_allocnew 分配失败
std::bad_castdynamic_cast 失败
std::bad_variant_accessvariant 取错类型

关键区分:logic_error 表示”代码写错了”(参数非法、越界——本可以避免),runtime_error 表示”运行环境出了问题”(文件打不开、网络断了——写代码时无法预防)。这个区分对错误处理策略有指导意义:logic_error 通常在开发期暴露就该修,runtime_error 需要运行期容错。

what():返回错误信息#

所有标准异常都有 what(),返回 const char* 的错误描述:

#include <stdexcept>
#include <iostream>
try {
throw std::runtime_error("磁盘写入失败: /dev/sda");
} catch (const std::exception& e) {
std::cout << "错误: " << e.what() << "\n";
}

what() 返回的指针只在异常对象存活期间有效,别把 what() 的结果长期保存(拷贝成 std::string 才安全)。

throw啥都能抛#

throw 可以抛任何东西——throw 42;、throw "error"; 都能编译:

throw 42; // 能编译,但 catch 要写 catch(int)
throw "something"; // 抛字符串字面量,catch 要写 catch(const char*)

为什么不推荐抛非异常类型?

  1. 无法携带标准信息:没有 .what(),信息全靠自己约定
  2. catch 类型难写:调用者得记着”这个函数会抛 int”,心智负担大
  3. 和标准库不统一:标准库都抛异常对象,你抛别的,兜底 catch (const std::exception&) 接不住

惯例是抛标准异常类或自定义异常类。throw 后面的表达式其实还有讲究——C++11 起 throw e; 在传播时会调用移动或拷贝构造(把异常对象”复制”到异常存储区),所以抛局部对象是安全的(不会悬垂),但会多一次拷贝/移动,抛小对象无感。

自定义异常#

继承相关的异常类即可

#include <stdexcept>
#include <string>
// 推荐:继承 runtime_error,复用 what() 的机制
class ConfigError : public std::runtime_error {
public:
explicit ConfigError(const std::string& file, const std::string& msg)
: std::runtime_error("配置文件 " + file + ": " + msg),
file_(file) {}
const std::string& file() const { return file_; }
private:
std::string file_;
};
// 使用
try {
// 某处:throw ConfigError("app.conf", "端口号非法");
} catch (const ConfigError& e) {
std::cerr << e.what() << "(文件: " << e.file() << ")\n";
} catch (const std::exception& e) {
std::cerr << "其它错误: " << e.what() << "\n";
}

自定义异常的设计要点:

  1. 继承 std::runtime_error(运行期错误)或 std::logic_error(逻辑错误),别直接继承 std::exception——exception 没有接受 string 的构造函数,你得自己存消息
  2. 构造函数里把消息拼好传给基类,what() 直接可用
  3. 需要的额外数据(文件名、错误码)做成成员 + getter
  4. 保持”小而专”:一个异常类对应一类错误,别搞一个万能异常装所有情况

总结:标准异常以 std::exception 为根分两大枝——logic_error(代码 bug)与 runtime_error(环境问题),按 const std::exception& 捕获可统一处理、what() 取描述;业务需要时继承 runtime_error 自定义异常并携带上下文字段;牢记”别抛裸类型、异常不穿 C 边界”两条底线。

六、栈展开与RAll#

“栈展开(stack unwinding)“指的是:异常抛出后,控制权沿调用链向上跳,每退出一层函数,就把这一层栈帧上的局部对象全部销毁。

#include <stdexcept>
struct Guard {
int id;
~Guard() { std::printf("析构 #%d\n", id); }
};
void c() { throw std::runtime_error("boom"); }
void b() {
Guard g(2);
c(); // 抛异常
std::printf("b 的收尾\n"); // 不会执行
}
void a() {
Guard g(1);
b();
}
int main() {
try {
a();
} catch (const std::exception& e) {
std::printf("捕获: %s\n", e.what());
}
}
输出顺序:
析构 #2 (退出 b 的栈帧,销毁 g(2))
析构 #1 (退出 a 的栈帧,销毁 g(1))
捕获: boom (回到 main 的 catch)

注意 b 里 c() 之后的 printf 根本没执行——异常一跳,b 的剩余代码全被跳过,但 b 的局部对象 g(2) 照常析构。这就是”展开”的含义:像剥洋葱一样,一层层退出调用栈,每层栈帧的局部对象依次析构,直到找到 catch。

必然执行#

栈展开的两条硬规则:

  1. 析构顺序 = 构造逆序:同一作用域内后构造的先析构(和正常离开作用域一样)
  2. 析构必然执行:只要对象在该栈帧上(且还没析构完),展开路径上一定会析构它

第二条就是 RAII 的基石。回想 42 篇的话:无论正常 return、中间 return、还是抛异常,局部对象都会析构。栈展开把”异常”这个最容易让人忘记清理的路径,也变得和正常路径一样可靠。

一个容易忽略的细节:异常抛出的那一刻,还没构造完的”半成品”怎么办? C++ 保证:构造函数抛异常时,已经构造好的成员会被逐个析构,但对象本身的析构函数不会执行(因为对象没构造完)。这条规则防止了”构造一半的对象被错误地完整析构”。

雷区:析构函数不能抛异常#

栈展开期间正在析构对象,如果这个析构函数自己又抛异常——两个异常同时活跃,C++ 直接调用 std::terminate 终止程序。

struct Bad {
~Bad() {
throw std::runtime_error("析构里抛异常"); // 危险!
}
};

如果 Bad 的析构在栈展开过程中执行(因为别的异常),又抛一个异常,程序直接崩。所以工程铁律:析构函数必须 noexcept(默认就是),有错也吞掉或记录,绝不抛出。

异常安全#

厉不厉害你RAll+栈展开

#include <mutex>
#include <vector>
#include <stdexcept>
void process_all(std::mutex& mtx, const std::vector<int>& data) {
std::lock_guard<std::mutex> lock(mtx); // 上锁(RAII)
for (int x : data) {
if (x < 0) {
throw std::runtime_error("负数"); // 异常!
}
// 处理 x……
}
} // 无论异常还是正常结束:lock_guard 析构 → 自动解锁

throw 之后栈展开,lock_guard 的析构自动 unlock()——锁不会因为异常而卡死。这比手写 lock()/unlock() 强在哪?手写版本遇到异常直接跳过 unlock(),锁永远不释放,其它线程全部阻塞——比崩溃还难排查的 bug。RAII 把这条路径也堵死了。

5d90416c2f5a96bfd272985d0abdad85e96b23f1

异常安全等级#

由栈展开 + RAII 引出一个重要概念——异常安全保证的等级。函数面对异常时能做到什么程度:

级别含义例子
不保证异常后对象可能处于任意状态手写裸指针管理
基本保证对象状态合法但不明确普通容器操作
强保证操作要么成功要么回滚std::vector 的 push_back(拷贝版本)
no-throw绝不抛异常析构、swap、move

工程目标通常是”基本保证”起步,关键路径追求”强保证”(如容器插入用拷贝构造保证回滚)。

七、noexcept与异常规范#

noexcept 是 C++11 起的异常说明符,放在函数声明末尾,承诺”这个函数不会抛出异常”:

void swap_ints(int& a, int& b) noexcept {
int t = a;
a = b;
b = t; // 纯整型操作,不可能抛异常
}

如果标了 noexcept 的函数实际抛了异常呢?程序不会”优雅地”传播这个异常——而是直接调用 std::terminate 终止。所以 noexcept 是双刃剑:它既是给编译器的承诺,也是给自己立下的规矩——“这里绝不能抛,抛了就是程序级错误”。

胖狗ps:牛皮,对赌是吧?

noexcept 还有带条件的版本:noexcept(表达式),当表达式为 true 时承诺不抛。最常见的使用是”转发类型特性”:

template <typename T>
void f(T& x) noexcept(noexcept(x.swap(x))) {
x.swap(x); // 只有 swap 也不抛时,f 才承诺不抛
}

默认与例外#

一个容易混淆的点:哪些函数默认 noexcept?

函数默认 noexcept?
析构函数是(C++11 起默认 noexcept)
普通成员/自由函数否
默认构造/拷贝构造/赋值条件性(依赖成员是否 noexcept)
移动构造/移动赋值条件性(依赖成员)
struct X {
~X() {} // 默认 noexcept(true)
void f(); // 默认 noexcept(false)(可以抛)
void g() noexcept; // 显式承诺不抛
};

为撒移动和swap要noexcept#

这是工程里最重要的一条惯例,和 std::vector 的扩容行为直接相关。

看 std::vector 扩容时发生了什么:容量不够就重新分配内存,把旧元素”搬”到新内存。搬的时候用移动构造还是拷贝构造,取决于移动构造是否 noexcept:

  • 如果移动构造标了 noexcept:扩容直接用移动(快,只是把指针/内部状态换过去)
  • 如果移动构造可能抛异常:扩容必须退回拷贝(因为移动到一半抛异常,旧数据已经乱了,无法回滚)

17efb38fb44e6aa03fb2278fc4d264c5a83c08e0

总结就是用安全换性能。

所以自定义类如果要放进 std::vector 且成员移动很快(指针、整数、string 之类),移动构造/移动赋值/swap 都标 noexcept 是标准做法——它直接影响容器扩容的性能。标准库自己的容器、std::string 的移动操作全都是 noexcept 的。

noexcept对性能的影响#

noexcept 对性能的影响有两层:

1. 优化层面(间接):编译器知道函数不抛异常,就能省掉”异常处理边界的登记”(unwind tables、异常元数据),生成更干净的代码。这个收益在小型、高频函数上最明显。但不是魔术——-O2 下编译器自己也能推断一部分”显然不抛”的函数,noexcept 是显式告诉它。

2. 语义层面(关键):如上节所述,标准库的容器操作会看移动/swap 是否 noexcept 来选策略。这不是性能优化,是行为选择——标了,容器敢用移动;没标,容器保守用拷贝。这个影响远比”寄存器优化”大。

顺带一提:std::vector 的 shrink_to_fit、std::swap 泛型版本等,标准库都会利用 noexcept 信息。所以 noexcept 是”写给别人看的承诺”,别人(标准库、你的队友)依据它做决策。

啥时候该标#

该标:

  • 析构函数(默认已是,别画蛇添足写 noexcept,写了也一样)
  • 移动构造/移动赋值(成员都可安全移动时)
  • swap(大多数情况)
  • 纯查询/纯计算函数(只读成员、纯算法)
  • 给 std::vector 用的自定义类型的移动操作

不该标:

  • 可能分配内存的函数(new/容器操作可能抛 bad_alloc)
  • 可能抛业务异常的函数
  • 调用了”不承诺 noexcept”的函数(除非用 try/catch 包住)

别拿 noexcept 当”性能开关”乱贴——标错了(实际会抛),异常发生时不是”优雅传播”而是 terminate 崩掉。noexcept 是契约,不是装饰。

总结:noexcept 声明”此函数不抛异常”,违反会直接 std::terminate;析构默认 noexcept,移动构造/移动赋值/swap 标 noexcept 是工程惯例——它不只关乎优化,更决定 std::vector 扩容敢不敢用移动,是”写给标准库和队友的承诺”。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

CPP重学(五)
https://fatdog.20060113.xyz/posts/cpp-again5/
作者
神秘大胖狗
发布于
2026-10-25
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00