使用说明
mcpp-standard是一个强调动手实践的现代C++核心语言特性教程项目。基于xlings(d2x)工具搭建了一套编译器驱动开发模式的代码练习, 可以自动化的检测练习代码的状态和跳转到下一个练习...
0.xlings工具安装
xlings包含教程项目所需的工具 - 更多工具细节
Linux
curl -fsSL https://d2learn.org/xlings-install.sh | bash
or
wget https://d2learn.org/xlings-install.sh -O - | bash
Windows - PowerShell
Invoke-Expression (Invoke-Webrequest 'https://d2learn.org/xlings-install.ps1.txt' -UseBasicParsing).Content
1.获取项目及自动配置环境
下载项目到当前目录并自动配置本地环境
xlings install d2x:mcpp-standard
本地电子书
可以在项目目录执行
d2x book
命令, 打开本地文档(包含使用说明和电子书)
d2x book
练习代码自动检测
进入项目目录
mcpp-standard
运行checker命令, 进入练习代码自动检测程序
xlings checker
指定练习进行检测
xlings checker [name]
注: 练习名支持模糊匹配
同步最新的练习代码
由于项目处于持续更新阶段, 可以使用下面的命令进行自动同步(如果同步失败, 可能需要手动用git进行更新项目代码)
d2x update
2.自动化检测程序简介
使用xlings checker
进入自动化代码练习环境后, 工具会自动定位打开对应的练习代码文件, 并在控制台输出提示编译器的错误及提示信息。一般检测程序分两个检测阶段: 第一个是编译期检测, 即你需要通过练习代码中的提示信息和控制台编译器的报错, 修复代码的编译错误; 第二个是运行时检测, 即当前代码运行时是否能通过所有检查点。当修复编译错误并通过所有检查点时, 控制台就会显示当前练习通过并提示你进入下一个练习
代码练习文件示例
// mcpp-standard: https://github.com/Sunrisepeak/mcpp-standard
// license: Apache-2.0
// file: dslings/hello-mcpp.cpp
//
// Exercise/练习: 自动化代码练习使用教学
//
// Tips/提示:
// 该项目是使用xlings工具搭建的自动化代码练习项目, 通过在项目根目录下
// 执行 xlings checker 进入"编译器驱动开发模式"的练习代码自动检测.
// 你需要根据控制台的报错和提示信息, 修改代码中的错误. 当修复所有编译错误和
// 运行时检查点后, 你可以删除或注释掉代码中的 D2X_WAIT 宏, 会自动进入下一个练习.
//
// - D2X_WAIT: 该宏用于隔离不同练习, 你可以删除或注释掉该宏, 进入下一个练习.
// - d2x_assert_eq: 该宏用于运行时检查点, 你需要修复代码中的错误, 使得所有
// - D2X_YOUR_ANSWER: 该宏用于提示你需要修改的代码, 一般用于代码填空(即用正确的代码替换这个宏)
//
// Auto-Checker/自动检测命令:
//
// d2x checker hello-mcpp
//
#include <d2x/common.hpp>
// 修改代码时可以观察到控制台"实时"的变化
int main() {
std::cout << "hello, mcpp!" << std:endl; // 0.修复这个编译错误
int a = 1.1; // 1.修复这个运行时错误, 修改int为double, 通过检查
d2x_assert_eq(a, 1.1); // 2.运行时检查点, 需要修复代码通过所有检查点(不能直接删除检查点代码)
D2X_YOUR_ANSWER b = a; // 3.修复这个编译错误, 给b一个合适的类型
d2x_assert_eq(b, 1); // 4.运行时检查点2
D2X_WAIT // 5.删除或注释掉这个宏, 进入下一个练习(项目正式代码练习)
return 0;
}
控制台输出及解释
🌏Progress: [>----------] 0/10 -->> 显示当前的练习进度
[Target: 00-0-hello-mcpp] - normal -->> 当前的练习名
❌ Error: Compilation/Running failed for dslings/hello-mcpp.cpp -->> 显示检测状态
The code exist some error!
---------C-Output--------- - 编译器输出信息
[HONLY LOGW]: main: dslings/hello-mcpp.cpp:24 - ❌ | a == 1.1 (1 == 1.100000) -->> 错误提示及位置(24行)
[HONLY LOGW]: main: dslings/hello-mcpp.cpp:26 - 🥳 Delete the D2X_WAIT to continue...
AI-Tips-Config: https://d2learn.org/docs/xlings -->> AI提示(需要配置大模型的key, 可不使用)
---------E-Files---------
dslings/hello-mcpp.cpp -->> 当前检测的文件
-------------------------
Homepage: https://github.com/d2learn/xlings
3.资源于交流
交流群(Q): 167535744
教程讨论版块: https://forum.d2learn.org/category/20
xlings: https://github.com/d2learn/xlings
教程仓库: https://github.com/Sunrisepeak/mcpp-standard
教程视频合集: https://space.bilibili.com/65858958/lists/5208246
类型自动推导 - auto和decltype
auto 和 decltype 是C++11引入的强有力的类型自动推导工具. 不仅让代码变的更加简洁, 还增强了模板和泛型的表达能力
Book | Video | Code | X |
---|---|---|---|
cppreference-auto / cppreference-decltype / markdown | 视频解读 | 练习代码 |
为什么引入?
- 解决类型声明过于复杂的问题
- 模板应用中, 获取对象或表达式类型的需求
- 为lambda表达式的定义做支撑
auto和decltype有什么区别?
- auto常常用于变量定义, 推导的类型可能丢失const或引用(可显示指定进行保留auto &)
- decltype获取表达式的精确类型
- auto通常无法作为模板类型参数使用
一、基础用法和场景
声明定义
充当类型站位符, 辅助变量的定义或声明。使用auto时变量必须要初始化, decltype可以不用初始化
int b = 2;
auto b1 = b;
decltype(b) b2 = b;
decltype(b) b3; // 可以不用初始化
表达式类型推导
常常用于复杂表达式的类型推导, 确保计算精度
int a = 1;
auto b1 = a + 2;
decltype(a + 2 + 1.1) b2 = a + 2 + 1.1;
auto c1 = a + '0';
decltype(2 + 'a') c2 = 2 + 'a';
复杂类型推导
迭代器类型推导
std::vector<int> v = {1, 2, 3};
auto it = v.begin(); // 自动推导it类型
// decltype(v.begin()) it = v.begin();
for (; it != v.end(); ++it) {
std::cout << *it << " ";
}
函数类型推导
对于函数或lambda表达式这种复杂类型, 常常使用auto和decltype. 一般, lambda定义用auto, 模板类型参数用decltype
int add_func(int a, int b) {
return a + b;
}
int main() {
auto minus_func = [](int a, int b) { return a - b; };
std::vector<std::function<decltype(add_func)>> funcVec = {
add_func,
minus_func
};
funcVec[0](1, 2);
funcVec[1](1, 2);
//...
}
函数返回值类型推导
语法糖用法
auto为后置返回类型函数定义写法做支持, 并可以配合decltype进行返回类型推导使用
auto main() -> int {
return 0;
}
auto add(int a, double b) -> decltype(a + b) {
return a + b;
}
函数模板返回值类型推导
当无法确定模板返回值时可以用auto + decltype做推导, 可以让add支持一般类型int, double,... 和 复杂类型 Point, Vec,... 增强泛型的表达能力. (c++14中可以省略decltype)
template<typename T1, typename T2>
auto add(T1 a, T2 b) -> decltype(a + b) {
return a + b;
}
类/结构体成员类型推导
struct Object {
const int a;
double b;
Object() : a(1), b(2.0) { }
};
int main() {
const Object obj;
auto a = obj.a;
std::vector<decltype(obj.b)> vec;
}
二、注意事项 - 括号带来的影响
decltype(obj) 和 decltype( (obj) )的区别
- 一般
decltype(obj)
获取的时其声明类型 - 而
decltype( (obj) )
获取的是(obj)
这个表达式的类型(左值表达式)
int a = 1;
decltype(a) b; // 推导结果为a的声明类型int
decltype( (a) ) c; // 推导结果为(a)这个左值表达式的类型 int &
decltype(obj.b) 和 decltype( (obj.b) )的区别
decltype( (obj.b) )
: 从表达式视角做类型推导, obj定义类型会影响推导结果. 例如, 如果obj被const修饰时, const会限定obj.b的访问为constdecltype(obj.b)
: 由于推导的是成员声明类型, 所以不会受obj定义的影响
struct Object {
const int a;
double b;
Object() : a(1), b(2.0) { }
};
int main() {
Object obj;
const Object obj1;
decltype(obj.b) // double
decltype(obj1.b) // double
decltype( (obj.b) ) // double &
decltype( (obj1.b) ) // 受obj1定义的const修饰影响, 所以是 const double &
}
右值引用变量, 在表达式中是左值
int &&b = 1;
decltype(b) // 推导结果是声明类型 int &&
decltype( (b) ) // 推导结果是 int &
三、其他
...
列表初始化
列表初始是一种用{ arg1, arg2, ... }
列表(大括号), 初始化对象的一种初始化风格, 并且可以用于几乎所有的对象初始化场景, 所以也常常称他为统一初始化。此外, 他还增加了列表成员的类型检查功能, 防止一些窄化问题
Book | Video | Code | X |
---|---|---|---|
cppreference / markdown | 视频解读 | 练习代码 |
为什么引入?
- 解决初始化语法风格不统一问题
- 禁止隐式转换造成的窄化问题
- 方便容器类型的初始化
- 解决默认初始化语法陷阱
一、基础用法和场景
统一初始化风格
c++11之前不同场景有不同的初始化的方式
int a = 5; // 拷贝初始化
int b(5); // 直接初始化
int arr[3] = {1, 2, 3}; // 数组初始化
Object obj1; // 默认构造
Object obj2(obj1); // 拷贝构造
他们可以用{ }
进行风格统一
int a = { 5 }; // 拷贝初始化
int b { 5 }; // 直接初始化
int arr[3] = {1, 2, 3}; // 数组初始化
Object obj1 { }; // 默认初始化
Object obj2 { obj1 }; // 拷贝构造
避免隐式类型转换和窄化问题
一般传统的初始化方法, 是默认C语言隐式类型转换规则风格. 例如, 用double
类型初始化int
类型变量的时候会自动丢掉小数位. 而列表初始化会增加额外的编译期类型检查来避免隐式类型转换和精度丢失问题. 在现代C++中, 除非有意的需要这种隐式类型转换, 大多数时候使用列表初始化是更好的选择
int a = 3.3; // ok
int a = { 3.3 }; // error
constexpr double b { 3.3 }; // ok
int c(b); // ok -> 3
int c { b }; // error: 类型不匹配
数组初始化中的窄化检查
int arr[] { 1, 2, 3.3, 4 }; // error: 3.3会发生窄化
int arr[] = { 1, 2, b, 4 }; // error: b会发生窄化
注: 如果b是运行时变量, 编译期可能只会触发窄化警告而不会报错
提高容器初始化的简洁性
对于容器类型的初始化, 老C++中常常会分成两个步骤。第一步, 创建一个元素数组; 第二步, 使用这个数组来初始化容器
int arr[5] = {1, 2, 3, 4, 5};
std::vector<int> v(arr, arr + sizeof(arr) / sizeof(int));
而列表初始化的引入, 能让我们把两步合为一个步骤, 大幅度提高了容器初始化的简洁性
std::vector<int> v1 {1, 2, 3};
std::vector<int> v2 {1, 2, 3, 4, 3};
并且, 可以通过std::initializer_list
让我们的自定义类型也能支持这种不定长的列表初始化方式
class MyVector {
public:
MyVector() = default;
MyVector(std::initializer_list<int> list) {
for (auto it = list.begin(); it != list.end(); it++) {
// *it ...
}
}
};
MyVector v1 {1, 2, 3};
MyVector v2 {1, 2, 3, 4, 3};
避免初始化语法陷阱
使用{ }
调用默认构造函数, 避免语法陷阱
#include <iostream>
struct Object {
Object() { std::cout << "Constructor called!" << std::endl; }
};
int main() {
Object obj1 { };
Object obj2(); // obj2是函数, 而不是Object对象
}
二、注意事项
数组类型列表初始化
数组类型的定义里面的值一般是不确定的, 但是列表初始化的方式会做默认值的初始化, 并支持自动补0
普通数组
int arr[4]; // arr[0] 不确定
int arr[4] { }; // arr[0] = 0
int arr[4] { 1, 2 }; // arr[2] / arr[3] 会自动补成0
数组容器
std::array<int, 4> arr; // arr[0] 不确定/可能是随机值
std::array<int, 4> arr { }; // arr[0] == 0
std::array<int, 4> arr { 1, 2 }; // arr[0] == 1, arr[2] 会自动补成0
成员初始化问题
列表初始化支持直接对 聚合类型的成员做初始化, 但需要注意添加构造函数后必须要匹配构造函数才可以
struct Point {
int x, y;
// Point(int x, int y) { ... }
};
Point { 1, 2 };
Point p1 { 2, 3 }; // p1 { x: 2, y: 3}
优先匹配std::initializer_list
的构造函数
class MyVector {
public:
MyVector() = default;
MyVector(int x, int y) { }
MyVector(std::initializer_list<int> list) {
for (auto it = list.begin(); it != list.end(); it++) {
// *it ...
}
}
};
MyVector v1 { 1, 2 }; // 会优先匹配 MyVector(std::initializer_list<int> list)
MyVector v1(1, 2); // 匹配MyVector(int x, int y)
三、其他
委托构造函数
委托构造是C++11中引入的语法糖, 通过简单的语法, 可以在不影响性能的情况下, 来避免过多重复代码的编写, 实现构造逻辑复用
Book | Video | Code | X |
---|---|---|---|
cppreference / markdown | 视频解读 | 练习代码 |
为什么引入?
- 构造函数重载中, 避免重复代码的编写
- 方便代码的维护
一、基础用法和场景
复用构造逻辑
当一个类需要编写重载的构造函数时, 很容易造成大量的重复代码, 例如:
class Account {
string id;
string name;
string coin;
public:
Account(string id_) {
id = id_;
name = "momo";
coin = "0元";
}
Account(string id_, string name_) {
id = id_;
name = name_;
coin = "0元";
}
Account(string id_, string name_, int coin_) {
id = id_;
name = name_;
coin = std::to_string(coin_) + "元";
}
};
这里3个构造函数中的初始化代码, 很明显是重复了(实际的初始化可能要更复杂)。 有了委托构造的支持后, 通过在构造函数成员初始化列表的位置以: Account(xxx)
的形式来委托其他更加完整实现的构造函数进行构造, 这样就可以只保留一份代码
class Account {
string id;
string name;
string coin;
public:
Account(string id_) : Account(id_, "momo") { }
Account(string id_, string name_) : Account(id_, name_, 0) { }
Account(string id_, string name_, int coin_) {
id = id_;
name = name_;
coin = std::to_string(coin_) + "元";
}
};
上面的两个构造函数, 通过委托构造的方式, 最后都会转发到Account(string id_, string name_, int coin_)
为什么更方便维护?
可以假设, 如果上面货币的单位或名称需要修改时, 重复的代码实现不仅没有遵循复用原则, 而且修改构造逻辑时也要重复多次的修改, 提高了维护成本
而通过委托构造的方式, 把构造逻辑放到了一个地方, 这样修改和维护时也变的更加方便
例如, 我们需要把元
改成原石
时, 只要修改一次即可
class Account {
// ...
Account(string id_, string name_, int coin_) {
//...
//coin = std::to_string(coin_) + "元";
coin = std::to_string(coin_) + "原石";
}
};
和封装成一个init函数的区别
一些朋友可能会想到, 如果把构造逻辑写成一个init
函数, 不就是也可以实现代码复用的效果吗? 为什么还要搞一个新的写法, 作为特性添加到标准中. 是不是有点多余并且让C++变的更加复杂了
class Account {
// ...
init(string id_, string name_, int coin_) {
id = id_;
name = name_;
coin = std::to_string(coin_) + "元";
}
public:
Account(string id_) { init(id_, "momo", 0); }
Account(string id_, string name_) { init(id_, name_, 0); }
Account(string id_, string name_, int coin_) {
init(id_, name_, coin_);
}
};
实际, 从性能角度考虑。大多数时候, 单独封装一个init
函数的性能是低于委托构造的。因为成员的构造, 一般会经历两个阶段:
- 第一步: 执行 默认初始化 或 成员初始化列表
- 第二步: 运行构造函数体中的构造逻辑
class Account {
// ...
public:
Account(string id_, string name_, int coin_)
/* : 1 - 成员初始化列表 */
{
// 2 - 执行构造函数的函数体
init(id_, name_, coin_);
}
};
这就导致使用init函数, 实际上成员被"初始化"了两次, 而委托构造可以通过成员初始化列表来避免这个问题
class Account {
// ...
public:
Account(string id_, string name_, int coin_)
: id { id_ }, name { name_ }, coin { std::to_string(coin_) + "元" }
{
// ...
}
};
二、注意事项
临时对象误会
在一些不使用委托构造的场景中, 一个构造函数体中调用另外一个构造函数, 他实际只是创建了一个临时对象
- 调用普通函数
init
: 初始化的是本对象的成员 - 调用另外一个构造函数: 在本对象外, 创建了一个新的临时对象
class Account {
// ...
public:
Account(string id_, string name_) {
Account(id_, name_, 0); // 创建的是临时对象
// init(id_, name_, 0);
// this->Account(id_, name_, 0); // error
}
Account(string id_, string name_, int coin_) {
id = id_;
name = name_;
coin = std::to_string(coin_) + "元";
}
};
不能重复初始化
当使用委托构造时, 就不能使用初始化列表去初始化其他成员, 这样的限制可以避免重复的初始化, 保证了数据成员只会被初始化一次
例如, 如果下面的语法被允许 coin
将会被初始化多次且可能会造成歧义
class Account {
// ...
public:
Account(string id_)
: Account(id_, "momo"), coin { "0元" } // error
{
}
};
三、其他
继承构造函数
继承构造函数是C++11 引入的一个语法特性 - 解决了在类继承结构中 派生类重复定义基类构造函数 的繁琐问题
Book | Video | Code | X |
---|---|---|---|
cppreference / markdown | 视频解读 | 练习代码 |
为什么引入?
- 减少重复代码, 避免手动转发
- 提高代码的表达能力
一、基础用法和场景
复用基类的构造函数
在继承构造函数这个特性引入之前, 即使基类和派生的构造函数形式没有任何区别, 也需要重新定义, 这不仅造成了一定程度的代码重复, 而且也不够简洁。例如, 下面的MyObject
就对每个Base
中的构造函数做了重新实现
class ObjectBase {
//...
public:
ObjectBase(int) {}
ObjectBase(double) {}
};
class MyObject : public ObjectBase {
public:
MyObject(int x) : ObjectBase(x) {}
MyObject(double y) : ObjectBase(y) {}
//...
};
而用这个特性, 可以通过using ObjectBase::ObjectBase;
直接继承基类中的构造函数, 避免这个手动转发的过程
class MyObject : public ObjectBase {
public:
using ObjectBase::ObjectBase;
//...
};
这里需要注意的是, 构造函数继承 的编译期隐式代码生成, 不仅仅是对构造函数的"单纯"复制, 而且在派生类中还有类似"自动重命名的效果 ObjectBase
-> MyObject
"。即:
class MyObject : public ObjectBase {
public:
// 可能的生成代码
MyObject(int x) : ObjectBase(x) {}
MyObject(double y) : ObjectBase(y) {}
};
临时扩展功能用于测试
对一些类型做测试或调试时, 我们常常期望可以使用像to_string()
之类的一些接口。如果在不方便直接修改源代码的情况下, 就可以使用 继承构造函数 的性质创建一个"具有一样接口"的新类型, 并追加一些方便调试的接口函数, 从而在有更方便的调试函数下实现间接测试。例如下面有个Student
类:
class Student {
protected:
//...
double score;
public:
string id;
string name;
uint age;
Student(string id, string name);
Student(string id, string name, uint age);
Student(string id, ...);
};
在对其测试时, 通过实现StudentTest
并增加一些辅助测试的函数, 这样更方便测试代码的编写。
class StudentTest : public Student {
public:
using Student::Student;
std::string to_string() const {
return "{ id: " + id + ", name: " + name
+ ", age: " + std::to_string(age) + " }";
}
void dump() const { /* 一些成绩细节 ... */ }
void assert_valid() const {
assert(score >= 0 && score <= 100);
// ...
}
};
其中需要注意的是, 在继承Student
的同时, 也继承了构造函数。所以, 他们具有相同的内部布局、构造方式及接口, 从而实现了:
- 保证了, 使用上的一致性和间接测试的有效性
- 不修改源码, 做了测试相关功能的扩展, 更方便代码的测试
- 相对外部为其编写测试函数, 能访问到被保护的数据成员(一般建议以只读方式访问)
其实, 对于很多 "不改变类数据结构的前提下, 来扩展只读行为或工具函数的很多场景", 继承构造函数都用发挥其作用
泛型装饰器和行为约束
继承构造函数不仅可以用于普通的继承中, 他还可以用于模板类型。例如, 下面定义的NoCopy
中, 使用了using T::T
对泛型T中的构造函数做继承。他的作用是在不改变目标对象的内存布局和使用接口下, 做一定的行为约束
template <typename T>
class NoCopy : public T {
public:
using T::T;
NoCopy(const NoCopy&) = delete;
NoCopy& operator=(const NoCopy&) = delete;
// ...
};
在一些模块或场景中, 我们期望再对象创想创建后, 不能再复制的方式创建其他对象时, 就可以在定义时使用这个NoCopy
装饰器/包装器, 通过包装器中的delete
显示告诉编译器删除了拷贝构造和拷贝赋值, 也意味着对象不在拥有拷贝语义。例如:
class Point {
double mX, mY;
public:
Point() : mX { 0 }, mY { 0 } { }
Point(double x, double y) : mX { x }, mY { y } { }
string to_string() const {
return "{ " + std::to_string(mX)
+ ", " + std::to_string(mY) + " }";
}
};
Point p1(1, 2);
NoCopy<Point> p2(2, 3);
这个时候p1
和p2
在接口的使用上都是一样的, 但是p2
相对p1
就少了可拷贝的属性
p1.to_string(); // ok
p2.to_string(); // ok
auto p3 = p1; // ok (拷贝构造)
auto p4 = p2; // error (不能拷贝)
二、注意事项
优先考虑继承还是组合
由于本章是介绍继承构造函数的特性和使用方式, 它是和继承性质绑定的。所以, 从实现上是倾向用继承的方式来实现的。 但是从于目标功能上考虑, 往往使用继承和组合都是可以实现的, 他们更偏向是手段而不是目的, 所以选择需要结合具体的应用场景。
例如, 对于一些测试环境, 或仅功能函数扩展, 无数据结构变动的场景下, 使用继承配合继承构造函数是比较方便的, 还可以避免大量的函数转发。但是, 对于一些 要对少量特定接口做"拦截"或较复杂的场景, 现在(2025)主流是更倾向用组合代替继承的
- 复杂场景或要加一个中间层做特殊处理 -> 一般组合优于继承
- 简单功能扩展, 且需保留接口使用的一致 -> 一般继承优于组合
三、练习代码
练习代码主题
练习代码自动检测命令
d2x checker inherited-constructors
四、其他
mcpp-standard更新日志
2025/08
C++11 - 11 - 继承造函数
C++11 - 10 - 委托构造函数
练习检测命令
d2x checker delegating-constructors
常见问题
更多问题和反馈 -> 教程论坛交流版块