代码风格与可读性 -将事物组合在一起





5.00/5 (3投票s)
我们如何将类成员组合在一起,
背景
在文章《代码风格和可读性》中,我讨论了代码风格,主要集中在如何进行缩进。
这一次,我将讨论代码风格的另一个领域,主要关注如何将成员分组在一起,但也讨论其他一些项目,例如命名规则。
示例
让我们从以下代码开始。我们暂时不讨论这个类有多么有用,比如MinValue
和MaxValue
是否应该在构造函数中设置并设为readonly
等等。让我们只关注事物的分组。
public sealed class MinMaxContainer
{
public MinMaxContainer()
{
_value = 50;
_maxValue = 100;
}
private int _minValue;
public int MinValue
{
get => _minValue;
set
{
if (value > _value)
throw new ArgumentException("MinValue cannot be greater than Value.");
_minValue = value;
}
}
private int _maxValue;
public int MaxValue
{
get => _maxValue;
set
{
if (value < _value)
throw new ArgumentException("MaxValue cannot be less than Value.");
_maxValue = value;
}
}
private int _value;
public int Value
{
get => _value;
set
{
if (value < _minValue || value > _maxValue)
throw new ArgumentException("Value must be between MinValue and MaxValue, inclusive.");
_value = value;
}
}
}
正如你所见,只有三个属性,分别名为MinValue
、MaxValue
和Value
。这三个属性使用了后备字段,分别命名为_minValue
、_maxValue
和_value
,这些字段声明在它们所操作的属性之前。
我认为这是该类最合乎逻辑的分组方式。然而,如果我使用一些样式验证工具,我可能会被迫将此类更改为如下所示:
public sealed class MinMaxContainer
{
private int _minValue;
private int _maxValue;
private int _value;
public MinMaxContainer()
{
_value = 50;
_maxValue = 100;
}
public int MinValue
{
get => _minValue;
set
{
if (value > _value)
throw new ArgumentException("MinValue cannot be greater than Value.");
_minValue = value;
}
}
public int MaxValue
{
get => _maxValue;
set
{
if (value < _value)
throw new ArgumentException("MaxValue cannot be less than Value.");
_maxValue = value;
}
}
public int Value
{
get => _value;
set
{
if (value < _minValue || value > _maxValue)
throw new ArgumentException("Value must be between MinValue and MaxValue, inclusive.");
_value = value;
}
}
}
这还算不上太糟。我只需要把所有字段放在类声明的顶部。论点是所有字段都应该分组在一起,并且它们先出现。然后是构造函数(和析构函数),然后是属性,如果存在的话,就是方法。此刻,我将暂时不讨论事件。
这种分组方式对于这个简单的类也同样适用。使用这种分组方式的人通常会说,将所有字段放在顶部会更好,这样我们就可以轻松地了解类的所有信息,而无需扫描整个类。目前这是一个**稳健**的论点。
一些不同的属性
那么,添加另一个属性怎么样?比方说,对于这个类,我还想有一个Caption
。想法是,在屏幕上,我们将有一个滑块,可以在MinValue
和MaxValue
之间移动,并且该滑块会显示一个文本,说明它的作用。同样,我们不关注这个类有多么有用……新属性,如果添加到第一段代码中,可能是这样的:
public string Caption { get; set; }
考虑到第一段代码将字段和属性组合在一起,当我们在get
和set
中不需要进行任何处理时,使用自动属性是非常有意义的。但是,为了进一步提高可读性,我认为自动属性必须出现在其他属性之前……特别是,如果有很多自动属性,这会让阅读类变得更容易,在我看来。
另一种选择怎么样?
关于第二段代码,我想知道
- 我是否可以使用自动属性
- 自动属性应该与字段还是属性分组
为了解释,想法是先有字段,然后是构造函数/析构函数,然后是属性。如果我使用自动属性,这意味着我声明了一个可以无需验证而使用的属性,就像一个字段一样,同时也创建了一个“不可见的后备字段”。
因此,如果我将此属性与则其他属性分组在一起,那么仅通过读取类的顶部就可以轻松了解类所持有的所有信息的论点就被抛诸脑后了。
那么,我的想法是,对于那些自动属性,最好将它们与字段放在一起,因为它们大部分行为像字段,并且代表类型实际持有的数据。
但是,那样的话,样式验证工具可能会抱怨属性不能出现在构造函数之前。
所以,一种选择是将属性移动到与其他属性分组在一起。如果我将其保留为自动属性,它就可以正常工作。工具不会抱怨,但我们失去了通过查看字段来了解类所持有的所有数据的能力。那么,我们是否应该尝试通过以下方式来解决这个问题:
- 向类添加一个实际字段,与则其他字段分组
private string _caption;
- 使属性使用该字段而不是自动属性
public string Caption { get => _caption; set => _caption = value; }
那样的话,为了做同样的事情需要更多的代码。然而,那不是真正的问题,因为我从来没有讨论过编写代码的数量。新的问题是,我们现在可能会得到一个“建议”,即代码可以重构为使用自动属性。但是“建议”这个名字并不合适,因为代码将无法通过任何验证而能够被提交/提交到生产环境。
因此,答案是回到使用自动属性,与其他属性一起使用。而关于将所有信息放在类顶部的论点只是一个站不住脚的论点(人们可能会继续使用它)。自动属性在使用时会使该论点失效,但如果强制执行错误的样式,则需要使用它们。
所以,与我在上一篇文章中展示的类似,我的观点不在于是否使用自动属性。*将字段与操作它们的属性分组*或*将所有字段分组在一起*是两种有效的样式。最重要的是,要坚持使用它们的理由保持一致。
当字段与操作它们的属性分组在一起时,我们失去了扫描类顶部并查看其包含所有信息的这个能力。另一方面,如果我们决定删除属性或将其复制到不同的文件,我们有一个地方可以查看,因为字段和属性都写在一起。因此,如果一个属性不需要特殊处理,我们可以选择自动属性。这样做非常合理。
当所有字段都分组在一起,在其他成员之前时,这不仅仅是“字段需要与字段分组”。这是关于这样一个想法:我们将类对象持有的所有内容放在一个地方(这样我们甚至可以确定这些对象将消耗多少内存)。事实上,字段可以全部放在顶部(C# 中最常见)或底部(C++ 中最常见)。然而,重要的特质是:我们知道对象将持有的所有内容都将一起声明。但是,那样的话,我们就不能将自动属性作为属性分组,或者我们会失去这个特质。因此,如果使用的是这种逻辑,我们应该决定是将这些自动属性与字段分组,还是我们总是需要为属性声明一个字段,然后实现该属性的get
和set
来使用该字段,即使不需要额外的处理。
但是,您猜怎么着,大多数地方使用(并强制执行)的规则是:
- 将所有字段分组在一起,以便我们仅通过查看字段即可了解类包含的所有信息
- 尽可能使用自动属性
- 属性(无论是完整属性还是自动属性)不能靠近字段。它们需要作为属性进行分组。
而我真的认为第三点是一个巨大的错误,并且违背了第一点的目的。
所以,在简单地*强制执行*规则之前,我认为我们需要看看它们在处理边缘情况时效果如何。如果它们在处理这些边缘情况时不起作用,那么它们就应该只是建议。真正的建议,而不是禁止代码签入的建议。
还有更多吗?
当然还有。我在之前的讨论中故意排除了事件,但我们也应该谈谈它们。
我们也有static
成员。关于如何对static
成员进行分组,正确的规则是什么?
那么,_
(下划线)在字段名前的使用呢?事实上,它只是用于字段吗?
所以,让我们来探讨这些话题。
事件
因为我以前用Delphi编程,所以我认为事件总是最后声明的。
C# 最初遵循了这种逻辑。尽管事件可以实现add
和remove
方法,但大多数都是使用默认的编译器实现的单行代码。自 C# 出现以来一直如此。
另一方面,在 C# 的早期版本中,属性需要实现它们的get
和set
。现在,属性和事件看起来非常相似。我们可以拥有自动实现的属性以及自动实现的事件,或者我们可以实现属性的get
和set
,以及事件的add
和remove
,这可能意味着我们也将为这些对中的每一个都有一个后备字段。
所以,如果属性和事件如此相似,那么将它们分组在一起是有意义的,对吧?
嗯……这是我最近越来越常听到的一个论点。但让我们回顾一下 Delphi。在那里,事件是特殊类型的属性。但它们的组织方式不同。
对我来说,属性和事件的分组从来都不是关于它们的声明有多么相似或不同,而是关于它们所实现的目的。
属性是我们操作并有兴趣读取的东西。另一方面,事件在声明它们的类外部无法读取(至少在 C# 中),它们在那里是为了通知我们发生了什么事或即将发生什么事。我仍然认为,在大多数情况下,属性应该与其他属性分组,事件应该与其他事件分组,并且它们不应该混在一起。
然而,我会在事情值得例外时做出例外。例如,如果我决定在示例中使用的类中添加一个ValueChanged
事件,并且我谈论的是一个用于通知Value
属性变化的事件,而不是通知所有属性变化的事件,那么我认为将ValueChanged
与其属性Value
分组在一起会非常有意义,至少在使用第一种分组方式时,在这种方式下,如果字段和属性相关,我可以将它们分组在一起。在这种情况下,将_value
、Value
和ValueChanged
分组在一起将是有意义的。
无论如何,为了说清楚,我将继续将像PropertyChanged
这样的事件与其他事件一起分组在类定义的末尾,因为这样的事件与单个属性无关。
静态成员
关于示例代码,您是否注意到我使用50
初始化了Value
属性,并使用100
初始化了MaxValue
(通过直接设置字段值)?如果我决定实际使这些默认值可配置呢?
我可以在类中添加static
成员,例如DefaultMinValue
、DefaultValue
、DefaultMaxValue
和DefaultCaption
。当然,这也会伴随所需的后备字段。真正的问题是,这些默认成员应该放在哪里?
我知道这里有两种主要的想法:
- 所有
static
成员先,然后是所有实例成员 - 总是先是字段,然后是构造函数/析构函数,然后是属性(可能有事件),然后是方法,如果事件不与属性在一起,那么就是事件。
static
成员在每个各自组的内部先出现。
而且我老实说认为,一直以来将代码约束在这种方式下是错误的,并且适得其反。在许多情况下,我可能会创建一个static
方法来帮助验证instance
属性的setter,例如。如果这样的方法与单个属性相关,我会将这样的方法与该属性放在一起,忽略这两种样式。
对于其他情况,我通常更喜欢所有static
成员先,然后是所有instance
成员。但是,无论我的普遍偏好如何,就样本类而言,我非常倾向于说将类似以下内容分组:
DefaultMinValue
与MinValue
DefaultValue
与Value
- 以及
DefaultMaxValue
与MaxValue
……这是最有意义的。如果我决定将DefaultValue
和Value
放在所有其他属性之前或之后,这并不那么重要。
在这种情况下,重要的是与Value
相关的所有内容(即它的后备字段、它的事件以及它的默认值)都分组在一起。其他属性也是如此。
然而,大多数验证工具会假设这种样式是错误的,并试图强制执行一种样式,在这种样式中,您不能将所有相关内容都放在一起,因为它们想要将“同类事物”分组在一起,而不是那些事物的功能。也就是说,它们要么将static
与static
分组,要么将属性与属性分组,并且不允许static
字段、static
属性、关于同一事物的字段和属性分组在一起,然后是遵循相同模式的另一个组。
下划线的使用
那是在 2008 年,我和一些同事在工作时讨论了这个问题。由于我长期使用 Delphi,我通常会在所有字段前加上“f”。
当时,示例类的字段将被命名为fMinValue
、fMaxValue
和fValue
。实际上,在 Delphi 中,它会是大写的 F,但由于 C# 中的字段以小写字母开头,我最终使用了“f”。
一些同事要么不使用任何前缀,要么使用p_
(来自private
)。因此,代码有点混乱,因为我们有前缀为p_
的字段、前缀为f
的字段以及没有前缀的字段。
在讨论了采用单一标准之后,一些开发人员强烈认为我们不应该使用f
,而另一些人(包括我)则认为p_
不是一个好主意。最后,我们都同意只对字段和private
成员使用_
。
事实上,事实证明我们应该只对private
和internal
成员使用_
(但不是internal protected
,因为它们对外部程序集可见),并且由于字段应该总是private
,所以效果很好。
有一个_
前缀的好处之一是,我们永远不会错误地将字段当作局部变量来访问。正如您可能知道的,连续两次访问字段可能会遇到线程问题,如果值被另一个线程修改。局部变量没有这个问题。
请注意,这里我指的是意外地将字段当作局部变量使用。那些不使用任何类型前缀的字段的人说,我们应该始终使用this.
来访问字段。然而,我的观点是,当人们不使用this.
时,他们可能假设他们正在访问一个局部变量,并且他们可能会意外地访问一个字段(尤其是在大型方法中)。
当字段总是以_
作为前缀时,这种情况就不会发生。而我们只需要写一个额外的字符(下划线),而不是写this.
的五个字符来访问字段,同时也减少了在强制执行固定行长度时需要多行语句的可能性。
有趣的是,我发现世界各地的许多不同项目也使用了下划线,无论是用于private
成员还是仅仅用于字段。
同样,多年后,我发现 Python 开发人员也使用_
来表示成员是private
的,并且不应该在类外使用。
但是,又过了几年,我看到许多用 C# 编写代码的人正在摆脱字段的任何前缀,并编写更冗长且更容易意外使用的代码。
无论如何,当强制使用this.
来访问字段时,不为字段使用任何前缀是可以的。请注意,这个规则与我之前所说的对于分组项目的观点有些相反。对于分组,我认为强行执行模式是糟糕的。在这种情况下,我认为不强制使用this.
来访问字段(当字段没有前缀时)是很糟糕的。
例如,从我提供的示例类来看,如果我们把字段_value
重命名为value
,我们需要确保我们正确地区分字段和局部变量,因为属性Value
需要将value
(局部变量)赋值给value
(字段)。
结论
我的结论是,无论公司选择使用哪种样式,它都应该真正有助于代码的可读性,而不是仅仅“需要遵循的规则”。
奇怪的是,有些人似乎认为规则强制执行得越多,代码就会越好,而实际上许多规则都在阻碍项目的开发,并且使代码的可读性降低,这与规则本应起到的作用恰恰相反。
所以,如果您能够强制执行或避免这些规则,请考虑边缘情况以及如果这些情况处理不当,整体产品可能会受到多大的影响。
历史
- 2022年6月15日:初始版本