从C++函数返回多个值

作者:Eli Bendersky

http://eli.thegreenplace.net/2016/returning-multiple-values-from-functions-in-c/

因为C++没有内置的从函数或方法返回多个值的语法,程序员在需要时使用各种技术来模拟之,而自C++11的引入,这个数量进一步提升。在本文里,我想提供我们今天所拥有的从函数返回多个值的某些选择的一个概况,以及语言里未来可能的方向。

引子——为什么需要多个返回值?

从函数返回多个值不是一个新的编程概念——某些古老而庄严的语言像Common Lisp自1980早期就支持。

有许多场景返回多个值是有用的:

首先及最重要的,对于天然具有多个值要计算的函数。例如,CommonLisp的floor函数计算两个操作数的商及余数,并返回两者。另一个例子是C++11里的std::minmax,它同时在一个容器里查找最小与最大值。

其次,在函数操作的数据结构里每项包含多个值时,返回多个值是有益的。例如,Python 3的dict.items是键/值对上的一个迭代器,每次迭代返回两者,而这通常是有用的。类似的,在C++里映射容器家族提供了保存键/值对的迭代器,以及类似std::map::find的在逻辑上返回一个对的方法,尽管这个对被封装在一个迭代器对象中。另一个相关的,但稍有不同的例子是Python的enumerate,它接受任意序列或迭代器,并返回索引/值对——对编写某些for循环非常有用。

第三,多个返回值可以标志不同的“路径”——像除了实际值以外的错误条件或“找不到”标记。在Go里,map查找返回一个值/查找结果对,其中“查找结果”是一个布尔标记,指示在map里是否找到该键值。通常,在Go里,从函数返回一个值/错误标记对是惯用手法。这个方法在C++里也是有用的,在下一节我将讨论一个例子。

多返回值是如此便利,即使在不直接支持它们的语言里,程序员通常会想办法模拟它们。至于新的编程语言,它们中的多数原生支持这个特性。Go,Swift,Clojure,Rust以及Scala都支持多返回值。

以输出参数在C++中实现多返回值

回到C++,让我们以最古老,可能仍然是最常见的方法开始我们的探索——使用一些函数参数作为“输出”参数。C++(基于之前的C)通过严格区分函数值传递与引用传递参数来实现这个方法。由指针传递的参数可以用于向调用者“返回”值。

这个技术源在C中历史悠久,在标准库里它被用于许多地方;例如fgets与scanf。许多POSIX函数采纳了返回一个整数“错误代码”(0表示成功)的惯例,同时将它们具有的任何输出写入一个输出参数。例子数不胜数——gettimeofday,pthread_create……有上百(设置上千)。这已经成为如此常用的惯例,某些代码库对输出参数采用了特殊的记号,以注释或伪宏的形式。这是为了在函数声明里区分通过指针的输入参数与输出参数,因此向用户声明了哪个是哪个:

#define OUT

 

int myfunc(int input1, int* input2, OUT int* out) {

   ...

}

C++在标准库里也采纳了这个技术。一个好的例子是std::getline函数。下面是我们如何从stdin读入并以一个前缀回显每一行:

#include <iostream>

#include <string>

 

int main(int argc, constchar** argv) {

  std::string line;

  while(std::getline(std::cin, line)) {

    std::cout << "echo: "<< line << "\n";

  }

  return 0;

}

Std::getline书写它读入到第二个参数的行。它返回输入流(第一个参数),因为一个C++流在布尔上下文里具有有趣的行为。只要一切都好,它是true,但一旦发生错误或到达文件结尾,就会成为false。上述例子在一个while循环的条件里紧凑地调用std::getline用到了后者。

C++引入的引用类型在C做法的基础上增加了一种选择。对输出参数我们使用指针还是引用呢?一方面引用导致更简单的语法(如果上面的代码里行必须通过指针传递,在调用里我们将不得不使用&line),并且不可能是nullptr,这对于输出参数很重要。另一方面,使用引用,在看一个调用时,区分哪些是输入参数,哪些是输出参数是非常困难的。同样,nullptr实参也是好坏参半——偶尔它可用于告诉被调用者某个输出是不需要的,在输出参数中传递nullptr是一个通常的做法。

因此,某些编程指引建议对输出参数只使用指针,而对输入参数使用const引用。但正如风格的所有争议,这是因人而异的(but as with all issues ifstyle, YMMV)。

不管你选择了什么风格,这个做法有明显的缺点:

·        输出值不统一——一些是返回的,一些是参数,不太容易搞清楚哪些参数用于输出。Std::getline足够简单,但当你的函数有4个参数并返回其中3个值时,你的心里开始长草了。

·        调用要求事先声明输出参数(比如上面例子中的line)。这使代码臃肿。

·        更糟的,在函数调用里将参数声明与其赋值,在某些情形下分离会导致未初始化的值。在上面的例子里要分析line是否被初始化了,必须仔细地理解std::getline的语义。

另一方面,在引入C++11的移动语义之前,比起其他做法,这个形式有可观的性能优势,因为它可以避免额外的拷贝。在本文稍后我将更进一步讨论它。

对与元组(pairs and tuples

类型std::pair是C++里的老兵了。在标准库中它被用在很多地方,用作诸如保存有映射关系的键与值,或者保存“状态,结果”对。下面是展示两者的一个例子:

#include <iostream>

#include <unordered_map>

 

using map_int_to_string =std::unordered_map<int, std::string>;

 

voidtry_insert(map_int_to_string& m, int i, const std::string& s) {

 std::pair<map_int_to_string::iterator, bool> p = m.insert({i,s});

 

  if (p.second) {

    std::cout << "insertion succeeded. ";

  } else {

    std::cout << "insertion failed. ";

  }

 

  std::cout << "key=" <<p.first->first << "value=" <<p.first->second << "\n";

}

 

int main(int argc, constchar** argv) {

  std::unordered_map<int, std::string>mymap;

  mymap[1] = "one";

 

  try_insert(mymap, 2, "two");

  try_insert(mymap, 1, "one");

 

  return 0;

}

方法std::unorderd_map::insert返回两个值:一个元素迭代器,一个表示请求对是否插入的布尔标记(如果该键已经存在map里,就不会插入)。使得这个例子真正有趣的是这里返回的嵌套多值。Insert返回一个std::pair。但该对的第一个元素,迭代器,只是另一个对——键/值对的一层薄封装——因此在打印值时我们使用first->first与first->second进行访问。

这样我们也有std::pair缺点的一个例子——first与second的含糊不清,它要求我们总是要记住在对里值的相关位置。P.first->second获取了完成的工作,但它称不上可读代码的典范。

使用C++11,我们有另一个选择——std::tie:

voidtry_insert_with_tie(map_int_to_string& m, int i, const std::string& s) {

 map_int_to_string::iterator iter;

  bool did_insert;

  std::tie(iter,did_insert) = m.insert({i, s});

 

  if (did_insert) {

    std::cout << "insertion succeeded. ";

  } else {

    std::cout << "insertion failed. ";

  }

 

  std::cout << "key=" <<iter->first << " value=" << iter->second << "\n";

}

现在我们可以向对成员给出可读的名字。当然,这个做法的坏处是,我们需要占据额外空间的独立声明。同样,在原来的例子里我们可以使用auto来推导对的类型(对于复杂的迭代器是有用的),在这里我们不得不完整地声明它们。

对用于返回两个值,但有时我们需要更多返回值。C++11引入的可变参数模板使得向标准库添加一个泛型元组类型最终成为可能。Std::tuple是std::pair对多个值的推广。下面是一个例子:

std::tuple<int, std::string, float> create_a_tuple() {

  return std::make_tuple(20,std::string("baz"), 1.2f);

}

 

int main(int argc, constchar** argv) {

  auto data =create_a_tuple();

  std::cout << "the int: "<< std::get<0>(data) << "\n"

            << "the string: "<< std::get<1>(data) << "\n"

            << "the float: "<< std::get<2>(data) << "\n";

 

  return 0;

}

模板std::get用于访问元组成员。同样,这不是最友好的语法,但我们可以使用std::tie改善之:

int i;

std::string s;

float f;

std::tie(i, s, f) = create_a_tuple();

std::cout << "theint: " << i << "\n"

          << "the string: "<< s << "\n"

          << "the float: "<< f << "\n";

其他方法是使用更神奇的模板元编程来创建一个“具名”元组(类似于Python的namedtuple类型)。这里是一个例子。虽然对此没有标准答案。

结构体

在面对复杂的“具名元组”实现时,老前辈们轻蔑地哼了一声并提醒我们,在过去C的时候这个问题已经有了一个完美有效的解决方案——结构体。下面最后的例子以一个结构体重写:

struct RetVal {

  int inumber;

  std::string str;

  float fnumber;

};

 

RetVal create_a_struct() {

  return {20, std::string("baz"), 1.2f};

}

 

// ... usage

 

{

  // ...

  auto retvaldata =create_a_struct();

  std::cout << "the int: "<< retvaldata.inumber << "\n"

            << "the string: "<< retvaldata.str << "\n"

            << "the float: "<< retvaldata.fnumber << "\n";

}

在创建一个返回值时,语法简洁、漂亮。如果某些域的缺省值足够好(或者该结构体有部分域初始化的构造函数),我们甚至可以忽略这些域。还要注意到访问返回值的域是多么自然:所有的域都有描述性的名字——这很好!这里C99走得更远,允许结构体域的具名初始化语法:

RetVal create_a_struct_named() {

  return {.inumber = 20, .str= std::string("baz"), .fnumber = 1.2f};

}

对于每次希望解码一个值时,不用强迫你去窥视RetVal类型定义的自描述代码这非常有用。不幸地,即使你的C++编译器支持这,它不是标准C++,因为C++没有采纳这个特性。显然,存在要添加它的积极的提议,不过它没有被接受;至少目前没有。

C++委员会,AFAIU的理由是倾向于构造函数来初始化结构体域。仍然,因为C++函数没有具名参数语法(Python术语的“keyword argument”),这里使用构造函数将不会有更多的可读性。虽然它允许便利地非0值缺省初始化值。

例如:

struct RetValInitialized {

  int inumber = 17;

  std::string str = "foobar";

  float fnumber = 2.24f;

};

 

RetValInitialized create_an_initialized_struct() {

  return {};

}

或者甚至更有趣的,带有一个构造函数的初始化模式:

struct RetValWithCtor {

  RetValWithCtor(int i)

    : inumber(i), str(i, 'x'), fnumber(i) {}

 

  int inumber;

  std::string str;

  float fnumber;

};

 

RetValWithCtor create_a_constructed_struct() {

  return {10};

}

这也将是一个简短处理我之前提到的性能问题的好地方。在C++11里,几乎可以肯定按值返回的结构体,归因于返回值优化机制,不会被实际拷贝。结构体中std::string值也不会被拷贝。至于更多细节,参考C++11标准的12.8节,这样开头的段落:

在满足一定条件时,允许一个实现忽略一个类对象的拷贝/移动构造,即使该拷贝/移动函数以及/或者析构函数有副作用。在这样的情形下,实现只是将被忽略拷贝/移动操作的源及目标对象视为援引同一个对象的两个不同方式,而该对象的析构发生在,在没有优化时这两个对象析构后的,稍后时间

标准称之为拷贝消除(copy elision)。

结构化绑定:C++17的新希望

幸运地,C++标准委员会都是些聪明脑袋,他们已经意识到即使C++有许多种方法返回多个值,没有一个是真正完美的。因此对语言的C++17版本,有一个称为结构化绑定的新提案正在寻求录用。

简而言之,想法是支持一个将使得返回元组结果函数更容易维系的新语法。回想上面的讨论,虽然从函数返回元组具有相当便利的语法,在笨拙的std::get调用或预声明以及std::tie之间选择,接收方的情形远谈不上理想。

所提出的提议是下列用于接收由create_a_tuple返回元组的语法:

auto {i, s, f} =create_a_tuple();

// Note: proposed C++17 code, doesn't compile yet

i,s与f的类型由编译器从create_a_tuple的返回类型自动推导。另外,C++17另一个改进是允许一个更短的元组创建语法,消除了std::make_tuple,使得它像创建结构体那么简洁:

std::tuple<int, std::string, float> create_a_tuple() {

  return {20, std::string("baz"), 1.2f};

}

// Note: proposed C++17 code, doesn't compile yet

结构化绑定的提议也用于返回结构体值,不只是元组,因此我们可以这样做:

auto {i, s, f} =create_a_struct();

我希望这个提议能被接受。它将使得代码更加友好,而没有带来编译器与运行时的代价。

结论

如此多的可能性,该怎么选?因为我个人相信代码可读性比快速组合更为重要,我喜欢在结构体里封装多个值的显式方法。当返回值在逻辑上是一起时,这是以一个自然的自描述方式将它们收集起来的好方法。因此这将是我最常使用的方法。

也就是说,有时返回的两个值在逻辑上毫无关联——比如getline例子中的流与字符串。让只用一次的称为StreamAndResult或OutputAndStatus结构体类型充斥源代码远谈不上理想,因此在这些情形下实际上我会考虑std::pair或std::tuple。

不言而喻,在C++17中提议的结构化绑定使得所有这一切更容易编写,使得亲们不那么反感绕口的元组。

  • 5
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值