在2018年CppCon大会上的演讲中,Herb Sutter在两个方向上向公众展示了他的成就。 首先,它是变量生存期的控制 (Lifetime),它允许在编译阶段检测整个bug类。 其次,这是有关元类的更新建议,一旦描述了类类别的行为,然后用一行将其连接到特定类,就可以避免代码重复。
前言:更多=更容易?
听到C ++的指控,该标准正在毫无意义和残酷地增长。 但是,即使是最热心的保守派也不会认为range-for(收集周期)和auto(至少对于迭代器)这样的新构造会使代码更简单。 您可以制定(至少一种,最好是全部)新语言扩展必须满足的近似标准,以简化实践中的代码:
- 减少,简化代码,删除重复的代码(范围,自动,lambda,元类)
- 使安全代码更易于编写,防止出现错误和特殊情况(智能指针,生命周期)
- 完全替换旧的,功能较少的功能(typedef→使用)
Herb Sutter识别“现代C ++”-符合现代编码标准(例如C ++核心准则 )的功能的子集,并将完整标准视为每个人都不需要知道的“兼容模式”。 因此,如果“现代C ++”没有增长,那么一切都很好。
检查变量的生命周期(生命周期)
现在,新的终身验证组可作为Clang和Visual C ++的核心准则检查器的一部分。 目标不是要像Rust中那样达到绝对的严格性和准确性,而是要在各个功能内执行简单而快速的检查。
验证的基本原则
从生命周期分析的角度来看,类型分为三类:
- 该值是指针可以指向的值。
- 指针-指值,但不控制其寿命。 可能会挂(悬空的指针)。 示例:
T*
, T&
,迭代器, std::observer_ptr<T>
, std::string_view
, gsl::span<T>
- 所有者-控制价值的有效期。 通常可以提前删除其值。 例如:
std::unique_ptr<T>
, std::shared_ptr<T>
, std::vector<T>
, std::string
, gsl::owner<T*>
指针可以处于以下状态之一:
- 指向存储在堆栈中的值
- 指向某个所有者“内部”包含的值
- 为空(空)
- 挂起(无效)
指标与价值
对于每个指针 p 被追踪 pset(p) -它可能指示的一组值。 删除值时,它在所有值中都出现 pset 替换为 无效 。 访问指针值时 p 这样 无效∈pset(p) 发出错误。
string_view s;
使用注释,可以配置将哪些操作视为访问值的操作。 默认情况下: *
, ->
, []
, begin()
, end()
。
请注意,警告仅在访问无效索引时发出。 如果“值”被删除,但没有人访问过该指针,则一切正常。
路标和所有者
如果指针 p 指示所有者中包含的值 o 然后这个 pset(p)=o′ 。
拥有者的方法和功能分为:
- 所有者价值访问操作。 默认值:
*
, ->
, []
, begin()
, end()
- 对所有者本身的访问操作,
v.clear()
指针,如v.clear()
。 默认情况下,这些是所有其他非常量操作 - 对所有者本身(非无效指针
v.empty()
访问操作,例如v.empty()
。 默认情况下,这些都是const操作。
旧内容所有者宣布 无效 移走所有者或应用无效操作后。
这些规则足以检测C ++代码中的许多典型错误:
string_view s;
vector<int> v = get_ints(); int* p = &v[5];
std::string_view s = "foo"s; cout << s[0];
vector<int> v = get_ints(); for (auto i = v.begin(); i != v.end(); ++i) {
std::optional<std::vector<int>> get_data();
跟踪功能参数的寿命
当我们开始使用C ++中的返回指针的函数时,我们只能猜测参数的生存期与返回值之间的关系。 如果一个函数接受并返回相同类型的Pointer,则假定该函数从输入参数之一“获取”返回值:
auto f(int* p, int* q) -> int*;
很容易检测到可疑函数,这些函数从无处获得结果:
std::reference_wrapper<int> get_data() {
由于可以将临时值传递给const T&
参数,因此不会考虑它们,除非结果无处可取:
template <typename T> const T& min(const T& x, const T& y);
using K = std::string; using V = std::string; const V& find_or_default(const std::map<K, V>& m, const K& key, const V& def);
还可以相信,如果一个函数接受一个指针(而不是引用),则它可以是nullptr,并且在与nullptr比较之前不能使用该指针。
寿命控制结论
我再说一遍,Lifetime并不是针对C ++标准的建议,而是大胆尝试在C ++中实现生命周期检查,例如,与Rust不同,那里从来没有相应的注释。 最初,会有很多误报,但随着时间的流逝,启发式方法将不断完善。
听众的提问
生命周期组检查是否提供数学上准确的保证,确保没有悬空的指针?
从理论上讲,有可能(在新代码中)在类和函数上悬挂一堆注释,作为回报,编译器将提供此类保证。 但是这些检查是遵循80:20原则开发的,也就是说,您可以使用少量规则并应用最少的注释来捕获大多数错误。
元类以某种方式补充了应用它的类的代码,并且还充当满足某些条件的一组类的名称。 例如,如下所示, interface
元类将使所有功能公开,并且对您而言完全是虚拟的。
去年,Herb Sutter进行了他的第一个元类项目( 请参阅此处 )。 从那时起,当前建议的语法已更改。
首先,使用元类的语法已更改:
它已经变长了,但是现在有一种自然的语法可以一次应用几个元类: class(meta1, meta2)
。
以前,元类是用于修改类的一组规则。 现在,元类是一个constexpr函数,它接受一个旧类(在代码中声明)并创建一个新类。
即,该函数采用一个参数-有关旧类的元信息(参数的类型取决于实现),创建类元素(片段),然后使用__generate
指令将其添加到新类的主体中。
可以使用__fragment
, __inject
和idexpr(…)
构造生成片段。 发言者希望不要专注于他们的目的,因为在将其提交给标准化委员会之前,这部分仍会更改。 名称本身保证会被更改,专门添加了双下划线以澄清这一点。 报告中的重点是更进一步的例子。
介面
template <typename T> constexpr void interface(T source) {
您可能会认为在第(1)和(2)行中,我们修改了原始类,但没有。 请注意,我们通过复制来遍历原始类的功能,修改这些功能,然后将其插入新类中。
元类应用程序:
class(interface) Shape { int area() const; void scale_by(double factor); };
互斥调试
假设我们有一个互斥锁保护的非线程安全数据。 如果在调试程序集中的每个调用中检查当前进程是否已锁定此互斥锁,则可以促进调试。 为此,编写了一个简单的TestableMutex类:
class TestableMutex { public: void lock() { m.lock(); id = std::this_thread::get_id(); } void unlock() { id = std::thread::id{}; m.unlock(); } bool is_held() { return id == std::this_thread::get_id(); } private: std::mutex m; std::atomic<std::thread::id> id; };
此外,在MyData类中,我们希望每个公共领域都喜欢
vector<int> v;
替换为+ getter:
private: vector<int> v_; public: vector<int>& v() { assert(m_.is_held()); return v_; }
对于功能,也可以执行类似的转换。
使用宏和代码生成可解决此类任务。 赫伯·萨特(Herb Sutter)向宏宣战:它们是不安全的,会忽略语义,名称空间等。 解决方案在元类上看起来像什么:
constexpr void guarded_with_mutex() { __generate __fragment class { TestableMutex m_;
使用方法:
class(guarded) MyData { vector<int> v; Widget* w; }; MyData& x = findData("foo"); xv().clear();
演员
好吧,即使我们用互斥锁保护了某个对象,但现在所有内容都是线程安全的,也没有要求正确性。 但是,如果一个对象经常可以被多个并行线程访问,则互斥对象将过载,并且占用大量开销。
解决多虫互斥问题的根本解决方案是参与者的概念,当一个对象有一个请求队列时,对该对象的所有调用都将排入队列,并在一个特殊线程中一个接一个地执行。
让Active类包含所有这些的实现-实际上,是具有单个线程的线程池/执行程序。 好吧,元类将帮助摆脱重复的代码并使所有操作排队:
class(active) ImageFilter { public: ImageFilter(std::function<void(Buffer*)> w) : work(std::move(w)) {} void apply(Buffer* b) { work(b); } private: std::function<void(Buffer*)> work; }
class(active) log { std::fstream f; public: void info(…) { f << …; } };
财产
几乎所有现代编程语言都具有属性,而没有根据C ++实现它们的人:Qt,C ++ / CLI,各种丑陋的宏。 但是,它们永远不会被添加到C ++标准中,因为它们本身被认为过于狭窄,并且始终希望有一些提案可以将它们作为一种特殊情况来实现。 好了,它们可以在元类上实现!
您可以设置自己的getter和setter:
class Date { public: class(property<int>) MonthClass { int month; auto get() { return month; } void set(int m) { assert(m > 0 && m < 13); month = m; } } month; }; Date date; date.month = 15;
理想情况下,我想将property int month { … }
写为property int month { … }
,但是即使这样的实现也将取代发明属性的C ++扩展的动物园。
对于已经很复杂的语言,元类是一个很大的新功能。 值得吗? 这里有一些好处:
- 让程序员更清楚地表达自己的意图(我想写演员)
- 减少代码重复并简化遵循某些模式的代码的开发和维护
- 消除一些常见的错误(一次足以解决所有细微差别)
- 允许摆脱宏吗? (草药萨特非常好战)
听众的提问
如何调试元类?
至少对于Clang,有一个内在函数,如果被调用,它将在编译时打印类的实际内容,即在应用所有元类后获得的内容。
据说它能够在元类中声明非成员,如swap和hash。 她去哪了
语法将进一步发展。
如果已经为标准化采用了概念,为什么我们需要元类?
这些是不同的东西。 需要使用元类来定义类的各个部分,并且概念会使用类示例检查类是否符合特定模式。 实际上,元类和概念可以很好地协同工作。 例如,您可以定义迭代器的概念和“典型迭代器”的元类,该“典型迭代器”定义其余的一些冗余操作。