在.NET中,将数组或列表作为属性返回是否可行?

23

我正在阅读有关在何时使用属性和方法的MSDN文档,其中涉及到一条规则引起了我的质疑:

如果“操作返回一个数组”,则应该使用方法(而不是属性)。

页面链接: 选择属性和方法之间

如果操作返回一个数组,则使用方法,因为要想保留内部数组,你必须返回数组的一个深拷贝,而非属性使用的数组的引用。这个事实与开发人员将属性视为字段相结合,会导致非常低效的代码。

我理解属性的get方法会返回对数组的引用,这将允许修改数组即使没有set。在他们给出的示例中,每次访问属性都会进行一次深拷贝,我想是为了避免此情况发生,但这反过来又非常低效。

如果属性只返回引用而不做所有复制,那么它就不会低效了,是吗?而且,使用方法而不是属性并不能自动保护列表免于被修改。这几乎是相同的情况,你仍然需要进行深拷贝。

使用属性并仅返回对数组的引用总是不好的习惯吗?如果你希望调用程序员能够修改数组,或者你不关心他们是否修改它,那么这样做是否仍然不好,并且为什么?如果真的不好,那么允许调用者进行修改的适当方式是什么?


您可能也会对Eric Lippert的文章《数组被认为是有些有害的》感兴趣,因为它涉及到了这个确切的场景。 - Daniel Pryden
6个回答

10
你能允许调用者通过属性修改内部数组吗?当然可以,但是你将面临一系列可能的问题。如何处理这些问题以及你能接受什么是由你决定的。
MSDN的建议在非常严格的意义上是正确的。话虽如此,我之前见过从类中返回List<T>T[]属性。如果你的类是一个非常简单的POCO,那么这不是一个大问题,因为这些类只是原始数据,没有真正影响业务逻辑的东西。
话虽如此,如果我返回一个列表,并且不希望任何人干扰内部列表,我要么每次返回一个深拷贝,要么返回一个ReadOnlyCollection或迭代器。例如,在许多地方缓存网络服务请求调用时,当我返回一个缓存项时,我不希望调用者修改那个数据,否则他们将修改我正在缓存的内容。因此,在那里我做深度复制(这仍然比网络服务调用的开销快)。
你只需要知道你的使用是否需要安全性。这个类只是用于内部消费吗?还是设计成被更广泛的观众消费,而你不知道他们会用它做什么?这些类型的问题可能会驱动你的回应。
对于一个"取决于情况"的答案,我很抱歉,但这确实取决于你的目标以及类的内部是否敏感于变化。
更新:你也可以返回一个迭代器,我建议避免使用IEnumerable作为超类向上转换,因为它可以被转换回来,但如果你返回一个迭代器(比如使用Skip(0)),你是安全的(当然还能修改包含的对象)。例如:
public IEnumerable<T> SomeList 
{
    get { return _internalList.Skip(0); }
}

比起:

更好的是:

public IEnumerable<T> SomeList
{
    get { return _internalList; }
}

因为后者仍然可以转换回 List<T> 或其他类型,而前者是迭代器,无法修改。


谢谢你提供有关ReadOnlyCollection的信息,我以前从未听说过它,但它确实非常好用。下次我不想让任何人搞乱列表时,我会记住这个方法的。这是一个很好的提示。对于我所处理的大部分事情,我实际上并不希望人们修改列表本身,但他们可以设置它们从来没有真正困扰过我,而且从方法返回数组并不能自动保护它免受修改。对我来说,属性就是一种方法。感谢您的回复。 - Chris Mullins
是的,我的意思是我也有这个问题,到目前为止它还没有引起任何问题,但我不知道有一种简单的方法可以使列表只读。ReadOnlyCollection会导致任何性能损失吗? - Chris Mullins
有一点。它将IList<T>包装在一个包装类中,因此在委托调用到底层的IList<T>时会稍微增加一些开销。 - James Michael Hare
6
ReadOnlyCollection不能保护包含对象的修改,而深拷贝可以。假设你有一个ReadOnlyCollection<MyObject> cantTouchMe,你仍然可以对其进行修改,例如:cantTouchMe[0].SomeProperty = somethingElse。它只是防止向集合中添加或删除元素。 - Davy8
1
@Chris:正如我所说,我并不否认它可能很有用,但如果随意使用,它也可能非常昂贵。就个人而言,我认为如果我们能像C++一样拥有一个常量引用的概念会更好。这样就不需要复制,但也无法调用可变成员。 - James Michael Hare
显示剩余11条评论

2

这几乎总是一件不好的事情,但它并不能阻止人们这么做(包括我在内)。 这是使用适当的用例场景的问题。

然而,保护数组封装最简单的方法是仅公开IEnumerable或ICollection以及修改内部数组的方法。 请参见以下示例。 我在此处使用了List,因为语义更简单。

class YourClass {
    List<string> list = new List<string>();
    public IEnumerable<string> List {
        get { return list; }
    }
    public void Add(string str) {
        list.Add(str);
    }
}

由于返回类型为IEnumerable/ICollection,因此除了通过类中的方法/属性进行操作之外,没有其他改变内部状态的可能性。
最终的封装是在属性访问时复制整个集合,但对于大多数情况来说这是低效的。
从语义上讲,没有太多代价、幂等且高效的东西应该被分配一个属性。改变数据或具有高执行成本的东西应该是一个方法。

这也是一个不错的提示。我从未想过返回IEnumberable的好处之一。我认为ICollection并不能提供完全的保护,因为仍然可以通过clear、add和remove更改底层列表。感谢您的回复。 - Chris Mullins

2
如果您的类的内部状态不依赖于您公开的数组或列表,则可以将其原样返回。因此,更改列表或数组将无法破坏您的类。
如果您信任调用代码(即由您编写),并且它可以为您提供性能(可测量且与您的应用相关),那么也可以这样做。
如果您想要只读访问权限,则可以返回一个封装在要公开的列表或数组周围的“ReadonlyCollection”。

2

你的想法是正确的,如果你仅从属性获取现有的数组而不进行复制,那么这已经不再是一个效率问题,因为这样做与返回任何其他引用类型一样高效。

至于它是否是“不良实践”,这在很大程度上源于他们所描述的原因:即使您只在属性上提供了get访问器,用户仍然可以修改数组的内容,从而改变了类的状态,而您的类没有办法验证这些更改--最糟糕的是,除非您已经具有足够深入的C#数组语义知识,否则这种设计的后果并不是非常明显。

如果您可以接受用户能够更改数组的内容,并且如果您可以接受您的类没有验证或防止这些更改的方法,那么返回数组引用可能是可以接受的,但请确保记录该决策及其背后的原理。

然而,除非您真正需要原始数组的性能,否则最好让属性返回集合类实例。您类的使用者仍然可以使用相同的语法按索引引用项目,但您保留了使返回的集合不可变或提供更新验证的功能。


我理解他们的论点,但我并不完全认同。同样的道理也适用于暴露任何可变类。如果你暴露一个可变类,你可以修改它的属性,这同样适用于List,因为它是一个可变类。 - Davy8
我同意,返回一个List<T>与返回一个T[]有相同的可变性问题。我看到的关键区别是,如果我设计一个API来返回List<T>,我可以在未来的版本中开始返回List<T>的子类来实现附加功能,而使用T[]无法实现这一点。(就个人而言,我认为返回List<T>太过于不合适了;我通常选择ICollection<T>或IList<T>,具体取决于客户端的预期需求,这样我就可以随时在将来的版本中更换基础类。) - Timothy Fries
@Timothy:不要忘记数组也实现了 IList<T>,因此返回一个 IList<T> 可以让你选择返回 数组 还是集合对象。 - Daniel Pryden

1

使用属性返回数组或集合通常并不是一个坏习惯。特别是如果每次都返回相同的缓存实例。但是,如果每次都创建一个新实例,那么一个名为CreateItems()的方法会更清晰地表明这一事实。


1

我不同意你想要“保留内部数组,必须返回数组的深拷贝,而不是属性所使用的数组的引用”的前提。

事实上,如果属性确实返回了深拷贝,那会相当"惊人"

有时候,确实需要保留内部结构,只返回它的只读视图(以 IEnumerable<T> 的形式),但这与属性无关。

他们认为,对外部世界而言,属性应该像字段一样操作,并允许修改。如果他们说把数组/列表作为字段是不好的做法,那就完全是与属性无关的另外一个问题。


我并不认为这会让我感到惊讶。那么,关于一个返回内部值的过滤集合的“视图”属性呢?你有三个选择:(1)使过滤集合不可修改;(2)在新的可修改集合中返回过滤集合;或者(3)将过滤集合作为“实时”视图,将修改传递给父集合。这些选择中没有一个比其他选择更令人惊讶;它们中也没有一个特别直观。 - Daniel Pryden
关于“属性应该像字段一样工作”的问题:属性的整个意义在于它们不必像字段一样工作。也就是说,它们可以是合成的,可以包括验证,可以实现各种缓存策略。如果您只是使用属性来获得公共字段般的行为,那么您可能还不如直接使用公共字段。 - Daniel Pryden
缓存是不应该泄漏的实现细节,因此从使用端来看,它就像一个字段。对于验证,我反复思考,只要在项目/组织内保持一致,无论是始终允许还是始终禁止在getter和setter中编写验证代码,我都可以接受。 - Davy8
就“视图”属性而言,它必须以一种暗示它是“视图”的方式命名。如果我调用myFarm.Animals.Add(new Pig());,我希望底层集合得到更新。此外,如果我调用myFarm.Animals.First(a=>a.Name == "Bob").Name = "Bill";,我希望修改的是实际的底层对象,而不仅仅是对象的深拷贝。 - Davy8
即使过滤集是不可修改的,它也可能不清楚它代表了实时视图还是快照。如果它是可修改的,试图修改它可能会一致地修改基础数据,安全地不修改实时数据(例如,如果它是可修改的快照),但也许可以在修改后方便地将其内容复制回数据源,或者导致奇怪和任意的影响。很遗憾没有只读数组类型,因为像I/O这样的东西通常需要字节数组,尽管只读类型会更好。 - supercat

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