目录
卢瑟们的作业展示。
提交 PR 不应当更改当前 README
,请将作业提交到 src\群友提交
中,比如你要提交第一个作业:
你应当在 src\群友提交\第01题
中创建一个自己的 .md
或 .cpp
文件,文件名以自己交流群ID命名(或 GitHub 用户名都可,方便找到本人即可)。
答题的一般要求如下(题目额外要求也自行注意看):
- 不更改
main
函数,不得使其不运行(意思别捞偏门)。 - 自行添加代码,在满足第一点的要求下,要能成功编译运行并与 给出运行结果一致。
日期:2023/7/21
出题人:mq白
给出代码:
int main(){
std::vector v{1, 2, 3};
std::function f {[](const int& i) {std::cout << i << ' '; } };
auto f2 = [](int& i) {i *= i; };
v | f2 | f;
}
1 4 9
- 难度:★☆☆☆☆
答题者:andyli
#include <algorithm>
#include <vector>
#include <functional>
#include <iostream>
template <typename R, typename F>
auto operator|(R&& r, F&& f) {
for (auto&& x: r)
f(x);
return r;
}
int main() {
std::vector v{1, 2, 3};
std::function f{[](const int& i) { std::cout << i << ' '; }};
auto f2 = [](int& i) { i *= i; };
v | f2 | f;
}
很常规,没啥问题。
答题者:mq松鼠
#include <iostream>
#include <vector>
#include <functional>
auto operator | (std::vector<int>&& v,std::function<void(const int&)> f){
for(auto&i:v){
f(i);
}
return v;
}
auto operator | (std::vector<int>& v,std::function<void(int&)> f){
for(auto&i:v){
f(i);
}
return v;
}
int main(){
std::vector v{1, 2, 3};
std::function f {[](const int& i) {std::cout << i << '\n'; } };
auto f2 = [](int& i) {i *= i; };
v | f2 | f;
}
评价:闲的没事多写个重载,裱起来。
template<typename U, typename F>
requires std::regular_invocable<F, U&>//可加可不加,不会就不加
std::vector<U>& operator|(std::vector<U>& v1, F f) {
for (auto& i : v1) {
f(i);
}
return v1;
}
不使用模板:
std::vector<int>& operator|(std::vector<int>& v1, const std::function<void(int&)>& f) {
for (auto& i : v1) {
f(i);
}
return v1;
}
不使用范围 for
,使用 C++20 简写函数模板:
std::vector<int>& operator|(auto& v1, const auto& f) {
std::ranges::for_each(v1, f);
return v1;
}
各种范式无非就是这些改来改去了,没必要再写。
很明显的是我们需要重载运算符 |,其次根据管道运算符的调用形式得知返回值为 std::vector&
,管道运算符实际上执行的是operator |(v:std::vector,f2:F){//实现细节}
,如果要对存储任意类型的vector都可应用此重载,我们不免需要使用模板。既然使用模板我可以使用c++20的requires表达式来约束模板参数。
template<typename U,typename F>
requires std::regular_invocable<F, U&> //我们可以认为对模板形参U,F满足std::regular_invocable的约束
如果没接触过约束表达式,没关系,下面将简要的介绍。
requires 表达式如同一个返回bool的函数,而U和F作为类型填入std::regular_invocable的实参列表里,只要作为类型的U,F满足该表达式则返回true;不满足则返回false,称为“不满足约束”。不满足约束的类型自然不会执行后续的代码。而std::regular_invocable我们可以简单看成对类型U的每一个值,我们是否可以调用函数F,即调用 std::invoke
。相当于我们在编译期对运行期做了想象,想象一下可以对U在运行期执行F吗?如果可以那满足约束。
而函数主体则极为简单
std::vector<U>& operator|(std::vector<U>& v1, const F f) {
for (auto& i : v1) {
f(i);
}
return v1;
}
其中范围表达式 for (auto& i : v1)
,如同for(auto i=v.begin();i=v.end();++i){f(*i)}
我们对vector(范围)中的每一个元素应用一次f函数。返回时照常返回v1。
如若不使用模板,则我们的形参列表得用std::functoin来接住我们使用的函数。对范围中的每个成员应用f不需要返回值且需要对范围中的元素进行修改,所以第二个形参为std::function<void(int&)>
,并且我们不需要对传进来的函数f进行修改与拷贝,所以加上const限定是个好习惯。
同样的我们可以不使用范围for而是更简单的std::ranges::for_each(v1, f);
即同上一样对范围v1内的每个元素,应用一次函数f。
对于使用模板的形式,我们可以使用c++20的简写函数模板;简而言之,在函数形参列表中auto占位符会为模板形参列表追加一个虚设的模板形参。最开始的模板形式可以写成
std::vector<int>& operator|(auto& v1, const auto& f)
它和原形式相同。
日期:2023/7/22
出题人:mq白
给出代码:
int main(){
std::cout << "乐 :{} *\n"_f(5);
std::cout << "乐 :{0} {0} *\n"_f(5);
std::cout << "乐 :{:b} *\n"_f(0b01010101);
std::cout << "{:*<10}"_f("卢瑟");
std::cout << '\n';
int n{};
std::cin >> n;
std::cout << "π:{:.{}f}\n"_f(std::numbers::pi_v<double>, n);
}
乐 :5 *
乐 :5 5 *
乐 :1010101 *
卢瑟******
6
π:3.141593
- 难度:★★☆☆☆
6
为输入,决定
答题者:andyli
#include <format>
#include <iostream>
#include <string_view>
#include <string>
namespace impl {
struct Helper {
const std::string_view s;
Helper(const char* s, std::size_t len): s(s, len) {}
template <typename... Args>
std::string operator()(Args&&... args) const {
return std::vformat(s, std::make_format_args(std::forward<Args>(args)...));
}
};
} // namespace impl
impl::Helper operator""_f(const char* s, std::size_t len) noexcept {
return {s, len};
}
int main() {
std::cout << "乐 :{} *\n"_f(5);
std::cout << "乐 :{0} {0} *\n"_f(5);
std::cout << "乐 :{:b} *\n"_f(0b01010101);
std::cout << "{:*<10}"_f("卢瑟");
std::cout << '\n';
int n{};
std::cin >> n;
std::cout << "π:{:.{}f}\n"_f(std::numbers::pi_v<double>, n);
}
constexpr auto operator""_f(const char* fmt, size_t) {
return[=]<typename... T>(T&&... Args) { return std::vformat(fmt, std::make_format_args(std::forward<T>(Args)...)); };
}
同样的我们需要对运算符进行重载,""_f
正是用户自定义字符串后缀,但自定义字符串的重载的形参列表有一些限制,我们需要的是(const char *,std::size)
这样的形参列表,不过这是允许的;而重载函数的返回值我们需要独立创建为一个类型,并且需要在内部重载**()**运算符,以满足上述字符串像函数一样调用的要求。
/* 实现定义*/ operator""_f(const char* s, std::size_t len){return {s,len};}
我们通过引入一个自定义类型解决返回值问题,首先我们的自定义类型需要cosnt string_view
类型的数据成员用以保存cosnt char *
,其次string_view
的构造函数还需要一个长度参数,这就是为什么形参列表中需要传递长度;其次需要重载 () 运算符由于我们事先并不知道operotar()()
中形参的长度,我们可以使用c++20的模板形参包,借此我们可以传递变长参数:
template <typename... Args>
std::string operator()(Args&&... args)
如果形参包的相关语法不熟悉可参看形参包或视频。
而函数体内我们需要使用std::format来格式化字符串来把传递进来的参数塞进原字符串的 {} 中。std::format
提供了vformat
函数,它返回std::string
,用它来帮我们完成把值塞进 {} 的操作,当然同样提供了format
函数,不过我们不建议使用,他们的区别是带v的版本首个参数不要求编译期常量。
template <typename... Args>
std::string operator()(Args&&... args) const {
return std::vformat(s, std::make_format_args(std::forward<Args>(args)...));
}
在调用vformat
函数的实参列表中,我们并不能直接进行包展开传递参数,而是需要使用std::make_format_args()
构造vformat
的第二个参数,参看他的形参列表可知它第二个参数需要format_args
类型,即std::basic_format_args
;而std::make_format_args()
正是用来构造它的,在其中进行包展开时,因为函数实参在绑定到具体的值发生值类型的变换,我们需要完美转发形参包的参数。
以上是本题的一般思路,不过更进一步来说,我们可以把上述代码简化成一行泛型lambda表达式,因为lambda表达式自身就重载了函数调用运算符。
constexpr auto operator""_f(const char* fmt, size_t) {
return[=]<typename... T>(T&&... Args) { return std::vformat(fmt, std::make_format_args(std::forward<T>(Args)...)); };
}
<typename...T>
表示该lambda表达式的形参包,我们可以像平常使用函数形参包一样使用它,泛型lambda中为了使用重载字符串调用运算符的形参列表中的参数,我们需要复制捕获使用到的参数。
日期:2023/7/24
出题人:mq白
实现一个print
,如果你做了上一个作业,我相信这很简单。
要求调用形式为:
print(格式字符串,任意类型和个数的符合格式字符串要求的参数)
struct Frac {
int a, b;
};
给出自定义类型Frace
,要求支持
Frac f{ 1,10 };
print("{}", f);// 结果为1/10
1/10
- 难度:★★★☆☆
禁止面向结果编程,使用宏等等方式,最多B
(指评价),本作业主要考察和学习format
库罢了。
提示: std::formatter
提交代码最好是网上编译了三个平台的截图,如:
template<>
struct std::formatter<Frac>:std::formatter<char>{
auto format(const auto& frac, auto& ctx)const{//const修饰是必须的
return std::format_to(ctx.out(), "{}/{}", frac.a, frac.b);
}
};
void print(std::string_view fmt,auto&&...args){
std::cout << std::vformat(fmt, std::make_format_args(std::forward<decltype(args)>(args)...));
}
我们只是非常简单的支持了题目要求的形式,给 std::formatter
进行特化,如果要支持比如那些 {:6}
之类的格式化的话,显然不行,这涉及到更多的操作。
简单的特化以及 std::formatter
支持的形式可以参见文档。
一些复杂的特化,up之前也有写过,在 Cookbook
中,里面有对 std::ranges::range
和 std::tuple
的特化,支持所有形式。
实现一个print很简单,我们只要按第二题的思路来,接一个std::string_view
对象,再接需要格式化,也就是往里塞的参数。同样的我们使用简写函数模板和形参包。
void print(std::string_view fmt,auto&&...args){
std::cout << std::vformat(fmt, std::make_format_args(std::forward<decltype(args)>(args)...));
}
此处我们没有显示声明模板形参,所以展开时不能使用以往的模板形参做完美转发的模板实参,但是根据形参包展开的规则。例
args...
展开成args1,args2,args3...
,而上式展开成
std::forward<decltype(args1)>(args1),
std::forward<decltype(args2)>(arsg2),
std::forward<decltype(args3)>(args3),...
这样我们对每个应用到的参数用decltype取他的类型再作为完美转发的模板参数。这样调用vformat
,返回string,可以使用cout直接输出。
而自定义类型,特化std::formatter;我们需要知道的是:想要自定义std::formatter模板特化需要提供两个函数,parse和format,parse 用来处理格式说明,并且设置相关的成员变量,相对于本题我们不需要如此麻烦的写此成员函数;我们选择继承std::formatter<char>
的parse函数,独立实现format函数。此处模板特化的语法,不了解请复习模板特化。
template<>
struct std::formatter<Frac>:std::formatter<char>{
auto format(const auto& frac, auto& ctx)const{//const修饰是必须的
return std::format_to(ctx.out(), "{}/{}", frac.a, frac.b);
}
};
我们同样使用auto作占位符的简写函数模板,对于format函数,首个参数为我们传递的自定义类,第二个参数(ctx)为我们要传递给std::format_to
输出迭代器的格式字符串。在函数体中我们直接返回std::format_to()
调用表达式的结果,此函数返回输出迭代器;返回值我们使用auto占位符进行返回值推导。在函数实参中,ctx.out
即为输出迭代器,第二个参数为可转换为std::string_view或std::wstring_view,而转换结果是常量表达式和 Args 的合法格式字符串。本题中我们填入我们需要的形式即{}/{}
我们想要两个参数塞到 { },就如我们使用printf(%d,x)
一样;最后两个参数为“需要塞进 { } 的值”,即要格式化的参数。
日期:2023/7/25
出题人:Maxy
#include<iostream>
class ComponentBase{
protected:
static inline size_t component_type_count = 0;
};
template<typename T>
class Component : public ComponentBase{
public:
//todo...
//使用任意方式更改当前模板类,使得对于任意类型X,若其继承自Component
//则X::component_type_id()会得到一个独一无二的size_t类型的id(对于不同的X类型返回的值应不同)
//要求:不能使用std::type_info(禁用typeid关键字),所有id从0开始连续。
};
class A : public Component<A>
{};
class B : public Component<B>
{};
class C : public Component<C>
{};
int main()
{
std::cout << A::component_type_id() << std::endl;
std::cout << B::component_type_id() << std::endl;
std::cout << B::component_type_id() << std::endl;
std::cout << A::component_type_id() << std::endl;
std::cout << A::component_type_id() << std::endl;
std::cout << C::component_type_id() << std::endl;
}
0
1
1
0
0
2
- 难度:★☆☆☆☆
提交应当给出多平台测试结果,如图:
template<typename T>
class Component : public ComponentBase{
public:
static size_t component_type_id(){
static size_t ID = component_type_count++;
return ID;
}
};
分析:
我们需要实现 Component
的静态成员函数 component_type_id
。这是从给出代码得知的:
class A : public Component<A>
{};
A::component_type_id()
题目要求是每一个自定义类类型(假设是X)继承 Component<X>
,调用 component_type_id()
返回的是自己独一无二的ID。其他的类型同理。
解决题目之前我们需要强调一个知识点:
C++ 的模板不是具体类型,实例化之后才是,模板类的静态成员或静态成员函数也不属于模板我们可以用一段代码来展示结论:
#include <iostream>
template<typename T>
struct Test{
inline static int n = 10;
};
int main(){
Test<int>::n = 1;
std::cout << Test<void>::n << '\n';//10
std::cout << Test<int>::n << '\n';//1
}
这段代码很轻易的就展示了静态数据成员属于模板实例化后的具体类型 Test<void>::n
和 Test<int>::n
不是相同的n,并且 Test<void>
和 Test<int>
也不是一种类型(静态成员函数同理)。
所以我们的解法利用的是:不同的类型实例化 Component
类模板,也是不同的静态成员函数,静态成员函数里面的局部静态数据成员在第一次调用的时候才会初始化,后面就不会。
日期:2023/7/29
出题人:Da'Inihlus
要求实现 scope_guard
类型 ( 即支持传入任意可调用类型 , 析构的时候同时调用 )。
#include <cstdio>
#include <cassert>
#include <stdexcept>
#include <iostream>
#include <functional>
struct X {
X() { puts("X()"); }
X(const X&) { puts("X(const X&)"); }
X(X&&) noexcept { puts("X(X&&)"); }
~X() { puts("~X()"); }
};
int main() {
{
// scope_guard的作用之一,是让各种C风格指针接口作为局部变量时也能得到RAII支持
// 这也是本题的基础要求
FILE * fp = nullptr;
try{
fp = fopen("test.txt","a");
auto guard = scope_guard([&] {
fclose(fp);
fp = nullptr;
});
throw std::runtime_error{"Test"};
} catch(std::exception & e){
puts(e.what());
}
assert(fp == nullptr);
}
puts("----------");
{
// 附加要求1,支持函数对象调用
struct Test {
void operator()(X* x) {
delete x;
}
} t;
auto x = new X{};
auto guard = scope_guard(t, x);
}
puts("----------");
{
// 附加要求2,支持成员函数和std::ref
auto x = new X{};
{
struct Test {
void f(X*& px) {
delete px;
px = nullptr;
}
} t;
auto guard = scope_guard{&Test::f, &t, std::ref(x)};
}
assert(x == nullptr);
}
}
Test
----------
X()
~X()
----------
X()
~X()
- 难度:★★★★☆(完全满足要求的情况下)
- 使用
std::function
并擦除类型
struct scope_guard {
std::function<void()>f;
template<typename Func, typename...Args> requires std::invocable<Func, std::unwrap_reference_t<Args>...>
scope_guard(Func&& func, Args&&...args) :f{ [func = std::forward<Func>(func), ...args = std::forward<Args>(args)]() mutable {
std::invoke(std::forward<std::decay_t<Func>>(func), std::unwrap_reference_t<Args>(std::forward<Args>(args))...);
} }{}
~scope_guard() { f(); }
scope_guard(const scope_guard&) = delete;
scope_guard& operator=(const scope_guard&) = delete;
};
- 使用
std::tuple
+std::apply
template<typename F, typename...Args>
requires requires(F f, Args...args) { std::invoke(f, args...); }
struct scope_guard {
F f;
std::tuple<Args...>values;
template<typename Fn, typename...Ts>
scope_guard(Fn&& func, Ts&&...args) :f{ std::forward<Fn>(func) }, values{ std::forward<Ts>(args)... } {}
~scope_guard() {
std::apply(f, values);
}
scope_guard(const scope_guard&) = delete;
};
template<typename F, typename...Args>//推导指引非常重要
scope_guard(F&&, Args&&...) -> scope_guard<std::decay_t<F>, std::decay_t<Args>...>;
日期:2023/8/2
出题人:mq白
#include <iostream>
#include <atomic>
int main() {
std::atomic<int> n = 6;
std::cout << n << '\n';
}
解释,为什么以上代码在 C++17 后可以通过编译, C++17 前不行?
- 难度:★★★☆☆
std::atomic<int> n = 6
中,由于 6
和 std::atomic<int>
不是同一类型(但是这里其实有一个用户定义转换序列,你可以简单的认为6
可以隐式转换)。
即调用转换构造函数:
constexpr atomic( T desired ) noexcept;
转换构造函数也会作为用户定义的转换序列中的一部分
6
会调用转换构造函数,构造出一个临时的atomic对象用来直接初始化 n
,即
std::atomic<int> n(std::atomic<int>(6))
在 C++17 之前 的版本,理所应当应该查找检测复制/移动 构造函数,满足要求才可以通过编译。但是:
atomic( const atomic& ) = delete;
实际上atomic的复制构造被删除(同时移动构造也被抑制生成了)。所以自然而然的不允许。
C++17 的改动是:复制消除变为强制要求。 纯右值表达式作为构造对象的参数,不会再调用移动构造,也不会去检测,而是原位构造。
说句题外话,C++17后纯右值不可能再调用移动构造。没有移动构造或者复制构造不影响使用同类型纯右值初始化对象,如
X x{X{}}
,即使移动/复制构造函数都被delete,也无所谓,code
。
日期:2023/8/6
出题人:mq白
给出代码:
struct MyException :std::exception {
const char* data{};
MyException(const char* s) :data(s) { puts("MyException()"); }
~MyException() { puts("~MyException()"); }
const char* what()const noexcept { return data; }
};
void f2() {
throw new MyException("new Exception异常....");
}
int main(){
f2();
}
灵感来源自 Java 人写 C++
在 main
函数中自行修改代码,接取 f2()
函数抛出的异常(try catch
)。
MyException()
new Exception异常....
~MyException()
- 难度: ★☆☆☆☆
某些IDE或者平台可能会将打印的异常信息标为红色放到第一行,即 new Exception异常.... 这句话也可能在第一行(一般终端运行不会,默认vs也无此功能)
int main() {
try {
f2();
}
catch (std::exception* e){
std::cerr << std::unique_ptr<std::exception>(e)->what() << '\n';
}
}
实际上本题是用来讽刺将 Java 的写法带入到其他语言中,也就是很经典的:Java 人写什么都是 Java。
只是看我们这道题,实际上你非要说 new
有什么不好,倒也没什么非常不行的地方,只是,没有理由自己多写一个 delete
表达式(或者包个智能指针)。
我希望不要有人开始幻想:
throw new MyException("new Exception异常....")
因为是throw
一个指针类型,所以按指针传递,效率更高。不要让我看到这种逆天想法。如果你想到这一点,那不妨思考一下,构造临时对象的开销,以及使用new
表达式?说实话挺无聊的问题,只是防止有人想到这些点,以及抬杠罢了。
日期:2023/8/12
出题人:mq白
给出代码:
template<class Ty,size_t size>
struct array {
Ty* begin() { return arr; };
Ty* end() { return arr + size; };
Ty arr[size];
};
int main() {
::array arr{1, 2, 3, 4, 5};
for (const auto& i : arr) {
std::cout << i << ' ';
}
}
要求自定义推导指引,不更改已给出代码,使得代码成功编译并满足运行结果。
提示:可参考
std::array
1 2 3 4 5
- 难度: ★★★☆☆
template<class Ty, class...Args>
requires (std::same_as<Ty, Args>&&...)//不会这个C++20约束以及里面折叠表达的用法也可以不用
array(Ty, Args...) -> array<Ty, 1 + sizeof...(Args)>;
本题的目的如你所见主要考察的是 C++17 用户定义类模板推导指引。但是我更多的其实我想表达的是:
定义类模板推导指引和构造函数没什么直接的关联;如题目所示,我们的 array
是一个聚合类型,压根没有显式的用户定义构造函数,没有显示的构造函数并不影响我们使用自定义的用户推导指引。
如此强调只是因为大部分人对此有很多错误认知
推导指引实际上是按照你构造器传入的东西,来进行的这种推导,我觉得我视频里说的已经很清楚了。
日期:2023/8/15
出题人:mq白
#include<iostream>
template<class T>
struct X {
void f()const { std::cout << "X\n"; }
};
void f() { std::cout << "全局\n"; }
template<class T>
struct Y : X<T> {
void t()const {
this->f();
}
void t2()const {
f();
}
};
int main() {
Y<void>y;
y.t();
y.t2();
}
给出以上代码,要求解释其运行结果。
X
全局
- 难度: ★★★☆☆
本问题堪称经典,在某著名template书籍也有提过(虽然它完全没有讲清楚)。 并且从浅薄的角度来说,本题也可以让你向其他人证明加
this
访问类成员,和不加,是有很多区别的。
提示:名字查找
根据我们的提示 名字查找,我看大家多少都还是能查到一些东西。我们慢慢说吧,首先名字查找分为:有限定 名字查找,无限定 名字查找。
有限定名字查找指?
出现在作用域解析操作符
::
右边的名字是限定名(参阅有限定的标识符)。 限定名可能代表的是:
- 类的成员(包括静态和非静态函数、类型和模板等)
- 命名空间的成员(包括其他的命名空间)
- 枚举项
如果 ::
左边为空,那么查找过程只会考虑全局命名空间作用域中作出(或通过 using 声明引入到全局命名空间中)的声明。
this->f();
那么显然,这个表达式 不是有限定名字查找,那么我们就去无限定名字查找中寻找答案。
我们找到模板定义:
对于在模板的定义中所使用的非待决名,当检查该模板的定义时将进行无限定的名字查找。在这个位置与声明之间的绑定并不会受到在实例化点可见的声明的影响。而对于在模板定义中所使用的待决名,它的查找会推迟到得知它的模板实参之时。此时,ADL 将同时在模板的定义语境和在模板的实例化语境中检查可见的具有外部连接的 (C++11 前)函数声明,而非 ADL 的查找只会检查在模板的定义语境中可见的具有外部连接的 (C++11 前)函数声明。(换句话说,在模板定义之后添加新的函数声明,除非通过 ADL 否则仍是不可见的。)如果在 ADL 查找所检查的命名空间中,在某个别的翻译单元中声明了一个具有外部连接的更好的匹配声明,或者如果当同样检查这些翻译单元时其查找会导致歧义,那么行为未定义。无论哪种情况,如果某个基类取决于某个模板形参,那么无限定名字查找不会检查它的作用域(在定义点和实例化点都不会)。
原文很长,但是看我们加粗的也足够了。如果你没有学习过待决名,那这又会引入一个问题了。
我们这里简单描述一下吧:
this->f()
是一个待决名,这个 this
依赖于模板 X
。
所以,我们的问题可以解决了吗?
this->f()
是待决名,所以它的查找会推迟到得知它模板实参之时(即知道父类是谁,可以访问父类)。f()
是非待决名,检查该模板的定义时将进行无限定的名字查找(不知道父类),按照正常的查看顺序,先类内(查找不到),然后全局(找到)。
补充:如果是
msvc
的某些早期版本,或者c++版本设置在c++20之前,会打印X
X
。这是因为msvc
不支持Two-phase name lookup
。 详细的可以看看文档。实测Microsoft Visual Studio 17.6.4
设置C++20
之前的版本都无法得到正确结果。
题目的要求非常简单,在很多其他语言里也经常提供这种东西(一般是反射)。
但是显而易见 C++
没有反射。
我们给出代码:
int main() {
struct X { std::string s{ " " }; }x;
struct Y { double a{}, b{}, c{}, d{}; }y;
std::cout << size<X>() << '\n';
std::cout << size<Y>() << '\n';
auto print = [](const auto& member) {
std::cout << member << ' ';
};
for_each_member(x, print);
for_each_member(y, print);
}
要求自行实现 for_each_member
以及 size
模板函数。
要求支持任意自定义类类型(聚合体)的数据成员遍历(聚合体中存储数组这种情况不需要处理,具体原因看最后的补充说明)。
这需要打表,那么我们的要求是支持聚合体拥有 0
到 4
个数据成员的遍历。
如果从来没有接触过,那这道题有相当的难度,可以等 标准答案 以及 视频。或者去网络上查找这方面的知识。
1
4
0 0 0 0
- 难度: ★★★★☆
第二个四星题目
提示:学习
分为 C++20
的写法和 C++17
的写法。
C++20
得益于 requires
表达式,可以简化不少地方。主要在于 获取聚合类型的成员个数,即 size
函数的实现方式的不同。
其他的,诸如用于在不求值语境的 init
辅助类,或者用于遍历的 for_each_member
函数,在 C++17
和 C++20
没什么区别。
我们统一先放标准答案的代码再一一讲解。
#include <iostream>
#include<type_traits>
struct init {
template <typename T>
operator T(); // 无定义 我们需要一个可以转换为任何类型的在以下特殊语境中使用的辅助类
};
template<unsigned I>
struct tag :tag<I - 1> {};//模板递归展开 继承 用来规定重载的匹配顺序 如果不这么写,匹配是无序的
template<>
struct tag<0> {};
template <typename T>//SFIANE
constexpr auto size_(tag<4>) -> decltype(T{ init{}, init{}, init{}, init{} }, 0u)
{
return 4u;
}
template <typename T>
constexpr auto size_(tag<3>) -> decltype(T{ init{}, init{}, init{} }, 0u)
{
return 3u;
}
template <typename T>
constexpr auto size_(tag<2>) -> decltype(T{ init{}, init{} }, 0u)
{
return 2u;
}
template <typename T>
constexpr auto size_(tag<1>) -> decltype(T{ init{} }, 0u)
{
return 1u;
}
template <typename T>
constexpr auto size_(tag<0>) -> decltype(T{}, 0u)
{
return 0u;
}
template <typename T>
constexpr size_t size() {
static_assert(std::is_aggregate_v<T>);//检测是否为聚合类型
return size_<T>(tag<4>{});//这里就是要求从tag<4>开始匹配,一直到tag<0>
}
template <typename T, typename F>
void for_each_member(T const& v, F&& f) {
static_assert(std::is_aggregate_v<T>);//检测是否为聚合类型
if constexpr (size<T>() == 4u) {//使用C++17的编译期if和结构化绑定来遍历
const auto& [m0, m1, m2, m3] = v;
f(m0); f(m1); f(m2); f(m3);
}
else if constexpr (size<T>() == 3u) {
const auto& [m0, m1, m2] = v;
f(m0); f(m1); f(m2);
}
else if constexpr (size<T>() == 2u) {
const auto& [m0, m1] = v;
f(m0); f(m1);
}
else if constexpr (size<T>() == 1u) {
const auto& [m0] = v;
f(m0);
}
}
int main() {
struct X { std::string s{ " " }; }x;
struct Y { double a{}, b{}, c{}, d{}; }y;
std::cout << size<X>() << '\n';
std::cout << size<Y>() << '\n';
auto print = [](const auto& member) {
std::cout << member << ' ';
};
for_each_member(x, print);
for_each_member(y, print);
}
我们稍微聊一下那个模板类 tag
的作用,它那里是一个模板递归继承,注释也写了,用来规定重载决议匹配顺序,那么原理是什么呢?
#include <iostream>
struct X{};
struct Y:X{};
struct G:Y{};
struct X2:X{};
void f(X) { puts("X"); }
void f(Y) { puts("Y"); }
void f2(X) { puts("X"); }
int main(){
f(G{});//G的父类是Y,重载决议优先选择f(Y)
f2(G{});//但是实际上使用X一样可以,当没有更匹配的重载,重载决议会选择到f2(X)
}
X
Y
我们继续看 tag
的代码
template<unsigned I>
struct tag : tag<I - 1> {};
template<>
struct tag<0> {};
假设我们实例化了 tag<4>
,那么相当于
template<4>
struct tag :tag<3> {};
然后递归,以此类推,最终到 tag<0>
,结束。
也就是说 tag<4>
继承自 tag<3>
,tag<2>
继承自 tag<1>
,tag<1>
继承自 tag<0>
。
然后看到 size_<T>(tag<4>{});
以及 size_
模板的几个重载。
结合我们之前说的,我们可以知道,这样就是约束匹配顺序从
template <typename T>
constexpr auto size_(tag<4>) -> decltype(T{ init{}, init{}, init{}, init{} }, 0u);
开始,一直到
template <typename T>
constexpr auto size_(tag<0>) -> decltype(T{}, 0u);
也就是从大到小,获取传入的聚合类型的成员个数。
#include <iostream>
#include <type_traits>
struct init {
template <typename T>
operator T(); // 无定义 我们需要一个可以转换为任何类型的在以下特殊语境中使用的辅助类
};
template <typename T>
consteval size_t size(auto&&...Args) {
if constexpr (!requires{T{ Args... }; }) {
return sizeof...(Args) - 1;
}
else {
return size<T>(Args..., init{});
}
}
template <typename T, typename F>
void for_each_member(T const& v, F&& f) {//和C++17的写法一毛一样
static_assert(std::is_aggregate_v<T>);//检测是否为聚合类型
if constexpr (size<T>() == 4u) {//使用C++17的编译期if和结构化绑定来遍历
const auto& [m0, m1, m2, m3] = v;
f(m0); f(m1); f(m2); f(m3);
}
else if constexpr (size<T>() == 3u) {
const auto& [m0, m1, m2] = v;
f(m0); f(m1); f(m2);
}
else if constexpr (size<T>() == 2u) {
const auto& [m0, m1] = v;
f(m0); f(m1);
}
else if constexpr (size<T>() == 1u) {
const auto& [m0] = v;
f(m0);
}
}
int main() {
struct X { std::string s{ " " }; }x;
struct Y { double a{}, b{}, c{}, d{}; }y;
std::cout << size<X>() << '\n';
std::cout << size<Y>() << '\n';
auto print = [](const auto& member) {
std::cout << member << ' ';
};
for_each_member(x, print);
for_each_member(y, print);
}
我们来描述一下size
模板函数的实现。
他的设计非常的巧妙,如你所见,它是一个递归函数,还是编译期的递归,使用到了 编译期
if
;并且这个函数是以consteval
修饰 ,是立即函数,即必须在编译期执行,产生编译时常量。
好,我们正式进入这个函数。首先看到函数前两行,模板和 C++20
简写模板,这是一个形参包,万能引用。typename T
的 T
是指代外部传入的,需要获取成员个数的聚合体类型。
template <typename T>
consteval size_t size(auto&&...Args)
然后是
if constexpr (!requires{T{ Args... }; })
这句话表示的是:传入的聚合体类型如果可以 T{ Args... }
这样构造,那就不进入分支。注意这个 !
。
我们先重复一下聚合体的匹配,假设有一个聚合类型 X
,如下所示:
struct X{
int a,b,c;
};
它支持几个参数的初始化呢?如下所示
X x0{}; //ok
X x1{1}; //ok
X x2{1,2}; //ok
X x3{1,2,3}; //ok
X x4{1,2,3,4}; //error
上面这段例子非常重要。显然,只要传入的参数类型符合要求,并且不超过其成员个数即可。
我们再给一段代码:
#include <iostream>
struct init {
template <typename T>
operator T(); // 无定义 我们需要一个可以转换为任何类型的在以下特殊语境中使用的辅助类
};
template<typename T>
consteval size_t size(auto&&...Args) {
if constexpr (!requires{T{ Args... }; }) {
return sizeof...(Args) - 1;
}
else {
return size<T>(Args..., init{});
}
}
struct X{
int a,b,c;
};
int main(){
std::cout << size<X>() << '\n';//3
}
我们一步一步带入解释,为什么会打印 3。
- 进入
size
函数,T
是X
类型,形参包Args
为空。 - 编译期
if
中,条件表达式等价于! requires{ X{}; }
。显然X{}
符合语法,requires
表达式会返回true
但是有!
,那就是false
。不进入这个分支。 - 进入
else
,直接相当于return size<X>(init{})
。 - 第二次 进入
size
函数,此时形参包Args
有一个参数init
。 - 编译期
if
中,条件表达式等价于! requires{ X{ init{} }; }
。显然X{ init{} }
符合语法。同2
返回false
,不进入分支。 - 进入
else
,直接相当于return size<X>(init{},init{})
。 - 第三次 进入
size
函数,此时形参包Args
有两个参数init
。 - 编译期
if
中,条件表达式等价于! requires{ X{ init{},init{} } }
。显然同2
5
返回false
,不进入分支。 - 进入
else
,直接相当于return size<X>(init{},init{},init{})
。 - 第四次 进入
size
函数,此时形参包Args
有三个参数init
。 - 编译期
if
中,条件表达式等价于! requires{ X{ init{},init{},init{} } }
,显然同2
5
8
返回false
,不进入分支。 - 进入
else
,直接相当于return size<X>(init{},init{},init{},init{})
。 - 第五次 进入
size
函数,此时形参包Args
有四个参数init
。(注意,重点要来了,X
类型只有三个成员) - 编译期
if
中,条件表达式等价于! requires{ X{ init{},init{},init{},init{} } }
,即X{ init{},init{},init{},init{} }
不符合语法(X
类型只有三个成员)。所以requires
表达式返回false
,然后因为!
,表达式结果为true
,进入分支。 return sizeof...(Args) - 1;
注意,我们说了,第五次进入的时候,形参包Args
已经有四个参数,所以sizeof...(Args)
会返回4
,再-1
,也就是3
。得到最终结果。
到此,我们介绍完了 C++20
写法的 获取聚合类型的 size
函数。
至于 for_each_member
没必要再介绍,很普通简单的分支逻辑而已,只不过是用了编译期的分支。
我们给出的 C++20
或 C++17
的 size
的实现是有问题的,简单的说,它没办法处理聚合类型存储数组的问题。 在题目开头我们也说了。
我们拿 boost::pfr
的行为作为参考,我们采用1.82.0版本。
参见 示例代码
日期:2023/8/20
出题人:jacky
思考:以下代码为什么在 C++20
以下的版本中无法成功编译,而在 C++20
及以后却可以?
#include <vector>
struct Pos {
int x;
int y;
};
int main(){
std::vector<Pos> vec;
vec.emplace_back(1, 5);
}
- 难度:★★☆☆☆
你在使用 gcc
并且设置标准在 C++20 之前,会得到 编译器的提示信息
error: new initializer expression list treated as compound expression [-fpermissive]
187 | { ::new((void *)__p) _Up(std::forward<_Args>(__args)...); }
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
实际上 std::vector<T>::emplace_back()
最终会使用类似如下的代码构造对象:
::new (static_cast<void*>(p)) T(std::forward<Args>(args)...)//也就是完美转发+布置new
实际上是因为在 C++20 中增加了 “括号形式的聚合初始化”(用词并不准确,当字面意思凑合理解即可)。按照文档的描述
如果目标类型是(可能有 cv 限定)的 聚合类,则 按聚合初始化 中所述进行初始化,但允许窄化转换,不允许指派初始化器,不延长引用所绑定到的临时量的生存期,不进行花括号消除,并值初始化任何无初始化器的元素。
我们不需要关注那么多,我们用一段代码来演示:
struct Pos {
int x;
int y;
};
int main(){
Pos p(0,0);//C++20起允许
}
所以说白了,就是 T(std::forward<Args>(args)...)
这里用的小括号进行初始化,直到C++20才允许聚合类型使用小括号初始化。
日期:2023/8/28
出题人:jacky
请实现函数 make_vector(...)
,使以下代码编译通过(C++20):
#include <cstdio>
#include <vector>
inline void dbg(const char* msg)
{
std::puts(msg);
std::fflush(stdout);
}
struct X {
X() noexcept
{
dbg("X()");
};
~X() noexcept
{
dbg("~X()");
};
X(const X&)
{
dbg("X(const X&)");
}
X(X&&) noexcept
{
dbg("X(X&&)");
}
};
void test()
{
static_assert(requires {
{
make_vector(std::vector{1, 2, 3})
} -> std::same_as<std::vector<std::vector<int>>>;
{
make_vector(1, 2, 3)
} -> std::same_as<std::vector<int>>;
make_vector(1, 2, 3).size() == 3;
});
X x1;
X x2;
auto vec = make_vector(x1, std::move(x2));
}
int main()
{
test();
dbg("test end");
}
X()
X()
X(const X&)
X(X&&)
X(const X&)
X(const X&)
~X()
~X()
~X()
~X()
~X()
~X()
test end
- 难度:★★★☆☆
答题者:yuzhiy
template<typename...Args>
auto make_vector(Args&&...args)->decltype(auto){
return std::vector({std::forward<Args>(args)...});
}
没啥问题,就是
auto
占位,后置返回类型decltype(auto)
没意义。
答题者:Matrix-A
auto make_vector(auto&&... args) {
std::vector<std::common_type_t<decltype(args)...>> temp;
(temp.emplace_back(std::forward<decltype(args)>(args)),...);
return temp;
}
用
emplace_back()
比直接往vector
的初始化器里面传入参数要少拷贝。可自行测试。
template<typename T,typename...Args>
constexpr auto make_vector(T&&t,Args&&...args) {
return std::vector<std::remove_cvref_t<T>>{ std::forward<T>(t), std::forward<Args>(args)... };
}
这种写法纯属为了解决下面解法提到的问题(即推导问题)
template<typename...Args>
constexpr auto make_vector(Args&&...args) {
return std::vector({ std::forward<Args>(args)... });
}
事实上大多数人看到这段代码会觉得很简单,就这么一行核心代码。实际上这里有很多坑,如果你没有自己写过不会注意到,即使你自己写过,大多人也并不明白。
即:为什么需要 std::vector({})
这种形式?如果我不这么做呢?只用 ()
或者 {}
呢?有什么替代方式吗?
我们如果去掉外面的 ()
进行编译,会发生一个编译错误,编译器提示我们是static_assert
中的 requires
表达式出错了,但是看不出是哪个错误。
我们去掉 requires
表达式中的:
{
make_vector(std::vector{1, 2, 3})
} -> std::same_as<std::vector<std::vector<int>>>;
这段代码,发现就可以通过编译了。那么问题很简单了,就是:
make_vector(std::vector{1, 2, 3})
这段代码无法得到 std::vector<std::vector<int>>
类型(前提是我们去掉了 ()
)。
那么这个问题的本质是什么?
//C++20
std::vector v{std::vector{1,2,3}}; // std::vector<int>
就这么简单,这就是这个问题表面上的本质,vector
再这种情况下也会得不到我们想要的类型。
我们接下来需要解释为什么 vector
会有这样的问题,并且,如果是:
std::vector v{std::vector{1},std::vector{2}}; //就能得到std::vector<std::vector<int>>
我们写一个最小的复现 demo 即可
#include <cstdio>
#include <vector>
template<typename T>
struct Test {
Test() {}
Test(std::initializer_list<T>) { puts("被调用"); }
Test(const Test<T>&) { puts("复制构造"); }
};
template<typename T>
Test(std::initializer_list<T>) -> Test<T>;
int main() {
Test<int> a;
Test t{ a,a }; //被调用 会推导为Test<Test<int>>
Test t2{ a }; //复制构造 会推导为Test<int>
}
复制构造
复制构造
被调用
复制构造
根据运行结果我们可以知道,std::initializer_list
的构造函数版本只被调用了一次,是 Test t{ a,a };
。
Test t2{ a };
并没有调用 std::initializer_list
的构造函数。
不用感到奇怪,这理所应当。
当从类型为正在构造的类模板的特化或特化子级的单个元素进行初始化时,复制构造函数优先于列表构造函数。
这就可以解释为什么直接 {}
不行,({})
的话就很明确了,只能调用 initializer_list
的构造函数。
包括可以解释以下给出的错误写法:
template<typename... Args>
auto make_vector(const Args&... elems){
return std::vector{elems...};
}
auto v2 = make_vector(std::vector{1,2,3}); // std::vector<int>
可参见文档描述。
我们会给出三段使用到了 return std::move
代码。
解释说明这些代码是否有问题,问题在哪,或者没问题,那么为什么要这样使用。
- 全局函数,返回局部对象,使用
std::move
。
#include<iostream>
struct X{//后续代码不再重复X类
X() { puts("X()"); }
X(const X&) { puts("X(const X&)"); }
X(X&&)noexcept { puts("X(X&&)"); }
~X() { puts("~X()"); }
};
X f(){
X x;
return std::move(x);
}
int main(){
X x = f();
}
- 全局函数,返回局部的引用,使用
std::move
。
X&& f(){
X x;
return std::move(x);
}
- 类中成员函数,返回数据成员,使用
std::move
。
struct Test {
X x;
X f() {
return std::move(x);
}
};
- 难度:★★★☆☆
- 函数局部的
x
对象是隐式可移动实体,可以调用移动构造,显式std::move
毫无意义,甚至会影响NRVO
优化。 - 返回局部的引用,这是悬垂的。
- 类的数据成员不是隐式可移动实体,如果不
std::move
,而是直接return x
,重载决议不会选择移动构造。所以有意义。