极客时间已完结课程限时免费阅读

08 | 易用性改进 I:自动类型推断和初始化

08 | 易用性改进 I:自动类型推断和初始化-极客时间

08 | 易用性改进 I:自动类型推断和初始化

讲述:吴咏炜

时长15:24大小14.12M

你好,我是吴咏炜。
在之前的几讲里,我们已经多多少少接触到了一些 C++11 以来增加的新特性。下面的两讲,我会重点讲一下现代 C++(C++11/14/17)带来的易用性改进。
就像我们 [开篇词] 中说的,我们主要是介绍 C++ 里好用的特性,而非让你死记规则。因此,这里讲到的内容,有时是一种简化的说法。对于日常使用,本讲介绍的应该能满足大部分的需求。对于复杂用法和边角情况,你可能还是需要查阅参考资料里的明细规则。

自动类型推断

如果要挑选 C++11 带来的最重大改变的话,自动类型推断肯定排名前三。如果只看易用性或表达能力的改进的话,那它就是“舍我其谁”的第一了。

auto

自动类型推断,顾名思义,就是编译器能够根据表达式的类型,自动决定变量的类型(从 C++14 开始,还有函数的返回类型),不再需要程序员手工声明([1])。但需要说明的是,auto 并没有改变 C++ 是静态类型语言这一事实——使用 auto 的变量(或函数返回值)的类型仍然是编译时就确定了,只不过编译器能自动帮你填充而已。
自动类型推断使得像下面这样累赘的表达式成为历史:
// vector<int> v;
for (vector<int>::iterator
it = v.begin(),
end = v.end();
it != end; ++it) {
// 循环体
}
现在我们可以直接写(当然,是不使用基于范围的 for 循环的情况):
for (auto it = v.begin(), end = v.end();
it != end; ++it) {
// 循环体
}
不使用自动类型推断时,如果容器类型未知的话,我们还需要加上 typename(注意此处 const 引用还要求我们写 const_iterator 作为迭代器的类型):
template <typename T>
void foo(const T& container)
{
for (typename T::const_iterator
it = v.begin(),
}
如果 begin 返回的类型不是该类型的 const_iterator 嵌套类型的话,那实际上不用自动类型推断就没法表达了。这还真不是假设。比如,如果我们的遍历函数要求支持 C 数组的话,不用自动类型推断的话,就只能使用两个不同的重载:
template <typename T, std::size_t N>
void foo(const T (&a)[N])
{
typedef const T* ptr_t;
for (ptr_t it = a, end = a + N;
it != end; ++it) {
// 循环体
}
}
template <typename T>
void foo(const T& c)
{
for (typename T::const_iterator
it = c.begin(),
end = c.end();
it != end; ++it) {
// 循环体
}
}
如果使用自动类型推断的话,再加上 C++11 提供的全局 beginend 函数,上面的代码可以统一成:
template <typename T>
void foo(const T& c)
{
using std::begin;
using std::end;
// 使用依赖参数查找(ADL);见 [strong_begin][2][strong_end]
for (auto it = begin(c),
ite = end(c);
it != ite; ++it) {
// 循环体
}
}
从这个例子可见,自动类型推断不仅降低了代码的啰嗦程度,也提高了代码的抽象性,使我们可以用更少的代码写出通用的功能。
auto 实际使用的规则类似于函数模板参数的推导规则([3])。当你写了一个含 auto 的表达式时,相当于把 auto 替换为模板参数的结果。举具体的例子:
auto a = expr; 意味着用 expr 去匹配一个假想的 template <typename T> f(T) 函数模板,结果为值类型。
const auto& a = expr; 意味着用 expr 去匹配一个假想的 template <typename T> f(const T&) 函数模板,结果为常左值引用类型。
auto&& a = expr; 意味着用 expr 去匹配一个假想的 template <typename T> f(T&&) 函数模板,根据[第 3 讲] 中我们讨论过的转发引用和引用坍缩规则,结果是一个跟 expr 值类别相同的引用类型。

decltype

decltype 的用途是获得一个表达式的类型,结果可以跟类型一样使用。它有两个基本用法:
decltype(变量名) 可以获得变量的精确类型。
decltype(表达式) (表达式不是变量名,但包括 decltype((变量名)) 的情况)可以获得表达式的引用类型;除非表达式的结果是个纯右值(prvalue),此时结果仍然是值类型。
如果我们有 int a;,那么:
decltype(a) 会获得 int(因为 aint)。
decltype((a)) 会获得 int&(因为 a 是 lvalue)。
decltype(a + a) 会获得 int(因为 a + a 是 prvalue)。

decltype(auto)

通常情况下,能写 auto 来声明变量肯定是件比较轻松的事。但这儿有个限制,你需要在写下 auto 时就决定你写下的是个引用类型还是值类型。根据类型推导规则,auto 是值类型,auto& 是左值引用类型,auto&& 是转发引用(可以是左值引用,也可以是右值引用)。使用 auto 不能通用地根据表达式类型来决定返回值的类型。不过,decltype(expr) 既可以是值类型,也可以是引用类型。因此,我们可以这么写:
decltype(expr) a = expr;
这种写法明显不能让人满意,特别是表达式很长的情况(而且,任何代码重复都是潜在的问题)。为此,C++14 引入了 decltype(auto) 语法。对于上面的情况,我们只需要像下面这样写就行了。
decltype(auto) a = expr;
这种代码主要用在通用的转发函数模板中:你可能根本不知道你调用的函数是不是会返回一个引用。这时使用这种语法就会方便很多。

函数返回值类型推断

从 C++14 开始,函数的返回值也可以用 autodecltype(auto) 来声明了。同样的,用 auto 可以得到值类型,用 auto&auto&& 可以得到引用类型;而用 decltype(auto) 可以根据返回表达式通用地决定返回的是值类型还是引用类型。
和这个形式相关的有另外一个语法,后置返回值类型声明。严格来说,这不算“类型推断”,不过我们也放在一起讲吧。它的形式是这个样子:
auto foo(参数) -> 返回值类型声明
{
// 函数体
}
通常,在返回类型比较复杂、特别是返回类型跟参数类型有某种推导关系时会使用这种语法。以后我们会讲到一些实例。今天暂时不多讲了。

类模板的模板参数推导

如果你用过 pair 的话,一般都不会使用下面这种形式:
pair<int, int> pr{1, 42};
使用 make_pair 显然更容易一些:
auto pr = make_pair(1, 42);
这是因为函数模板有模板参数推导,使得调用者不必手工指定参数类型;但 C++17 之前的类模板却没有这个功能,也因而催生了像 make_pair 这样的工具函数。
在进入了 C++17 的世界后,这类函数变得不必要了。现在我们可以直接写:
pair pr{1, 42};
生活一下子变得简单多了!
在初次见到 array 时,我觉得它的主要缺点就是不能像 C 数组一样自动从初始化列表来推断数组的大小了:
int a1[] = {1, 2, 3};
array<int, 3> a2{1, 2, 3}; // 啰嗦
// array<int> a3{1, 2, 3}; 不行
这个问题在 C++17 里也是基本不存在的。虽然不能只提供一个模板参数,但你可以两个参数全都不写 🤣:
array a{1, 2, 3};
// 得到 array<int, 3>
这种自动推导机制,可以是编译器根据构造函数来自动生成:
template <typename T>
struct MyObj {
MyObj(T value);
};
MyObj obj1{string("hello")};
// 得到 MyObj<string>
MyObj obj2{"hello"};
// 得到 MyObj<const char*>
也可以是手工提供一个推导向导,达到自己需要的效果:
template <typename T>
struct MyObj {
MyObj(T value);
};
MyObj(const char*) -> MyObj<string>;
MyObj obj{"hello"};
// 得到 MyObj<string>
更多的技术细节请参见参考资料 [4]

结构化绑定

在讲关联容器的时候我们有过这样一个例子:
multimap<string, int>::iterator
lower, upper;
std::tie(lower, upper) =
mmp.equal_range("four");
这个例子里,返回值是个 pair,我们希望用两个变量来接收数值,就不得不声明了两个变量,然后使用 tie 来接收结果。在 C++11/14 里,这里是没法使用 auto 的。好在 C++17 引入了一个新语法,解决了这个问题。目前,我们可以把上面的代码简化为:
auto [lower, upper] =
mmp.equal_range("four");
这个语法使得我们可以用 auto 声明变量来分别获取 pairtuple 返回值里各个子项,可以让代码的可读性更好。
关于这个语法的更多技术说明,请参见参考资料 [5]

列表初始化

在 C++98 里,标准容器比起 C 风格数组至少有一个明显的劣势:不能在代码里方便地初始化容器的内容。比如,对于数组你可以写:
int a[] = {1, 2, 3, 4, 5};
而对于 vector 你却得写:
vector<int> v;
v.push(1);
v.push(2);
v.push(3);
v.push(4);
v.push(5);
这样真是又啰嗦,性能又差,显然无法让人满意。于是,C++ 标准委员会引入了列表初始化,允许以更简单的方式来初始化对象。现在我们初始化容器也可以和初始化数组一样简单了:
vector<int> v{1, 2, 3, 4, 5};
同样重要的是,这不是对标准库容器的特殊魔法,而是一个通用的、可以用于各种类的方法。从技术角度,编译器的魔法只是对 {1, 2, 3} 这样的表达式自动生成一个初始化列表,在这个例子里其类型是 initializer_list<int>。程序员只需要声明一个接受 initializer_list 的构造函数即可使用。从效率的角度,至少在动态对象的情况下,容器和数组也并无二致,都是通过拷贝(构造)进行初始化。
对于初始化列表在构造函数外的用法和更多的技术细节,请参见参考资料 [6]

统一初始化

你可能已经注意到了,我在代码里使用了大括号 {} 来进行对象的初始化。这当然也是 C++11 引入的新语法,能够代替很多小括号 () 在变量初始化时使用。这被称为统一初始化(uniform initialization)。
大括号对于构造一个对象而言,最大的好处是避免了 C++ 里“最令人恼火的语法分析”(the most vexing parse)。我也遇到过。假设你有一个类,原型如下:
class utf8_to_wstring {
public:
utf8_to_wstring(const char*);
operator wchar_t*();
};
然后你在 Windows 下想使用这个类来帮助转换文件名,打开文件:
ifstream ifs(
utf8_to_wstring(filename));
你随后就会发现,ifs 的行为无论如何都不正常。最后,要么你自己查到,要么有人告诉你,上面这个写法会被编译器认为是和下面的写法等价的:
ifstream ifs(
utf8_to_wstring filename);
换句话说,编译器认为你是声明了一个叫 ifs 的函数,而不是对象!
如果你把任何一对小括号替换成大括号(或者都替换,如下),则可以避免此类问题:
ifstream ifs{
utf8_to_wstring{filename}};
推而广之,你几乎可以在所有初始化对象的地方使用大括号而不是小括号。它还有一个附带的特点:当一个构造函数没有标成 explicit 时,你可以使用大括号不写类名来进行构造,如果调用上下文要求那类对象的话。如:
Obj getObj()
{
return {1.0};
}
如果 Obj 类可以使用浮点数进行构造的话,上面的写法就是合法的。如果有无参数、多参数的构造函数,也可以使用这个形式。除了形式上的区别,它跟 Obj(1.0) 的主要区别是,后者可以用来调用 Obj(int),而使用大括号时编译器会拒绝“窄”转换,不接受以 {1.0}Obj{1.0} 的形式调用构造函数 Obj(int)
这个语法主要的限制是,如果一个类既有使用初始化列表的构造函数,又有不使用初始化列表的构造函数,那编译器会千方百计地试图调用使用初始化列表的构造函数,导致各种意外。所以,如果给一个推荐的话,那就是:
如果一个类没有使用初始化列表的构造函数时,初始化该类对象可全部使用统一初始化语法。
如果一个类有使用初始化列表的构造函数时,则只应用在初始化列表构造的情况。
关于这个语法的更多详细用法讨论,请参见参考资料 [7]

类数据成员的默认初始化

按照 C++98 的语法,数据成员可以在构造函数里进行初始化。这本身不是问题,但实践中,如果数据成员比较多、构造函数又有多个的话,逐个去初始化是个累赘,并且很容易在增加数据成员时漏掉在某个构造函数中进行初始化。为此,C++11 增加了一个语法,允许在声明数据成员时直接给予一个初始化表达式。这样,当且仅当构造函数的初始化列表中不包含该数据成员时,这个数据成员就会自动使用初始化表达式进行初始化。
这个句子有点长。我们看个例子:
class Complex {
public:
Complex()
: re_(0) , im_(0) {}
Complex(float re)
: re_(re), im_(0) {}
Complex(float re, float im)
: re_(re) , im_(im) {}
private:
float re_;
float im_;
};
假设由于某种原因,我们不能使用缺省参数来简化构造函数,我们可以用什么方式来优化上面这个代码呢?
使用数据成员的默认初始化的话,我们就可以这么写:
class Complex {
public:
Complex() {}
Complex(float re) : re_(re) {}
Complex(float re, float im)
: re_(re) , im_(im) {}
private:
float re_{0};
float im_{0};
};
第一个构造函数没有任何初始化列表,所以类数据成员的初始化全部由默认初始化完成,re_im_ 都是 0。第二个构造函数提供了 re_ 的初始化,im_ 仍由默认初始化完成。第三个构造函数则完全不使用默认初始化。

内容小结

在本讲中,我们介绍了现代 C++ 引入的几个易用性改进:自动类型推断,初始化列表,及类数据成员的默认初始化。使用这些特性非常简单,可以立即简化你的 C++ 代码,而不会引入额外的开销。唯一的要求只是你不要再使用那些上古时代的老掉牙编译器了……

课后思考

你使用过现代 C++ 的这些特性了吗?如果还没有的话,哪些特性你打算在下一个项目里开始使用?
欢迎留言来分享你的看法。

参考资料

[1] cppreference.com, “Placeholder type specifiers”. https://en.cppreference.com/w/cpp/language/auto
[1a] cppreference.com, “占位符类型说明符”. https://zh.cppreference.com/w/cpp/language/auto
[2] Wikipedia, “Argument-dependent name lookup”. https://en.wikipedia.org/wiki/Argument-dependent_name_lookup
[2a] 维基百科, “依赖于实参的名字查找”. https://zh.wikipedia.org/zh-cn/ 依赖于实参的名字查找
[3] cppreference.com, “Template argument deduction”. https://en.cppreference.com/w/cpp/language/template_argument_deduction
[3a] cppreference.com, “模板实参推导”. https://zh.cppreference.com/w/cpp/language/template_argument_deduction
[4] cppreference.com, “Class template argument deduction”. https://en.cppreference.com/w/cpp/language/class_template_argument_deduction
[4a] cppreference.com, “类模板实参推导”. https://zh.cppreference.com/w/cpp/language/class_template_argument_deduction
[5] cppreference.com, “Structured binding declaration”. https://en.cppreference.com/w/cpp/language/structured_binding
[5a] cppreference.com, “结构化绑定声明”. https://zh.cppreference.com/w/cpp/language/structured_binding
[6] cppreference.com, “std::initializer_list”. https://en.cppreference.com/w/cpp/utility/initializer_list
[6a] cppreference.com, “std::initializer_list”. https://en.cppreference.com/w/cpp/utility/initializer_list
[7] Scott Meyers, Effective Modern C++, item 7. O’Reilly Media, 2014. 有中文版(高博译,中国电力出版社,2018 年)
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 7

提建议

上一篇
07 | 迭代器和好用的新for循环
下一篇
09 | 易用性改进 II:字面量、静态断言和成员函数说明符
 写留言

精选留言(31)

  • robonix
    2020-01-06
    老师,文中提到auto&& a = expr; 推断结果是一个跟 expr 值类别相同的引用类型。那么如果expr是左值引用或右值引用,对应的推断类型又是啥呢?

    作者回复: 仍然是第3讲里的规则,左值得到左值引用,右值得到右值引用(但要注意右值引用是个左值): int x = 42; int& a = x; int&& b = 42; auto&& c = a; // int& auto&& d = b; // int& auto&& e = std::move(b); // int&&

    共 2 条评论
    15
  • 中年男子
    2019-12-14
    建议各位如果文章中有没看懂的地方,去看看老师在文末的参考资料,这些也都是好东西

    作者回复: 识货。😇 毕竟这个专栏的篇幅是 30 讲,不是 60 讲或 100 讲啊。

    9
  • 花晨少年
    2019-12-14
    如果一个类有使用初始化列表的构造函数时,则只应用在初始化列表构造的情况。 是说{1.0}这种形式只用在初始化列表构造的情况吗? 什么是初始化列表构造的情况?不明白

    作者回复: 是说如果一个类Obj既有: Obj(initializer_list<int>); 又有: Obj(double); 那你想调用后面那个构造函数,就别用 Obj{1.0} 这种形式,而用 Obj(1.0)。

    6
  • Geek_68d3d2
    2020-06-11
    在使用模板的时候为什么T是容器的时候前面要加上一个typename?? 比如typename T::const_iterator it = v.begin()

    作者回复: 语言规则要求。在编译器不知道T是啥(模板定义中,尚未实例化)的情况下,显式通知编译器T::const_iterator是个类型。

    4
  • hello
    2020-04-02
    上学的时候看了一遍c++ primer,学到的全是c++98。工作遇到c++14或17新语法时,一头雾水。只能在工作中慢慢摸索,一直想看到一篇只讲新老版本c++每一个区别的文章。发现这篇对我来说真的适合
    3
  • 晚风·和煦
    2020-02-16
    转发引用就是万能引用吗😁😂

    作者回复: 对。C++11刚出来时,对T&&没有专门的术语,是Scott Meyers发明了“万能引用”这一术语来描述。后来标准委员会认识到我们需要一个专门术语,在C++17把这个概念用“转发引用”来描述了。为什么叫这个名字,可以参见: https://isocpp.org/files/papers/N4164.pdf

    2
  • 阿白
    2021-10-29
    老师我在练习结构化绑定的时候发现一个问题,下面这个例子 int p = 1; char l = 1; int m = 1; std::tuple<float &, char &&, int> tpl(p, std::move(l), m); const auto&[a, b, c] = tpl; 最终a,b,c类型的推断结果为float & a, char && b, const int& c 引用类型的cosnt限定被忽略了,我去查资料查到 Cv-qualified references are ill-formed except when the cv-qualifiers are introduced through the use of a typedef or of a template type argument, in which case the cv-qualifiers are ignored. 我在测试的时候感觉很混乱,到底什么情况下会出现ill-formed的Cv-qualified references。
    展开

    作者回复: 你举的这个例子相当复杂。常见情况下,一般不会用引用绑定去绑定到一个带引用的 tuple 上,所以我也不讨论这样的特殊情况了。 对于这个例子,编译器的第一步动作是: const auto& e = tpl; 我们随即得到 e 的类型是 const std::tuple<float &, char &&, int>&。 a、b、c 本质上就是 get<0>(e)、get<1>(e)、get<2>(e) 的语法糖了。对于 tuple 里的引用成员,get 的结果就是这个引用成员,所以没有 const:你对它修改时没有改变 tuple 本身,而是改变它指向的内容(可以把引用改成指针来想象一下;引用可以看作会自动解引用的指针)。对于 tuple 里的非引用成员,get 的结果实际是指向 tuple 的引用(即使你获得的结果不是引用)。这就好比 s.int_value 的类型是整数,但你修改了 s.int_value 会修改 s 一样。 下面的例子可以帮助你理解: int n = 0; std::tuple<int*, int> tup{&n, n}; auto& [p, m] = tup; m = 1; 到此为止,tup 里的整数也变成了 1。

    共 3 条评论
    1
  • Geek_b68b74
    2020-01-17
    什么时候该用auto,什么时候该用decltype呢?隐隐知道怎么用,但不知道具体的规则是什么呢

    作者回复: 就看你要不要保持值类别了。在应用的代码,一般写 auto 更安全。如果你是写通用的模板代码,那可能就需要写 decltype(auto) 了。但你得仔细考虑一下对象的生命期问题,确保不会返回一个过期的引用。

    1
  • EncodedStar
    2019-12-26
    auto 用了不少,真的好用,其他的都没有真正用到。工作中的项目都不支持11,只能自己学习的时候使用了。

    作者回复: 唉……想办法看看能不能把工具链先升级上去,并测试有没有问题吧。

    共 4 条评论
    1
  • A君
    2022-05-10
    请问同样是在函数中返回一个对象,一个是定义函数的返回值类型为引用:A& func(...)  { ... A a; ... return a; },另一个则是通过类型推导为函数返回值绑定类型:auto& a = func(...) {... return a; }。这两者是等价的吗?

    作者回复: 不等价,两个都是大错特错。 前一个是返回本地变量的引用,未定义行为,但应该能通过编译。 后一个是拿左值引用绑定到一个临时对象上,编译都过不去。 不过,后者如果把“auto&”改成“const auto&”或“auto&&”的话,代码就合法了——生命周期延长规则就生效了。

    共 3 条评论
  • 常振华
    2021-09-30
    说实话,我不觉得这些语法更易用,虽然增加了灵活性,但是越灵活的东西就越复杂。

    作者回复: 让新手上手更容易,让代码更简洁。 确实不是每个人都喜欢每个 C++ 的新特性。一门有着众多用户的庞大语言,要改进是很不容易的。

  • 怪兽
    2021-06-19
    我孤陋寡闻了,C++的语法分析竟然会在隐式函数转换上失败,我试了一下,如果这样写就可以: ifstream ifs(utf8_to_wstring(filename).operator wchar_t*()); 另外,我在使用map容器的时候,总是first、second,我觉得应用结构化绑定更直观些,那么是否依旧可以使用&(引用)符号呢?例如auto&,这样就不会发生拷贝了吧: std::map<int, string> students; for (const auto& [id, name] : students) { cout << id << name << endl; }
    展开

    作者回复: 对,对于遍历关联容器,你写的是标准的惯用法。

  • Geek_dddde9
    2021-06-14
    主要还是用auto 统一初始化 默认初始化 范围for,这种确实很容易简化代码
  • chang
    2021-05-13
    老师好。有点没有搞懂,StrVec是一个自定义的存储std::string元素类型的动态数组: class StrVec { public: explicit StrVec(initializer_list<std::string> ls); }; 当上面这个构造函数指定为explicit时,StrVec sv={"hello", "world"}; 这样定义会报错;当不指定explicit时,这样定义ok,不知为啥?
    展开

    作者回复: 你定义的形式是拷贝初始化。去掉sv后面的等号即可。

    共 2 条评论
  • 201201511
    2021-04-14
    class Complex {public: Complex() {} Complex(float re) : re_(re) {} Complex(float re, float im) : re_(re) , im_(im) {}private: float re_{0}; float im_{0};}; 功能安全26262要求,构造函数不要同时使用默认初始化和列表初始化,想请教下,从c++角度看这个要求有合理性吗?

    作者回复: 哪儿来的要求?我看不出这样的要求能有什么好处。 难道只允许一个构造函数,或者不允许默认成员初始化么?只要有超过一个构造函数,又有默认成员初始化,我感觉基本上就会违反这一条了。

  • xl000
    2020-04-02
    老师,decltype(auto) a = expr;这种写法,根auto a = expr;有什么区别呢?它能正确地推断出需要写auto& a = expr;的情况?

    作者回复: 区别不是写在正文里了么? 对,在某些情况下它就相当于 auto& a = exp;。

    共 2 条评论
  • JDY
    2020-02-27
    这个我觉得我很有发言权,尤其是对auto, 刚开始学的时候用vscode,每次都给我识别不了,然后居然果断放弃了auto!现在看来真是浪费啊,老师能不能推荐一个很好的支持c++11的编译器呢?

    作者回复: 你是说 IDE,而不是编译器吧…… 如果 Windows 上的话,就用 Visual Studio 2019 Community Edition 好了。 Mac 上的话,应该 Xcode 可以。 Linux 上不知道有很好用又免费的。如果只要求免费,试试 Vim 加 clang_complete(事实上,这是我的日常配置,但如果不是日常玩 Vim 的,我不推荐这个方案)。如果接受付费,可以用 CLion。

  • 晚风·和煦
    2020-02-16
    假设由于某种原因,我们不能使用缺省参数来简化构造函数,我们可以用什么方式来优化上面这个代码呢? "缺省参数简化构造函数"不太懂,就是函数的默认参数吗?好像不算简化吧🤔

    作者回复: 我的意思是,这个类我们本来也可以用下面的方式: class Complex { … Complex(float re = 0, float im = 0) : re_(re) , im_(im) {} }; 假如我们不用这种方式的话,该怎么办?

  • tr
    2020-02-05
    老师,不明白为什么 float x ={123456789LL};这种算做类型收窄,会编译报错?

    作者回复: 123456789 有 27 位有效数字(二进制),而 32 位的 float 类型的有效数字数是 24。你说损失了不? 详见 https://zh.wikipedia.org/zh-cn/%E5%96%AE%E7%B2%BE%E5%BA%A6%E6%B5%AE%E9%BB%9E%E6%95%B8

  • Qi
    2019-12-23
    还在用Ti很旧的arm板子做开发,工具链都不支持C++11了,还是坚持看到现在了。。。

    作者回复: 坚持向前看啊😄 看看工具链有没有升级版本可以支持更新的标准?