创作不易,方便的话点点关注,谢谢
文章结尾有最新热度的文章,感兴趣的可以去看看。
文章有点长(6000字),期望您能坚持看完,并有所收获。
导读
至少为什么它被认为如此? 如果你不是C++程序员,你知道C++有多可怕,因为……嗯……你就是知道,对吧?如果你是C++程序员,那么你要么对C++有一种不健康的爱恨关系,要么如果不是,你可能只是还不知道。
嗨,我是一名拥有多年+经验的C++程序员,我讨厌C++。但在你作为它最大的粉丝开始为其辩护之前,或者在你默默地关闭页面之前,让我们尝试客观一分钟。
C++真的像大家习惯认为的那样糟糕吗?有没有好的方面? 糟糕的基础还是糟糕的环境? 在我们继续之前,我想概述我们不会涉及的方面。
第一个是标准库。在C++中,它是通用的、强大的,而且是……贫乏的。与其他现代语言的标准库相比,C++的标准库在提供的设施方面只是太基本了。
在许多其他语言中,我可以安装编译器/解释器,而不需要任何额外的第三方库,我就可以解析各种格式(JSON、XML、CSV,你随便说),解压存档,甚至运行一个简单的HTTP服务器。现在回想一下,对于C++程序员来说,终于在等待了数十年之后得到了一个跨平台的std::filesystem或者一些与std::chrono相关的日历函数是多么令人兴奋。然而,标准库是你完全可以避免的东西。更喜欢Qt或定制的库?请自便!
C++的第二个弱点是将第三方库连接到项目的实际过程。确实,这相当麻烦,特别是当你需要跨平台这样做时。同时,一些现代工具如vcpkg或Conan至少提供了一些缓解。
我认为这两个话题是环境而不是语言本身的一部分。你会因为某人/她有一个糟糕的环境就称他/她为坏人吗?我知道这是诱人的,但也是不成熟的。最好避免对人过早下结论,所以让我们也对语言这样做。
当我将C++与其他语言进行比较时,我对前者有两个主要担忧:实现简单的事情需要写太多代码,并且需要同时记住太多东西。这些特性使C++变得如此困难。
正如我们所知,没有所谓的“直观行为”会为每个人所共有。这是基于你经验的相对指标。如果相当大一部分(尽管不是全部)的人或多或少有相同的经历,我们称之为“常识”。这就是将你对情况的抽象理解与预期效果联系起来的东西。当我们遇到意想不到的事情时,我们通常会感到失望。事实是:C++有很多这样的事情!
默认情况下为const
过去,有一种习惯是重用在栈上定义的变量用于多个,通常是不相关的用途。如今,编译器足够聪明,可以为你执行这样的优化。
现代风格指南鼓励你避免“魔法数字”和复杂的表达式。这导致了将中间计算的结果分配给变量的必要性——给它们命名。
考虑以下示例:
void MegaController::onDataReceived(const QByteArray & data) {
if(data.isEmpty())
return;
constauto jsonDocument =QJsonDocument::fromJson(data);
constauto jsonObject = jsonDocument.object();
if(jsonObject.contains("presetId")){
// ...
}
// ...
}
我们能避免声明至少一个jsonDocument吗?当然可以!我们应该吗?绝对不应该。几乎总是更容易阅读几个带有适当名称的声明,而不是一长串函数调用。
但这里的重点是,也许我们不会在函数内更改解析的JSON。所以一开始就让它成为const是有意义的。让一切都成为const——只是为了更安全。
问题是,很容易忘记这样做,因为这需要额外的努力。将JavaScript与之比较
// 它总是`let`或`const`。你必须以这种方式或另一种方式写它
let n = 10;
const m = 10;
到C++
auto n = 10; // 一个变量
const auto m = 10; // 一个常量(需要`const`和类型名称)
我知道这只是多了一个词。但是当你的心思忙于解决一些复杂的世界问题时,它倾向于跳过这些小细节。这不会造成太大伤害,对吧?
有趣的是,有一个地方C++默认使变量成为const。你还记得是哪一个吗?对了,就是在lambda中:
std::vector<int> srcVec;
int init = 0;
std::generate_n(std::back_inserter(srcVec), 10, [init] {
return init++; // 错误:只读变量‘init’的增量
});
(顺便说一下,不要这样写生成器:有一个更好的特定生成器:std::iota)。
是的,通过lambda捕获的变量默认是const的,如果你想在lambda体中更改它们,你需要明确地使它们可变:
std::generate_n(std::back_inserter(srcVec), 10, [init]() mutable {
return init++;
});
所以……这一切都相当令人困惑,我必须承认。
Rust决定避免这种混乱,使所有变量默认不可变:
let mut n = 10; // 可变变量绑定
let m = 10; // 不可变变量绑定
好主意?我认为是的。现在,使某物可变需要额外的努力,因此,更不安全。
默认情况下[[nodiscard]]
自C++11以来,我们有了属性,特别是自C++17以来的nodiscard。后者没有任何保证,但至少如果你用它标记一个函数,编译器“被鼓励发出警告”,你需要对返回值做些什么。也出现了你可以将nodiscard标记为类构造函数。你问“为什么?”
好吧,想象你有一个所谓的互斥锁守护——一个在创建时锁定互斥锁并在离开作用域后(即,被销毁时)自动解锁它的东西。像一个标准的std::lock_guard,对吧。所以,想象一下这样的用法:
{
const mega_guard lock{mutex};
// ...
}
问题是,你可以很容易地写出像这样的错误代码,编译器不会发出任何警告:
{
mega_guard{mutex}; // 不是lvalue,所以立即被销毁,解锁互斥锁
// ...
}
正如你已经猜到的,你可以通过将构造函数标记为nodiscard来解决这个问题:
class mega_guard {
public:
[[nodiscard]] explicit mega_guard(mutex &);
// ...
};
现在,对于后者情况会出现警告。有道理吗?嗯,“有点”!
但是为什么这不是标准行为呢?我在想你多久写一次返回可以安全忽略的值的函数?那它们为什么首先返回它呢?你多久写一次类的实例根本不以任何方式使用?只是为了副作用还是什么?
在Nim中,人们也这样认为,使所有返回值不可丢弃。编译器立即生成错误,而不是警告:
proc sum(x, y: int): int =
result = x + y
sum(3, 4) # 错误:表达式'sum(3, 4)'是'int'类型,必须丢弃
如果你有某种原因需要强制它忽略该值,你必须告诉它这样做:
discard sum(3, 4)
酷,不是吗? 不过很遗憾,我找不到除了Nim之外的其他语言会采用这种方法。似乎Swift能够在这种情况下生成警告,并且Rust有一个类似的提议,但没有像Nim那样严格。
默认情况下为explicit
像C++这样的编译语言通常被认为比解释语言更类型安全,因为它们中的大多数是静态类型而不是动态类型。多亏了这一点,类型检查器在程序运行之前就检测到许多潜在问题。
但是,为什么例如JavaScript比Python类型安全少?它们都是动态类型的,那么有什么区别呢?好吧,正如许多你所知,类型安全还有另一个维度:强类型和弱类型。简单地说,类型检查器越放松,它就越弱。例如,隐式类型转换是弱类型系统的标志,就像JavaScript的情况:
console.log('42' + 42) // => 4242
与Python相比:
print('42' + 42) # TypeError: 只能将str(而不是"int")连接到str
“动态类型语言中的类型检查怎么能起作用?”你会问。关键区别在于,在静态类型语言中,类型检查器主要操作变量的类型,而在动态类型语言中——它检查值的类型。
重要的是要理解,所有这些类型系统类别都不是布尔值——它们代表一个光谱。静态类型的C++不允许隐式将类型指针如char转换为“未类型”指针void,但它的前身C允许这样做。所以,C是弱的,C++是强的,对吧?别急!Haskell根本不允许隐式类型转换。更正确地说,C比C++弱,而C++比Haskell弱。
谁想成为弱者?我们想变得更强!因此,我们需要尽可能消除隐式类型转换。然而,出于某种原因,C++要求手动将单参数构造函数标记为explicit,有效地告诉编
译器避免这种类型的隐式类型转换。你真的多久依赖一次隐式类型转换?比你在类中写explicit更频繁吗?我怀疑。
即使我们不能根本改变C++类型转换规则,至少使构造函数默认为explicit并引入一个implicit关键字来覆盖这种行为也是有意义的。Python的座右铭说“显式比隐式好”。换句话说,隐式是坏的,但明确写出的隐式会好一些
告诉我,在你的编程生涯中,你创建了多少次既没有#include卫士也没有#pragma once的头文件?我努力回忆至少一个案例,但失败了。
我同意,写一个#pragma once并不太难。至少对你或我来说,因为我认为它已经在我们的肌肉记忆中了。但这对新手来说很难。当我还是新手时,我几次错过了它,花了很多痛苦的时间试图理解出了什么问题。
那么,如果所有头文件默认只包含一次,我们只需简单地添加#pragma multiple来改变这种默认行为,会不会更好一点呢?
我知道C++20中的模块确实做到了这一点(还有很多其他事情)。但已经是2024年了,我们大多数人仍然不能使用模块,因为许多现有的构建系统要么没有支持,要么只有有限的支持,C++标准库没有模块化等等。
赋值作为语句
一些编程语言区分构成程序的构造,分为表达式和语句。简单地说,表达式是具有值的东西(字面量、函数调用、二元运算符等),而语句是没有值的东西(if、for、函数声明等)。
有些语言根本没有语句——一切都是表达式。其他语言有混合。没有表达式概念的语言不多(实际上,我只能想到Brainfuck,但我想还有更多)。
我看到的问题是,在C++中,赋值是一个表达式。一方面,它使你能够写出这样的东西:
a = b = c = 0;
但无论如何,这被认为是一个坏习惯,通常被避免。
另一方面,当它在其他地方使用时,它会增加一些痛苦。例如,一个著名的例子:
int n = 13;
if (n = 13)
std::cout << "Yaaay!" << std::endl;
每个有一定(痛苦)经验的C++程序员都会立即认出问题。当然,应该是if (n == 13),但我们只是给变量分配了一个新值。赋值运算符是一个表达式,所以它有一个值(13),它被隐式转换为bool,if中的代码总是被执行,这绝对不是意图。
这个问题如此普遍,以至于人们想出了所谓的“尤达风格”来防止它:
if (13 == n) {} // 这个可以正常工作
if (13 = n) {} // 不能给字面量赋值
其他继承自C语法的语言迈出了重大一步,禁止if中的隐式类型转换——它必须包含一个布尔表达式。只是为了处理这个问题!最终,C++编译器也学会了警告程序员这个问题。(顺便说一下,const变量声明在这种情况下也会有所帮助😉)
让我们想象一下,赋值不再是表达式而是语句。后果是什么?
好吧,我们不能再链式赋值了(对我来说听起来像一个好处):
a = 0;
b = 0;
c = 0;
此外,if的问题消失了。
此外,我们还可以潜在地扩展结构化绑定的语法。目前,它只能在声明中使用:
const auto [it, success] = myMap.insert({key, value});
在这种情况下,它等同于以下内容:
const auto result = myMap.insert({key, value});
const auto it = result->first;
const auto success = result->second;
但是如果你的变量已经声明了,这就不会起作用,然而,你仍然可以使用std::tie(),尽管它看起来更神秘,迫使一些不知情的程序员想知道这首先是如何工作的:
std::tie(it, success) = myMap.insert({key, value});
换句话说,作者的意图不太清楚,这使得代码更难理解。
然而,如果赋值是语句,我们可以扩展它们来进行结构化绑定:
[it, success] = myMap.insert({key, value});
这实际上与JavaScript非常相似,在JavaScript中看到这样的代码并不不自然:
[a, b] = [b, a] // 交换变量
默认情况下公共继承? 好吧,我同意,这个想法是有争议的。但是你厌倦了在继承类时写public关键字吗?
class A {};
class B: public A {};
这尤其荒谬,因为对于struct,默认是公共的,这是类和结构体之间的唯一区别。我理解逻辑或过程:在结构体中,默认所有成员都是公共的,所以继承也是公共的。在类中,默认所有成员都是私有的,继承也是私有的。
但在这里,我的“默认问题”又来了:“你多久需要私有或受保护的继承?”我只需要几次,写private并不是一个可怕的负担。此外,我们已经习惯了所有这些“公共视觉混乱”,以至于我们的眼睛在某种程度上变得盲目。所以,当私有出现在继承列表中时,很容易错过。我认为,C#在这方面(大部分)是对的:类的继承默认总是公共的(好吧,在C#的情况下根本没有私有继承),而结构体只是代表一个POD。
现在对此怎么办?
C++不是一种糟糕的语言——它是一种有着糟糕默认值的伟大语言。而且没有一种语言拥有良好的默认值,但至少其中一些正在尝试寻找这些默认值。
我确信,C++还有许多像我上面描述的潜在改进。(默认情况下noexcept?默认情况下=delete复制构造函数?等等。)
但我感觉到你已经渴望向我解释我有多错,以及C++为什么这样做而不是另一种方式。谢谢,我完全意识到“遗留”、向后兼容性等等。但是过分关注过去没有多大意义。更重要的是决定下一步该做什么。
我在语法级别上关心向后兼容性吗?不。我们如今编写C++程序的方式已经发生了巨大变化。对于一个新项目,我只取我能做到的最现代的编译器,并用我能用的最新C++标准编写代码。我们的最新项目是用C++20编写的,所以它甚至不能用稍微老一点的编译器构建。
最新热门文章推荐:
国外CUDA程序员分享:2024年GPU编程CUDA C++(从环境安装到进阶技巧)
国外Python程序员分享:2024年使用Cython加速 Python完整过程
国外Python程序员分享:2024年NumPy高性能计算库(高级技巧)
国外程序员问题:C/C++最佳用途是什么能干什么?请留下您的最佳答案
国外C++程序员分享:2024年为了性能将 Python 与 C/C++ 接口(多个例子分析)
外国人眼中的卢湖川:从大连理工到全球舞台,他的科研成果震撼世界!
外国人眼中的张祥雨:交大90后男神博士,3年看1800篇论文,还入选福布斯精英榜
参考文献:《图片来源网络》
本文使用 文章同步助手 同步