我一直在研究和尝试 C++ 中各种错误处理范例。
我对强大的错误处理机制的目标是:
- 强制处理错误情况(换句话说,使不处理错误情况变得更加困难)。
- 即,不要
result.value()->do_stuff()
在没有适当检查的情况下让诸如此类的事情发生,而这些检查*result.value()
实际上是存在并且有效的。(我对诸如此类的事情的看法是std::expected
,没有什么可以真正阻止你不打电话result.has_value()
,而是直接继续result.value()
) - 关于异常,没有什么可以阻止你不写
try
和catch
。另外,如果不深入研究库的源代码,你根本不知道会引发什么异常。
- 也许 Java 对于检查异常一直以来都是正确的!
- 我个人喜欢 golang 风格的
result, err != nil; if err != nil {...}
。虽然与 golang 不同,C++ 并不强制使用变量(在 golang 中,如果你忘记处理err
,就会收到编译器错误)。我想你可以打开-Wunused-variable
并将警告视为错误。不过,C++ 实际上没有多个返回值,因此你可以将结果打包到单个变量中并跳过处理错误。
- 即,不要
- 尝试尽可能地利用编译器,而不是相信一切在运行时能够正确进行。
- 接受用户/客户端错误情况是程序的有效状态,并将其编码到程序中,而不是试图将它们埋在地毯下或将其视为脚注
- 能够通用支持复杂的错误数据类型,支持丰富、定制化的错误数据采集和输出。
目前我仅限于 C++17,因此我将在本演示中使用它。下面是Result
我为支持上述范例而提出的一个类:
namespace util {
template<typename Data, typename Error>
class Result {
public:
Result(Data&& data) : _result(std::move(data)) {}
Result(Error&& error) : _result(std::move(error)) {}
using DataFunction = std::function<void(Data&&)>;
using ErrorFunction = std::function<void(Error&&)>;
[[nodiscard]] bool handle(DataFunction&& data_func, ErrorFunction&& error_func) {
if (std::holds_alternative<Error>(_result)) {
error_func(std::move(std::get<Error>(_result)));
return false;
}
data_func(std::move(std::get<Data>(_result)));
return true;
}
private:
std::variant<Data, Error> _result;
};
}
它本质上是一个包装器std::variant
。
您将调用handle()
并提供两个 lambda 函数来“解包”变体,以便真正获取真实数据(或错误)。在data_func
和 中error_func
,您将分别处理Data
和Error
案例。
handle
返回一个布尔值,如果返回了错误状态,则为 false(使用[[nodiscard]]
来阻止忽略布尔值)。布尔值的目的是允许编写早期返回逻辑(因为您无法在 内部执行此操作error_func
)。
为了测试本Result
课程的实用性,我制作了一个小型示例程序,用于解析“假”配置文件格式。该格式只是一堆key=value
由换行符分隔的对。我将使用其中的片段进行解释。
Result
以下是类用法的一个示例:
static util::Result<FakeConfig, InitError> init(const std::string& filename) {
using Reason = InitError::Reason;
std::ifstream file(filename);
if (!file.is_open()) {
return InitError(Reason::INVALID_FILE);
}
std::unordered_map<std::string, std::string> dictionary;
int line_num = 1;
std::string line;
while (std::getline(file, line)) {
if (line.size() > 0) {
std::string key, value;
std::unique_ptr<InitError> error;
if (!split(line, "=").handle(
[&key, &value](std::pair<std::string, std::string>&& tokens) {
std::tie(key, value) = tokens;
},
[](SplitError&& split_error) { }
)) {
return InitError::from_line(filename, line_num, Reason::SYNTAX_ERROR);
}
if (dictionary.find(key) != dictionary.end()) {
return InitError::from_line(filename, line_num, Reason::KEY_ALREADY_DEFINED);
}
dictionary[key] = value;
}
++line_num;
}
return FakeConfig(std::move(dictionary));
}
由于Result
定义Result(Data&&)
和Result(Error&&)
构造函数,我们可以直接返回我们想要的任何类型(对象FakeConfig
或InitError
在本例中)。
以下是调用者如何使用返回类型的函数的示例Result
:
std::unique_ptr<FakeConfig> config;
if (!FakeConfig::init(filename).handle(
[&config] (FakeConfig&& fc) { config = std::make_unique<FakeConfig>(std::move(fc)); },
[&filename] (FakeConfig::InitError&& error) {
std::cerr << "Error initializing from file: " << filename << std::endl;
using Reason = FakeConfig::InitError::Reason;
switch (error.reason) {
case Reason::INVALID_FILE: {
std::cerr << "Invalid file" << std::endl;
break;
}
case Reason::SYNTAX_ERROR: {
std::cerr << "Syntax error on line " << error.line_num << std::endl;
std::cerr << "\t\t" << error.line << std::endl;
break;
}
case Reason::KEY_ALREADY_DEFINED: {
std::cerr << "Key already defined on line " << error.line_num << std::endl;
std::cerr << "\t\t" << error.line << std::endl;
break;
}
}
}
)) {
// early return logic
return -1;
}
// continue main program logic with *config ...
我们必须handle()
返回类型FakeConfig::init()
。我们通过提供data_func
成功案例(这里,我们将FakeConfig
返回类型移到指针中config
)和提供error_func
(这里我们只是根据InitError
返回类型的内容打印出实际的错误消息)来实现这一点。
如果handle()
为假,那么我们就提前返回。否则,我们可以继续*config
。
这是完整代码。使用g++
macOS-std=c++17
进行编译。
#include <cctype>
#include <fstream>
#include <functional>
#include <iostream>
#include <string>
#include <unordered_map>
#include <variant>
namespace util {
template<typename Data, typename Error>
class Result {
public:
Result(Data&& data) : _result(std::move(data)) {}
Result(Error&& error) : _result(std::move(error)) {}
using DataFunction = std::function<void(Data&&)>;
using ErrorFunction = std::function<void(Error&&)>;
[[nodiscard]] bool handle(DataFunction&& data_func, ErrorFunction&& error_func) {
if (std::holds_alternative<Error>(_result)) {
error_func(std::move(std::get<Error>(_result)));
return false;
}
data_func(std::move(std::get<Data>(_result)));
return true;
}
private:
std::variant<Data, Error> _result;
};
}
//
// simple example to demonstrate use case:
// parse a text file containing "key=value" pairs, newline separated.
//
// the file could look like the below:
//
// dog=max
// cat=tom
// tiger=tigger
// donkey=eeyore
//
// this is less trivial than something like division (and checking division by zero or not)
// but otherwise a fairly easy example to understand for demonstrating the Result class
//
namespace {
std::string fetch_line(const std::string& filename, int line_num) {
std::ifstream file(filename);
int l = 1;
std::string line;
while (std::getline(file, line)) {
if (l == line_num) {
return line;
}
++l;
}
return "";
}
struct SplitError {};
util::Result<std::pair<std::string, std::string>, SplitError> split(
const std::string& str,
const std::string& delim
) {
size_t delim_location = str.find(delim);
if (delim_location == std::string::npos) {
return SplitError();
}
std::string key = str.substr(0, delim_location);
std::string value = str.substr(delim_location + delim.size());
if (key.size() == 0 || value.size() == 0) {
return SplitError();
}
return std::make_pair(key, value);
}
bool is_all_alphanum(const std::string& str, size_t start) {
if (start >= str.size()) return false;
for (size_t i = start; i < str.size(); ++i) {
if (!std::isalnum(str[i])) {
return false;
}
}
return true;
}
}
class FakeConfig {
public:
FakeConfig(FakeConfig&&) = default;
struct InitError {
enum class Reason {
INVALID_FILE,
SYNTAX_ERROR,
KEY_ALREADY_DEFINED // disallow re-assignment of keys
} reason;
int line_num;
std::string line;
InitError(InitError&&) = default;
InitError(InitError::Reason r) : reason(r) {}
static InitError from_line(const std::string& filename, int line_num, Reason reason) {
std::string line = fetch_line(filename, line_num);
InitError error(reason);
error.line_num = line_num;
error.line = line;
return error;
}
};
static util::Result<FakeConfig, InitError> init(const std::string& filename) {
using Reason = InitError::Reason;
std::ifstream file(filename);
if (!file.is_open()) {
return InitError(Reason::INVALID_FILE);
}
std::unordered_map<std::string, std::string> dictionary;
int line_num = 1;
std::string line;
while (std::getline(file, line)) {
if (line.size() > 0) {
std::string key, value;
std::unique_ptr<InitError> error;
if (!split(line, "=").handle(
[&key, &value](std::pair<std::string, std::string>&& tokens) {
std::tie(key, value) = tokens;
},
[](SplitError&& split_error) { }
)) {
return InitError::from_line(filename, line_num, Reason::SYNTAX_ERROR);
}
if (dictionary.find(key) != dictionary.end()) {
return InitError::from_line(filename, line_num, Reason::KEY_ALREADY_DEFINED);
}
dictionary[key] = value;
}
++line_num;
}
return FakeConfig(std::move(dictionary));
}
struct GetError {
enum class Reason {
ILLEGAL_KEY,
KEY_NOT_FOUND,
} reason;
std::string key;
GetError(Reason r, const std::string& k) : reason(r), key(k) {}
};
util::Result<std::string, GetError> get(const std::string& key) {
using Reason = GetError::Reason;
bool is_legal_key = key.size() >= 1
&& std::isalpha(key[0])
&& is_all_alphanum(key, 1)
;
if (!is_legal_key) {
return GetError(Reason::ILLEGAL_KEY, key);
}
if (_values.find(key) == _values.end()) {
return GetError(Reason::KEY_NOT_FOUND, key);
}
return std::string(_values[key]);
}
private:
FakeConfig(std::unordered_map<std::string, std::string>&& values) : _values(values) {}
std::unordered_map<std::string, std::string> _values;
};
int main(int argc, char** argv) {
if (argc < 2) {
std::cerr << "usage: ./parser filename" << std::endl;
return -1;
}
std::string filename = argv[1];
std::unique_ptr<FakeConfig> config;
if (!FakeConfig::init(filename).handle(
[&config] (FakeConfig&& fc) { config = std::make_unique<FakeConfig>(std::move(fc)); },
[&filename] (FakeConfig::InitError&& error) {
std::cerr << "Error initializing from file: " << filename << std::endl;
using Reason = FakeConfig::InitError::Reason;
switch (error.reason) {
case Reason::INVALID_FILE: {
std::cerr << "Invalid file" << std::endl;
break;
}
case Reason::SYNTAX_ERROR: {
std::cerr << "Syntax error on line " << error.line_num << std::endl;
std::cerr << "\t\t" << error.line << std::endl;
break;
}
case Reason::KEY_ALREADY_DEFINED: {
std::cerr << "Key already defined on line " << error.line_num << std::endl;
std::cerr << "\t\t" << error.line << std::endl;
break;
}
}
}
)) {
return -1;
}
std::string input;
do {
std::cout << "Enter a key (or ctrl-C to quit): ";
std::cin >> input;
std::cout << "\t";
if (!config->get(input).handle(
[&input] (std::string&& value) { std::cout << input << " -> " << value << std::endl; },
[&input] (FakeConfig::GetError&& error) {
std::cerr << "Could not get key: " << input << std::endl << "\t";
using Reason = FakeConfig::GetError::Reason;
switch (error.reason) {
case Reason::ILLEGAL_KEY: {
std::cerr << "\"" << input << "\" is not a legal key" << std::endl;
break;
}
case Reason::KEY_NOT_FOUND: {
std::cerr << "key \"" << input << "\" was not found" << std::endl;
break;
}
}
}
)) {
// no early return, just continue with the next input
}
std::cout << std::endl;
} while (true);
return 0;
}
一些示例输出
./parser animals.txt
Enter a key (or ctrl-C to quit): tiger
tiger -> tigger
Enter a key (or ctrl-C to quit): donkey
donkey -> eeyore
Enter a key (or ctrl-C to quit): asdfqwef
Could not get key: asdfqwef
key "asdfqwef" was not found
Enter a key (or ctrl-C to quit): asdf@@
Could not get key: asdf@@
"asdf@@" is not a legal key
Enter a key (or ctrl-C to quit): ^C
./parser animals
Error initializing from file: animals
Invalid file
Error initializing from file: animals_error.txt
Syntax error on line 4
tigertigger=
我认为这种错误处理范例可能存在于其他语言中,但我在 C++ 中找不到类似的东西。这可能本质上是临时的模式匹配?
非常欢迎您提供反馈,并指出任何潜在的缺陷。特别是针对Result
类本身和使用示例的反馈。FakeConfig 代码本身只是一个愚蠢的演示程序,用于实际测试这个想法。
\endgroup
最佳答案
2
设计评审
您的设计描述中存在许多事实错误和误解,并且有不少值得怀疑的推理,所有这些都破坏了 的设计Result
。最终结果是几乎在各个方面都比和Result
差。expected
误解
让我们首先澄清一些误解。
误解 1:std::expected
没有任何东西可以阻止你.value()
不经过检查就跟注
这是一个有害的误解,因为它“在技术上是正确的”,但事实上却是错误的。
基本上,有两种方法可以从 a 中获取值std::expected
:
operator*
(或operator->
);或.value()
。
在第一种情况下,阻止您operator*
不加检查(无论是否加.has_value()
检查operator bool
)执行操作的原因是,您可能会触发 UB。如果您operator*
不加检查就执行操作,那么您的程序就是完全错误的。
虽然完全可以……不做……但很难忘记检查。std::expected
不会自动转换为值类型,因此您不能意外访问它。您必须明确执行operator*
,如果您必须采取额外的明确步骤,则很难忘记先检查。我甚至无法想象那会如何发生。您必须非常有意识地选择在访问之前不进行检查,这实际上与检查的返回值没有什么不同.handle()
。
至于.value()
,你只需尝试在出现错误之前不进行检查,然后看看会发生什么。我向你保证,你不会错过出现错误而你没有检查的情况。
换句话说,是的,你可以.value()
不经检查就调用,这在“技术上是真的”。但这是“技术上真的”胡说八道,因为.value()
确实进行了检查。
因此,基本上,如果您不希望程序出现 UB 或崩溃,那么这就是阻止您在expected
未经检查的情况下访问值的原因。而且您不能忘记检查,因为访问是显式的,因此当您记得(或被编译错误提醒)执行访问的额外步骤时,系统会自动提醒您检查。(或者选择不检查,让异常发生。)
误解 2:除了例外情况,“没有什么可以阻止你”不写作try
/catch
这是另一个“技术上正确”的有害误解。
有两种情况需要考虑:
- 您想要处理某种错误(或所有错误);或者
- 您不想处理任何错误。
对于情况 1… 是的,肯定有某种东西阻止您不编写try
块:如果您想处理错误,就不能不编写它。我的意思是,很明显,对吧?如果您编写任何代码来处理错误,如果不在块中,您到底要把它放在哪里try
?
在情况 2 中……当然,你可以不写代码try
块……但这是件好事。我看不出你的系统如何通过强迫程序员在他们不想处理错误时编写多余的、毫无意义的错误处理代码来改善事情。
因此,如果您想要处理错误,阻止您不编写try
块的原因是……您需要编写块来处理错误。如果您不想处理错误……为什么不编写块是一个问题?try
try
误解 3:如果不阅读库的源代码,你根本不知道会抛出什么异常
您从未听说过文档吗?
异常的逻辑是您不需要……也不应该……处理您不知道的异常。
设想一个系统从传感器读取温度值,用它们进行一些计算,并产生一些计算数据。假设首次设置时,传感器是简单的直接连接设备。这些设备可能会发生故障,当它们发生故障时,该get_sensor_value()
函数会抛出sensor_failure
异常。
现在该compute_data()
函数无法处理传感器故障。但是,按照您的逻辑,它仍然需要进行编码以明确处理sensor_failure
异常。至少,您似乎认为它应该宣布它可能会传播sensor_failure
异常,如果没有,那么实际上try
在函数中有一个块来处理它(怎么做?¯\_(ツ)_/¯ 您告诉我;您似乎认为所有错误都必须处理,并且必须在发生错误的地方处理)。
但是好吧,不知何故我们弄清楚了这一切,并compute_data()
按照你认为应该写的方式写出来……这意味着它明确地处理和传播sensor_error
s。
然后修改系统,以便现在可以通过 Wi-fi 或 I²C 接口等查询传感器。现在该get_sensor_value()
函数可能会抛出一个新错误:connection_error
。这是一个明显的错误,因为如果连接不好,我们可以尝试重新连接,并且如果连接良好但传感器不好,我们需要不同类型的警报:“更换传感器”而不是“检查 Wi-fi”。
但现在我们必须完全重写compute_data()
,因为现在它有了一类全新的错误需要处理和传播。如果这是一个安全关键系统,这可能意味着需要进行一系列昂贵的代码审查和测试。
如果使用正确的异常推理正确compute_data()
编写了,那么就不需要任何这些了。它从一开始就不会处理 ,也不需要知道。即使底层系统发生变化,它仍然保持完全相同,并且执行相同操作。sensor_error
connection_error
如果compute_data()
可以处理sensor_error
或connection_error
,那么它就会这样做。如果不能,那么它就不应该这样做。而且它无法处理它们,那么在代码中乱放与该层无关的东西的引用是没有意义的。
这只是一个例子,说明为什么受检异常是一个糟糕的想法。但我本以为它们显然无法正常工作。编写一个带有受检异常的稳定、健壮的库几乎是不可能的,因为你不知道或无法控制子库在异常方面可能做什么。这就是为什么即使 Java 受检异常已经存在,每个人都会这么做throws Exception
(或其他类似的技巧)。
误解 4 & 5:与 golang 不同,C++ 不强制使用变量,并且 C++ 实际上没有多个返回值
。
问题Result
Data
和Error
类型之间的混淆
如果我这样做会发生什么:Result<int, int>
?
为什么不呢?有时(通常是使用 OS 函数)您从函数中得到的唯一错误是int
错误代码。如果您想要的实际值是int
……
好吧,你说,只要禁止对两者使用相同的类型即可。如果你必须为操作系统返回的错误代码制作定制的枚举,或者将它们包装在一个多余的类中,这可能会带来巨大的麻烦,但这是你愿意做出的牺牲。
但…不,仍然不起作用。
要了解原因,想象一下你有一个Result<std::string, std::runtime_error>
…这似乎是一个非常合理的愿望…然后你这样做:
auto result = Result<std::string, std::runtime_error>{"..."};
尽管std::string
和std::runtime_error
是完全不同的类型,尽管如果你用替换任一类型就可以正常工作,但int
这段代码将无法工作。
这就是std::unexpected
存在的原因。
std::unexpected
这不仅使得不可能创建像上面那样的含糊不清的代码,而且还清楚明确地表明您正在创建错误状态。考虑一下:
// assume `using result_t = Result<X, Y>;` is defined somewhere far away.
auto f() -> result_t
{
return 42; // error or not?
}
和std::expected
:
// assume `using result_t = std::expected<X, Y>;`
auto f() -> result_t
{
return 42; // cannot be an error
}
auto g() -> result_t
{
return std::unexpected{42}; // must be an error
}
得到的实际结果是糟糕的人体工程学
对于名为 的类型Result
,从中获取实际结果最终是一场噩梦。
我只是要引用您自己的代码,为了简单起见,我将其精简了一下(这不是一个好兆头,因为我引用的代码已经应该是一个简单的、精简的示例):
std::unique_ptr<FakeConfig> config;
(void)FakeConfig::init(filename).handle(
[&config] (FakeConfig&& fc) { config = std::make_unique<FakeConfig>(std::move(fc)); },
[] (auto&&) {}
);
基本上,为了得到结果Result
,我必须:
- 创建一个占位符变量……因此结果类型要么最好有一个便宜的“默认”状态,要么我必须使用一个
optional
- 通过捕获“成功” lambda 传递对该占位符的引用;然后
- 在 lambda 中分配给该引用。
当然,有了这些,祝你好运,让回报值优化发挥作用。
更糟糕的是,尽管大家都在讨论如何确保错误得到处理,但所有这些繁琐的操作都意味着很容易使用未初始化的结果值。这不仅仅是可能……而且很容易。事实上,你必须非常小心,不要这样做:
auto f() -> Result<std::string, int>
{
return -1;
}
auto result = "i have not been initialized!!!"s;
(void)f().handle(
[&result](auto&& s) { result = s; },
[](auto&&) {}
);
std::println("what do we have here: {}", result);
就地错误处理会使代码变得丑陋,甚至不可读
好的,示例代码的问题是,无论何时需要进行任何错误处理,它总是在调用时立即完成。这是不现实的。
以split()
函数为例。它接受一个字符串和一个分隔符,并根据分隔符的第一次出现将字符串拆分为两个。它可能以几种方式失败,因此它返回一个Result<string, SplitError>
。
现在它被用在了 中FakeConfig::init()
,它看起来像这样:
if (!split(line, "=").handle(
[&key, &value](std::pair<std::string, std::string>&& tokens) {
std::tie(key, value) = tokens;
},
[](SplitError&& split_error) { }
)) {
return InitError::from_line(filename, line_num, Reason::SYNTAX_ERROR);
}
正如您所见,如果出现错误,则会在调用点立即处理。
在实际代码中,这种情况很少发生。更可能的情况是,split()
它会被埋在某种没有文件名或行号的行解析函数中。该函数将进行拆分,然后使用结果,或者让错误冒出来,就像上面的代码一样。这意味着该函数也需要if
像上面那样的大块。任何更深层次的函数也是如此。事实上,代码的编写方式是一种作弊行为,因为split()
不应该知道前后部分为空是错误的。将它们全部放在一起会隐藏如果逻辑被拆分并且错误处理必须一遍又一遍地重复,代码看起来会多么可怕。
这就是两个层次的深度Result
:
auto split(std::string_view s, std::string_view delim)
-> Result<std::pair<std::string_view, std::string_view>, parse_error>
{
if (auto const pos = s.find(delim); pos != std::string_view::npos)
return std::pair{s.substr(0, pos), s.substr(pos + delim.size())};
else
return parse_error::missing_delimiter;
}
auto parse_setting(std::string_view line)
-> Result<std::pair<std::string_view, std::string_view>, parse_error>
{
auto k = std::string_view{};
auto v = std::string_view{};
auto err = parse_error{};
if (not split(line, "=").handle(
[&k, &v](auto&& p) { std::tie(k, v) = p; },
[&err](auto&& e) { err = e; }
))
{
return err;
}
if (k.empty())
return parse_error::empty_key;
if (v.empty())
return parse_error::empty_value;
return std::pair{k, v};
}
auto parse_config(std::istream& in)
-> Result<std::unordered_map<std::string, std::string>, parse_error>
{
auto settings = std::unordered_map<std::string, std::string>{};
for (auto line = std::string{}; std::getline(in, line); /*nothing*/)
{
auto k = std::string_view{};
auto v = std::string_view{};
auto err = parse_error{};
if (not parse_setting(line).handle(
[&k, &v](auto&& p) { std::tie(v, v) = p; },
[&err](auto&& e) { err = e; }
))
{
return err;
}
settings.emplace(k, v);
}
return settings;
}
(我故意在上面打错了一个字。你发现了吗?)
看一下这种代码的样子std::expected
:
auto split(std::string_view s, std::string_view delim)
-> std::expected<std::pair<std::string_view, std::string_view>, parse_error>
{
if (auto const pos = s.find(delim); pos != std::string_view::npos)
return std::pair{s.substr(0, pos), s.substr(pos + delim.size())};
else
return std::unexpected{parse_error::missing_delimiter};
}
auto parse_setting(std::string_view line)
-> std::expected<std::pair<std::string_view, std::string_view>, parse_error>
{
return split(line, "=")
.and_then([](auto&& p) -> std::expected<std::pair<std::string_view, std::string_view>, parse_error>
{
if (std::get<0>(p).empty())
return std::unexpected{return parse_error::empty_key};
if (std::get<1>(p).empty())
return std::unexpected{return parse_error::empty_value};
return p;
}
;
}
auto parse_config(std::istream& in)
-> std::expected<std::unordered_map<std::string, std::string>, parse_error>
{
auto settings = std::unordered_map<std::string, std::string>{};
for (auto line = std::string{}; std::getline(in, line); /*nothing*/)
{
if (auto r = parse_setting(line); r)
settings.emplace(std::get<0>(*r), std::get<1>(*r));
else
return std::unexpected{r.error()};
}
return settings;
}
是的,最低级别的函数基本相同……但看看那些想要传递错误的高级函数。它们的长度几乎是前者的一半,而且与Result
版本不同,更难出错。(您是否注意到我在示例中的未初始化键parse_setting()
?Result
)函数中的几乎所有代码都是实际的业务逻辑,而不是错误处理。好吧,其中一些在检测到问题时会生成错误,但几乎没有任何代码只是传播错误。实际上 1-2 行——else
和中return
的parse_config()
是所有Result
错误传播代码。相比之下,代码中大约三分之一的代码只是用于将错误传递到链上。
现在有例外(假设使用<system_error>
):
auto split(std::string_view s, std::string_view delim) -> std::pair<std::string_view, std::string_view>
{
if (auto const pos = s.find(delim); pos != std::string_view::npos)
return std::pair{s.substr(0, pos), s.substr(pos + delim.size())};
else
throw std::system_error{std::error_code{parse_error::missing_delimiter}};
}
auto parse_setting(std::string_view line) -> std::pair<std::string_view, std::string_view>
{
auto const [k, v] = split(line, "=");
if (k.empty())
throw std::system_error{std::error_code{return parse_error::empty_key}};
if (v.empty())
throw std::system_error{std::error_code{return parse_error::empty_value}};
return {k, v};
}
auto parse_config(std::istream& in) -> std::unordered_map<std::string, std::string>
{
auto settings = std::unordered_map<std::string, std::string>{};
for (auto line = std::string{}; std::getline(in, line); /*nothing*/)
{
auto const [k, v] = parse_setting(line);
settings.emplace(k, v);
}
return settings;
}
看不到任何错误传播代码。
(请注意,我不建议使用异常来解析代码,因为解析错误几乎不算异常。但对于错误属异常的代码,这是方法。)
代码审查
Result(Data&& data) : _result(std::move(data)) {}
Result(Error&& error) : _result(std::move(error)) {}
为什么要通过右值引用来获取参数?如果我想将一些数据复制到结果中,而不是移动它,该怎么办?这并不是一件奇怪的事情。
using DataFunction = std::function<void(Data&&)>;
using ErrorFunction = std::function<void(Error&&)>;
[[nodiscard]] bool handle(DataFunction&& data_func, ErrorFunction&& error_func) {
if (std::holds_alternative<Error>(_result)) {
error_func(std::move(std::get<Error>(_result)));
return false;
}
data_func(std::move(std::get<Data>(_result)));
return true;
}
为什么要使用std::function
,而不只是接受任意可调用函数?而且,为什么要到处强制使用右值参数?
template<DataFunction, ErrorFunction>
[[nodiscard]] bool handle(DataFunction&& data_func, ErrorFunction&& error_func) {
if (std::holds_alternative<Error>(_result)) {
std::forward<ErrorFunction>(error_func)(std::get<Error>(_result));
return false;
}
std::forward<DataFunction>(data_func)(std::get<Data>(_result));
return true;
}
std::forward()
拨打电话时请注意使用。
当然,在 C++20 及更高版本中,您可以将模板参数限制为可调用的。但对于 C++17,必须这样做。
鉴于Data
和Error
可能是同一类型,std::holds_alternative<Error>
这不是一个好主意。为了安全起见,您应该使用.index()
或.get_if()
和索引。
另外,不要移动参数。如果你这样做,那么如果你.handle()
对同一个结果调用两次,就会引入 use-after-move 的可能性。你可以做的是在 上使用右值引用限定符.handle()
,并在右值上调用 时移动参数.handle()
。但一般来说,除非你要销毁或重新分配它(或者它已经是一个右值,所以其他人正计划销毁或重新分配它),否则永远不要移动任何东西。你没有用 做这两件事_result
,所以在这里移动它是不安全的。
\endgroup
4
-
\begingroup
我将把这个标记为答案,因为它直接回答了我的许多担忧,全面指出了陷阱,并提供了详细的std::expected
使用示例。现在将尝试在我的 C++17 代码中使用std::variant
或第三方expected
类,谢谢!评论有字符限制,所以我会写更多来回复。
\endgroup
–
-
\begingroup
顺便说一句,关于误解 4 和 5 的示例代码,我应该指定对未使用值的编译时result, err := run()
检查。在 golang 中,如果您执行了,但忘记使用err
,则会收到编译器错误,而不是运行时错误。当然,result, _ := run()
如果您愿意(或需要),也可以忽略错误,但很明显,run()确实返回了错误类型,并且故意不处理错误是明确的。
\endgroup
– -
\begingroup
关于误解 3,您说的基本上是异常机制背后的原因是,我们不应该到处编写“错误处理”逻辑,而应该“集中”我们在代码库中处理错误的位置,只让“内层”处理主要业务逻辑,让潜在异常上升到“错误处理层”。这个理由正确吗?
\endgroup
–
-
\begingroup
“你必须明确地做operator*
,如果你必须采取额外的明确步骤,那么很难忘记先检查。” => 我非常不同意这一点。也许在你写的时候你会考虑这一点,但老实说,我不会指望它。重构甚至更棘手,在改变类型和移动代码之间。除非你知道好的 linters 会标记这样的用法,否则事实上很容易在无意中触发 UB。
\endgroup
–
|
您的类确实确保DataFunction
只有当Data
变量中实际存储了时才会调用。但是,它有很多缺点:
关于稳健性
我对强大的错误处理机制的目标是:
- 强制处理错误情况
遗憾的是,我们经常不编写错误处理,不是因为我们忘记了,而是因为我们不想花任何精力去做,不管出于什么原因(包括正当的原因)。所以如果你想要强制处理错误,唯一的办法就是编写一个合适的错误处理程序比不编写合适的错误处理程序更省力。
击败强制执行的简单方法是编写一个空的 lambda:
util::Result<SomeData, SomeError> result = some_function(…);
(void)handle([](SomeData&& data){…}, [](auto){});
您的Result
类使用起来也很麻烦;您必须传入一个函数对象来处理有效状态,即使您传入了真正的错误处理程序,您仍然必须处理 的返回值handle()
。这需要更多行代码,犯错的可能性也更大。
- 至于异常,没有什么可以阻止你不写try和catch。
没错,但是如果您不抛出catch
异常,它将确保程序终止,而不是忽略该问题。根据情况,这种行为实际上可能更加稳健。std::expected
如果您尝试使用 if none 访问数据,则会抛出异常.value()
,因此不会忽略对它的错误处理。
不要无条件地移动数据
构造函数Result
采用右值引用并无条件地std::move()
对其进行赋值是一个坏主意。很容易忘记这个动作,然后你最终可能会写出这样的代码:
auto some_function() {
SomeData data = …;
Result<SomeData, SomeError> result = data; // moves
do_something_else(data); // UB?
return result;
}
访问已移出的对象可能是未定义的行为。您需要确保仅在明确请求时才移出数据。最好的方法是使用转发引用。我建议您查看 的以了解它如何处理这个问题。
使用std::move()
insidehandle()
也是有问题的。如果这个函数被调用两次会怎么样?同样,只有在明确请求时才会移动。再看看。另外:
handle()
为const
物体做工作
该语言的一个安全特性是您可以声明某些东西const
,这样您就不会意外修改它。您可能希望对结果对象执行相同操作。但是,由于您没有const
合格的重载handle()
,因此您无法在您的类中使用该安全特性。
查看std::expected
其他有用的功能
如果您希望使用它来代替,最好提供该类型所具有的所有功能,例如value_or()
、等等。此外,您还缺少大多数其他类型中存在的许多其他基本功能,例如赋值运算符、比较运算符、error_or()
等等。and_then()
swap()
的另一个特点是std::expected
您可以将非错误类型设置为void
。
\endgroup
|
|