返回两个值,元组 vs 'out' vs 'struct'

110

考虑一个返回两个值的函数。我们可以这样写:

// Using out:
string MyFunction(string input, out int count)

// Using Tuple class:
Tuple<string, int> MyFunction(string input)

// Using struct:
MyStruct MyFunction(string input)

哪种做法是最佳实践,为什么?


String不是一个值类型。我想你的意思是“考虑一个返回两个值的函数”。 - Eric Lippert
1
@lukas:没有什么问题,但这肯定不是最佳实践。这是一个轻量级的值(<16 KB),如果我要添加自定义代码,我会选择像Eric提到的那样使用struct - Xaqron
这里的措辞可能有点多,但我认为在你的情况下,使用KeyValuePair比Tuple更好。 - MikeT
1
我认为只有在需要返回值来决定是否处理返回数据时才使用out,例如TryParse,否则你应该始终返回一个结构化对象。至于结构化对象是值类型还是引用类型取决于您对数据的其他使用方式。 - MikeT
@Lukas,Tuple 是一个类。 - MikeT
显示剩余2条评论
7个回答

116

它们各有优缺点。

输出参数快速且便宜,但需要传递变量,并且依赖于变异。几乎不可能正确地使用带有LINQ的输出参数。

Tuple会产生集合压力1,并且不易自我说明。“Item1”并不是非常描述性的名称。

如果自定义结构体很大,则复制速度可能较慢,但是如果它们很小,则效率很高且易于自我说明。然而,为微不足道的用途定义许多自定义结构体也很繁琐。

其他所有条件相等的情况下,我倾向于使用自定义结构体解决方案。更好的方法是编写只返回一个值的函数。首先,为什么要返回两个值?

请注意,C# 7中的元组在此答案撰写六年后推出,是值类型,因此不太可能产生集合压力。


1每次从堆上分配小对象时,都会对垃圾收集器造成“压力”。压力越大,收集频率就越高。在某些应用程序中,重要的是控制所产生的集合压力量,因此在这些应用程序中不必要地分配几百万个元组可能是一件坏事。当然,像所有性能问题一样,在了解问题的严重程度之前,不要盲目更改。


2
返回两个值通常是没有选项类型或 ADT 的替代品。 - Anton Tykhyy
3
根据我处理其他语言的经验,通常元组用于快速且简单的项目分组。但是,最好创建一个类或结构体,因为这样可以为每个项目命名。使用元组时,每个值的含义可能很难确定。但如果该类/结构体不会在其他地方使用,则可以节省创建类/结构体所需的时间。 - Kevin Cathcart
26
如果你在程序中发现“带有超时的数据”的想法很普遍,那么你可以考虑制作一个通用类型“TimeLimited<T>”,这样你就可以让你的方法返回一个TimeLimited<string>或TimeLimited<Uri>或其他类型。然后,TimeLimited<T>类可以具有一些辅助方法,告诉你“我们还剩多长时间?”或“它是否已过期?”等等。尝试在类型系统中捕捉有趣的语义。 - Eric Lippert
3
当然,我绝不会将元组用作公共接口的一部分。但即使对于“私有”代码,使用适当类型而不是元组可以带来巨大的可读性(特别是通过使用自动属性创建私有内部类型非常容易)。 - SolutionYogi
4
元组确实有其优点:它们是将两个值组合成一个的逻辑原则性方式。引用元组的优势在于可以通过引用进行复制,速度较快。值元组的优势在于它们是值类型,这减轻了集合压力。解决这个问题的方法有很多种;在C# 7中,使用值元组是最常见的方法。 - Eric Lippert
显示剩余8条评论

39

除了之前的回答外,C# 7 还引入了值类型元组,不同于引用类型的 System.Tuple ,它还提供了改进的语义。

你仍然可以将它们命名为空并使用 .Item* 语法:

(string, string, int) getPerson()
{
    return ("John", "Doe", 42);
}

var person = getPerson();
person.Item1; //John
person.Item2; //Doe
person.Item3;   //42

但是这个新特性真正强大的地方在于它可以具有命名元组的能力。因此,我们可以像这样重写上面的内容:

(string FirstName, string LastName, int Age) getPerson()
{
    return ("John", "Doe", 42);
}

var person = getPerson();
person.FirstName; //John
person.LastName; //Doe
person.Age;   //42

解构也得到了支持:

(string firstName, string lastName, int age) = getPerson()


3
我理解您的意思是,这基本上返回一个使用引用作为成员变量的结构体,是这样吗? - Austin_Anderson
6
我们知道使用输出参数与该方法的表现相比如何吗? - SpaceMonkey

23

我认为答案取决于函数的语义和两个值之间的关系。

例如,TryParse 方法采用 out 参数接受解析后的值,并返回一个 bool 指示解析是否成功。这两个值实际上没有太大的联系,因此从语义上讲,使用 out 参数更有意义,代码的意图更容易阅读。

然而,如果您的函数返回屏幕上某个对象的 X/Y 坐标,则这两个值语义上是相关的,最好使用一个 struct

个人建议不要在对外部代码可见的任何地方使用 tuple,因为检索成员的语法很笨拙。


4
实际上,TryParse 中的两个值非常相关,远比一个作为返回值,另一个作为 ByRef 参数所暗示的要更相关。在很多情况下,逻辑上应该返回一个可空类型。有些情况下 TryParse 模式非常方便,但有些情况下却很麻烦(它可以在 "if" 语句中使用,但有许多情况下返回可空值或能够指定默认值会更加方便)。 - supercat
@supercat,我同意安德鲁的观点,它们不应该放在一起。尽管它们是相关的,但返回值告诉您是否需要处理该值,而不是需要同时处理的内容。因此,在处理返回值后,它不再需要进行与输出值相关的任何其他处理,这与从字典返回KeyValuePair不同,其中键和值之间存在明确且持续的链接。尽管我同意如果可空类型在.Net 1.1中已经出现,他们可能会使用它们作为null是标记没有值的正确方式。 - MikeT
@MikeT:我认为微软建议仅将结构用于表示单个值的事物是非常不幸的,因为实际上,具有公开字段的结构是将一组独立变量绑定在一起的理想媒介。成功指标和值在返回时一起有意义,即使在此之后它们作为单独的变量更有用。存储在变量中的公开字段结构的字段本身可用作单独的变量。无论如何... - supercat
@MikeT:由于协变在框架中的支持方式和不支持方式,与协变接口一起使用的唯一“try”模式是“T TryGetValue(whatever, out bool success)”。该方法将允许接口“IReadableMap<in TKey, out TValue> : IReadableMap<out TValue>”,并允许希望将“Animal”的实例映射到“Car”的实例的代码接受“Dictionary<Cat, ToyotaCar>“ [使用”TryGetValue <TKey>(TKey key,out bool success)]”。如果将TValue用作“ref”参数,则无法进行此类变化。 - supercat
@Supercat,我同意结构体在能做的事情上有些限制,无法满足它们本应完美胜任的许多任务。举个例子,当你想要将一个复杂对象传递到一个函数中,在不影响原始对象的情况下对其进行编辑时,使用结构体比生成克隆函数更有意义;或者当一个值对象可编辑但不可为空时。然而,不可变性意味着你无法改变该值,因此类成为唯一的选择。正是这些限制,我认为导致了微软的这一建议。 - MikeT
显示剩余2条评论

3
我将采用使用Out参数的方法,因为在第二种方法中,您需要创建一个Tuple类的对象,然后向其中添加值,我认为这是一种比返回Out参数更昂贵的操作。虽然如果您想在Tuple类中返回多个值(实际上无法通过只返回一个Out参数来完成),那么我会选择第二种方法。

我同意使用 out。此外,为了保持问题的简洁性,我没有提到 params 关键字。 - Xaqron

2
您没有提到另一个选项,即使用自定义类而不是结构体。如果数据具有与之相关的语义,可以通过函数进行操作,或者实例大小足够大(作为经验法则,> 16字节),则可能更喜欢使用自定义类。 在公共API中不建议使用"out",因为它与指针相关,并且需要理解引用类型的工作方式。

https://msdn.microsoft.com/en-us/library/ms182131.aspx

元组在内部使用时很好,但在公共 API 中使用时很麻烦。因此,在公共 API 中,我倾向于使用结构体或类。

1
如果一个类型存在的唯一目的是返回值的聚合,我会说一个简单的公开字段值类型是最清晰的语义适配。如果类型中除了它的字段之外没有任何东西,那么就不会有关于它执行什么样的数据验证(显然没有),它是否表示捕获或实时视图(开放字段结构不能作为实时视图)等问题。不可变类使用起来不太方便,只有在实例可以多次传递时才提供性能优势。 - supercat

1

没有所谓的“最佳实践”。重要的是你自己感觉舒适并且在你的情况下最有效的方法。只要你保持一致,那么你发布的任何解决方案都没有问题。


4
当然它们都能够工作。如果没有技术上的优势,我仍然很想知道专家们最常使用的是什么。 - Xaqron

0
我想对struct和tuple的比较再加一点思考。虽然在C# 7中可以给tuple的项命名,但在从一个tuple赋值给另一个tuple时,名称不会被检查。所以,虽然一个方法可以声明为
(int a, int b) DoSomething() {}

你可以用这个来叫它。
(int b, int a) result = DoSomething();

在元组中更改变量名,例如在方法的返回值声明中,可能会导致语法上正确但语义上错误的代码。在结构体中,成员声明的顺序并不重要,因为你是根据成员名称来赋值的。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接