C# 代码标准和原则详解

C# 中编码标准和原则的主要目标是让程序员通过编写性能更高、更易于维护的代码来提高自己的技能。在本章中,我们将看一些好代码的例子,与坏代码的例子进行对比。这将很好地引导我们讨论为什么我们需要编码标准、原则和方法。然后,我们将继续考虑命名、注释和格式化源代码的约定,包括类、方法和变量。

一个大的程序理解和维护起来可能相当笨拙。对于初级程序员来说,了解代码及其功能是一个令人望而生畏的前景。团队会发现很难在这样的项目上合作。从测试的角度来看,这会让事情变得相当困难。因此,我们将研究如何使用模块化将程序分解为更小的模块,这些模块一起工作以生成一个功能完整的解决方案,该解决方案也是完全可测试的,可以由多个团队同时工作,并且更易于阅读、理解和记录。

我们将通过查看一些编程设计指南来结束本章,主要是 KISS、YAGNI、DRY、SOLID 和 Occam 的 Razor。

本章将介绍以下主题:

本章的学习目标是让您做到以下几点:

  • 理解为什么坏代码会对项目产生负面影响。
  • 了解好的代码如何对项目产生积极影响。
  • 了解编码标准如何改进代码以及如何实施它们。
  • 了解编码原则如何提高软件质量。
  • 了解方法论如何帮助开发干净的代码。
  • 实施编码标准。
  • 选择假设最少的解决方案。
  • 减少代码重复并编写可靠的代码。

要使用本章中的代码,您需要下载并安装 Visual Studio 2019 Community Edition 或更高版本。此 IDE 可从下载 https://visualstudio.microsoft.com/

您可以在找到本书的代码 https://github.com/PacktPublishing/Clean-Code-in-C- 我将它们放在一个单独的解决方案下,每个章节都作为一个解决方案文件夹。您将在相关章节文件夹中找到每个章节的代码。如果正在运行项目,请记住将其指定为启动项目。

好代码和坏代码都可以编译。这是首先要了解的。接下来要了解的是,坏代码是有原因的,同样,好代码也是有原因的。让我们在下面的对比表中看看其中的一些原因:

| 好代码 | 错误代码 | | 适当的压痕。 | 压痕不正确。 | | 有意义的评论。 | 说明明显问题的评论。 | | API 文档注释。 | 以错误代码为借口的注释。 注释掉了代码行。 | | 使用名称空间的适当组织。 | 使用名称空间的组织不正确。 | | 良好的命名约定。 | 错误的命名约定。 | | 完成一项任务的类。 | 完成多个作业的类。 | | 做一件事的方法。 | 做很多事情的方法。 | | 方法少于 10 行,最好不超过 4 行。 | 具有 10 行以上代码的方法。 | | 不超过两个参数的方法。 | 具有两个以上参数的方法。 | | 例外情况的正确使用。 | 使用异常控制程序流。 | | 可读的代码。 | 难以阅读的代码。 | | 松散耦合的代码。 | 紧密耦合的代码。 | | 高内聚性。 | 低内聚力。 | | 物体被干净地处理掉了。 | 留在周围的物体。 | | 避免使用Finalize()方法。 | 使用Finalize()方法。 | | 正确的抽象层次。 | 过度工程化。 | | 在大类中使用区域。 | 大班缺乏区域。 | | 封装和信息隐藏。 | 直接公开信息。 | | 面向对象代码。 | 意大利面代码。 | | 设计模式。 | 设计反模式。 |

这是一份详尽的清单,不是吗?在以下部分中,我们将了解这些特性以及好代码和坏代码之间的差异如何影响代码的性能。

错误代码

现在,我们将简要介绍前面列出的每一种糟糕的编码实践,详细说明这种实践是如何影响代码的。

压痕不当

不恰当的缩进会使代码变得很难阅读,特别是当方法很大时。为了使代码易于人类阅读,我们需要适当的缩进。如果代码缺少适当的缩进,则很难看到代码的哪个部分属于哪个块。

默认情况下,当括号和大括号关闭时,VisualStudio2019 会正确格式化和缩进代码。但有时,它会错误地格式化代码,使您注意到您编写的代码包含异常。但是如果您使用的是一个简单的文本编辑器,那么您将不得不手工进行格式化。

错误缩进的代码也需要花费大量的时间来纠正,这是一种令人沮丧的编程时间浪费,本来可以很容易避免的。让我们看一个简单的代码示例:

public void DoSomething()
{
for (var i = 0; i < 1000; i++)
{
var productCode = $"PRC000{i}";
//...implementation
}
}

前面的代码看起来不太好,但仍然可读。但是,添加的代码行越多,代码就越难阅读。

很容易错过一个结束括号。如果您的代码没有正确缩进,那么这会使查找缺少的括号变得更加困难,因为您不容易发现哪个代码块缺少结束括号。

说明明显问题的评论

我看到程序员们对那些明显的评论感到非常不安,因为他们发现他们在光顾别人。在我参与的编程讨论中,程序员们已经说明了他们如何不喜欢注释,以及他们如何相信代码应该是自文档化的。

我能理解他们的情绪。如果您可以阅读没有注释的代码,就像您可以阅读一本书并理解它一样,那么它就是一段非常好的代码。如果您有一个声明为字符串的变量,那么为什么要添加注释,例如// string?让我们看一个例子:

public int _value; // This is used for storing integer values.

我们在这里知道,该值通过其类型int保存一个整数。因此,确实没有必要陈述显而易见的事实。你所做的只是浪费时间和精力,把代码弄得乱七八糟。

以错误代码为借口的注释

你可能会有一个很紧的截止日期,但像// I know this code sucks but hey at least it works!这样的评论太糟糕了。不要这样做。它显示出缺乏专业精神,并且会让其他程序员感到不满。

如果你真的迫不及待地想做点什么,那就提出一份重构通知单,并将其作为 TODO 注释的一部分添加进来,比如// TODO: PBI23154 Refactor Code to meet company coding practices。然后,您或其他被指派处理技术债务的开发人员可以拿起产品待办事项PBI)并重构代码。

下面是另一个例子:

...
int value = GetDataValue(); // This sometimes causes a divide by zero error. Don't know why!
...

这个真的很糟糕。好的,谢谢你让我们知道这里发生了被零除的错误。但是你提出了一个错误的罚单吗?你有没有试过追根究底,把它修好?如果每个积极参与项目的人都不接触该代码,他们怎么知道有错误代码?

至少,你应该有一个// TODO:评论。然后,至少注释会显示在任务列表中,这样开发人员就可以得到通知并处理它。

注释掉代码行

如果您注释掉代码行以尝试某些操作,则可以。但是,如果要使用替换代码而不是注释掉的代码,则在签入之前删除注释掉的代码。一两个评论提纲也没那么糟糕。但是当你有很多行被注释掉的代码时,它会分散你的注意力,使代码难以维护;它甚至可能导致混淆:

/* No longer used as has been replaced by DoSomethinElse().
public void DoSomething()
{
    // ...implementation...
}
*/

为什么?为什么?如果它已被替换并且不再需要,则只需删除它即可。如果代码处于版本控制中,并且需要取回该方法,则始终可以查看文件的历史记录并取回该方法。

名称空间组织不当

使用名称空间时,不要包含应该在其他位置的代码。这会使找到正确的代码变得非常困难或不可能,尤其是在大型代码库中。让我们看看这个例子:

namespace MyProject.TextFileMonitor
{
    + public class Program { ... }
    + public class DateTime { ... }
    + public class FileMonitorService { ... }
    + public class Cryptography { ... }
}

我们可以看到前面代码中的所有类都在一个名称空间下。然而,我们有机会再添加三个名称空间来更好地组织此代码:

  • MyProject.TextFileMonitor.Core:定义常用成员的核心类将放在这里,比如我们的DateTime类。
  • MyProject.TextFileMonitor.Services:所有充当服务的类都将放置在此命名空间中,例如FileMonitorService
  • MyProject.TextFileMonitor.Security:所有与安全相关的类都将放在这个名称空间中,包括我们示例中的Cryptography类。

错误的命名约定

在 VisualBasic6 编程时代,我们使用匈牙利符号。我记得第一次切换到 Visual Basic 1.0 时使用过它。不再需要使用匈牙利符号。另外,它会让你的代码看起来很难看。因此,现代的方法是分别使用NameLabelNameTextBoxSaveButton,而不是使用lblNametxtNamebtnSave等名称

使用隐晦的名称和似乎与代码意图不匹配的名称会使阅读代码变得相当困难。ihridx是什么意思?表示人力资源指数,为整数。真正地避免使用mystringmyintmymethod等名称。这样的名字真的没有用。

也不要在名称中的单词之间使用下划线,例如Bad_Programmer。这可能会给开发人员带来视觉压力,并使代码难以阅读。只需删除下划线即可。

不要在类级别和方法级别对变量使用相同的代码约定。这会使建立变量的范围变得困难。变量名的一个好习惯是对变量名(如alienSpawn)使用 camel 大小写,对方法、类、结构和接口名(如EnemySpawnGenerator)使用 Pascal 大小写。

遵循良好的变量名约定,您应该通过在成员变量前面加下划线来区分局部变量(包含在构造函数或方法中的变量)和成员变量(位于构造函数和方法之外的类顶部的变量)。我曾在工作场所将此作为一种编码约定,它确实工作得很好,程序员似乎确实喜欢这种约定。

执行多个作业的类

一个好的班级应该只做一项工作。拥有一个连接到数据库、获取数据、处理数据、加载报表、将数据分配给报表、显示报表、保存报表、打印报表和导出报表的类做得太多了。它需要重构成更小、组织更好的类。像这样包罗万象的课程读起来很痛苦。我个人觉得它们令人畏惧。如果遇到这样的类,请将功能组织到区域中。然后将这些区域中的代码移动到执行一个作业的新类中。

让我们看一个正在做多种事情的类的示例:

public class DbAndFileManager
{
 # region Database Operations

 public void OpenDatabaseConnection() { throw new 
  NotImplementedException(); }
 public void CloseDatabaseConnection() { throw new 
  NotImplementedException(); }
 public int ExecuteSql(string sql) { throw new 
  NotImplementedException(); }
 public SqlDataReader SelectSql(string sql) { throw new 
  NotImplementedException(); }
 public int UpdateSql(string sql) { throw new 
  NotImplementedException(); }
 public int DeleteSql(string sql) { throw new 
  NotImplementedException(); }
 public int InsertSql(string sql) { throw new 
  NotImplementedException(); }

 # endregion

 # region File Operations

 public string ReadText(string filename) { throw new 
  NotImplementedException(); }
 public void WriteText(string filename, string text) { throw new 
  NotImplementedException(); }
 public byte[] ReadFile(string filename) { throw new 
  NotImplementedException(); }
 public void WriteFile(string filename, byte[] binaryData) { throw new 
  NotImplementedException(); }

 # endregion
}

正如您在前面的代码中所看到的,该类主要做两件事:它执行数据库操作和文件操作。现在,代码被整齐地组织在正确命名的区域中,这些区域用于逻辑上分隔类中的代码。但单一责任原则SRP被打破。我们需要首先重构这些代码,将数据库操作分离为一个类,称为类似于DatabaseManager的类。

然后,我们将从DbAndFileManager类中删除数据库操作,只留下文件操作,然后将DbAndFileManager类重命名为FileManager。我们还需要考虑每个文件的命名空间,以及它是否应该被修改,以便将 AUTT3T 放置在 AUTT4TY 命名空间中,并且 AUT T5T 将放置在 AutoT6-命名空间中,或者它们在程序中的等价物。

以下代码是将数据库代码从DbAndFileManager类提取到其自己的类和正确的命名空间中的结果:

using System;
using System.Data.SqlClient;

namespace CH01_CodingStandardsAndPrinciples.GoodCode.Data
{
    public class DatabaseManager
    {
        # region Database Operations

        public void OpenDatabaseConnection() { throw new 
         NotImplementedException(); }
        public void CloseDatabaseConnection() { throw new 
         NotImplementedException(); }
        public int ExecuteSql(string sql) { throw new 
         NotImplementedException(); }
        public SqlDataReader SelectSql(string sql) { throw new 
         NotImplementedException(); }
        public int UpdateSql(string sql) { throw new 
         NotImplementedException(); }
        public int DeleteSql(string sql) { throw new 
         NotImplementedException(); }
        public int InsertSql(string sql) { throw new 
         NotImplementedException(); }

        # endregion
    }
}

文件系统代码的重构会导致FileSystem命名空间中的FileManager类,如下代码所示:

using System;

namespace CH01_CodingStandardsAndPrinciples.GoodCode.FileSystem
{
    public class FileManager
    {
         # region File Operations

         public string ReadText(string filename) { throw new 
          NotImplementedException(); }
         public void WriteText(string filename, string text) { throw new 
          NotImplementedException(); }
         public byte[] ReadFile(string filename) { throw new 
          NotImplementedException(); }
         public void WriteFile(string filename, byte[] binaryData) { throw 
          new NotImplementedException(); }

         # endregion
    }
}

我们已经了解了如何识别做得太多的类,以及如何将它们重构为只做一件事。现在,让我们重复这个过程,看看可以做很多事情的方法。

做很多事情的方法

我发现自己迷失在很多很多层次的缩进方法中,在这些不同的缩进中做很多事情。这些排列令人难以置信。我想重构代码以使维护更容易,但我的上级禁止了。我可以清楚地看到,通过将代码分成不同的方法,该方法可以变得更小。

是时候举个例子了。在本例中,该方法接受一个字符串。然后对该字符串进行加密和解密。它也很长,因此您可以看到为什么方法应该保持较小:

public string security(string plainText)
{
    try
    {
        byte[] encrypted;
        using (AesManaged aes = new AesManaged())
        {
            ICryptoTransform encryptor = aes.CreateEncryptor(Key, IV);
            using (MemoryStream ms = new MemoryStream())
                using (CryptoStream cs = new CryptoStream(ms, encryptor, 
                 CryptoStreamMode.Write))
                {
                    using (StreamWriter sw = new StreamWriter(cs))
                        sw.Write(plainText);
                    encrypted = ms.ToArray();
                }
        }
        Console.WriteLine($"Encrypted data: 
         {System.Text.Encoding.UTF8.GetString(encrypted)}");
        using (AesManaged aesm = new AesManaged())
        {
            ICryptoTransform decryptor = aesm.CreateDecryptor(Key, IV);
            using (MemoryStream ms = new MemoryStream(encrypted))
            {
                using (CryptoStream cs = new CryptoStream(ms, decryptor, 
                 CryptoStreamMode.Read))
                {
                    using (StreamReader reader = new StreamReader(cs))
                        plainText = reader.ReadToEnd();
                }
            }
        }
        Console.WriteLine($"Decrypted data: {plainText}");
    }
    catch (Exception exp)
    {
        Console.WriteLine(exp.Message);
    }
    Console.ReadKey();
    return plainText;
}

正如您在前面的方法中所看到的,它有 10 行代码,很难阅读。此外,它做的不止一件事。此代码可以分解为两个方法,每个方法执行一个任务。一个方法将加密字符串,另一个方法将解密字符串。这很好地解释了为什么方法的代码不应该超过 10 行。

具有超过 10 行代码的方法

大型方法不好阅读和理解。它们也会导致很难找到的 bug。大型方法的另一个问题是,它们可能会忽略其原始意图。更糟糕的是,当您遇到大型方法时,它们的部分由注释分隔,代码包装在区域中。

如果您必须滚动阅读一个方法,那么它太长,可能会导致程序员的压力和误解。这反过来会导致修改,从而破坏代码或意图,或两者兼而有之。方法应该尽可能小。但常识确实需要运用,因为你可以将小方法的问题提升到nthT3】的程度,以至于变得过度。获得正确平衡的关键是确保方法的意图非常清晰且简洁地实现。

前面的代码很好地解释了为什么应该保持方法的小型化。小方法易于阅读和理解。通常,如果您的代码漂移超过 10 行,那么它可能会超出预期。确保你的方法说出了他们的意图,如OpenDatabaseConnection()CloseDatabaseConnection()中所述,并且他们坚持自己的意图,不会偏离它们。

现在我们来看看方法参数。

具有两个以上参数的方法

具有许多参数的方法往往有点笨拙。除了很难读取外,很容易将值传递到错误的参数和断开类型安全。

随着参数数量的增加,测试方法变得越来越复杂,主要原因是您有更多的排列应用于测试用例。您可能会错过一个会导致生产中出现问题的用例。

使用异常控制程序流

用于控制程序流的异常可能隐藏代码的意图。它们还可能导致意想不到的结果。事实上,您的代码被编程为预期一个或多个异常,这表明您的设计是错误的。第 5 章异常处理中详细介绍了一个典型场景。

典型的场景是当业务使用业务规则异常BREs时。方法将执行预期将引发异常的操作。程序流将由是否引发异常来确定。更好的方法是使用可用的语言构造来执行返回布尔值的验证检查。

以下代码显示如何使用 BRE 控制程序流:

public void BreFlowControlExample(BusinessRuleException bre)
{
    switch (bre.Message)
    {
        case "OutOfAcceptableRange":
            DoOutOfAcceptableRangeWork();
            break;
        default:
            DoInAcceptableRangeWork();
            break;
    }
}

该方法接受BusinessRuleException。根据异常中的消息,BreFlowControlExample()调用DoOutOfAcceptableRangeWork()方法或DoInAcceptableRangeWork()方法。

控制流的更好方法是通过布尔逻辑。我们来看看下面的BetterFlowControlExample()方法:

public void BetterFlowControlExample(bool isInAcceptableRange)
{
    if (isInAcceptableRange)
        DoInAcceptableRangeWork();
    else
        DoOutOfAcceptableRangeWork();
}

BetterFlowControlExample()方法中,一个布尔值被传递到该方法中。布尔值用于确定要执行的路径。如果条件在可接受范围内,则调用DoInAcceptableRangeWork()。否则,调用DoOutOfAcceptableRangeWork()方法。

接下来,我们将考虑难以阅读的代码。

难以阅读的代码

像千层面和意大利面这样的代码真的很难阅读或理解。名称不好的方法也可能是一个难题,因为它们会混淆方法的意图。如果方法很大,并且链接的方法由许多不相关的方法分隔,则会进一步混淆这些方法。

烤宽面条代码,也被称为更常见的间接寻址,是指抽象层,在抽象层中,某些东西是通过名称而不是动作来表示的。分层在面向对象编程面向对象编程中得到了广泛应用,效果良好。但是,使用的间接寻址越多,代码就越复杂。这会使项目中的新程序员很难跟上理解代码的速度。因此,必须在间接性和易理解性之间取得平衡。

意式代码指的是一堆杂乱无章、紧密耦合、内聚力低的代码。这样的代码很难维护、重构、扩展和重新设计。尽管从好的方面来说,它可以很容易阅读和理解,因为它在编程中更具程序性。我记得我是一名初级程序员,负责一个 VB6 GIS 程序,该程序出售给公司并用于营销目的。我的技术总监和他的高级程序员曾试图重新设计软件,但失败了。所以他们向我提出挑战,让我重新设计程序。但由于当时不擅长软件分析和设计,我也失败了。

代码太复杂了,无法遵循并分组到相关项目中,而且太大了。事后看来,我最好是列出程序所做的一切,按功能对列表进行分组,然后在不看代码的情况下列出需求列表。

因此,我在重新设计软件时学到的教训是不惜一切代价避免查看代码。写下程序所做的一切,以及它应该包含的新功能。将列表转化为一组软件需求,以及相关的任务、测试和验收标准,然后根据规范进行编程。

紧密耦合的代码

紧密耦合的代码很难测试,也很难扩展或修改。在系统中重用依赖于其他代码的代码也很困难。

紧密耦合的一个示例是在参数中引用具体的类类型而不是引用接口时。引用具体类时,对具体类的任何更改都会直接影响引用它的类。因此,如果您有一个连接到 SQL Server 的客户端的数据库连接类,然后接受另一个需要 Oracle 数据库的客户,那么必须为该特定客户及其 Oracle 数据库修改具体的类。这将导致代码的两个版本。

客户越多,所需的代码版本就越多。这很快就变得站不住脚,成为一场需要维持的噩梦。假设您的数据库连接类有 100000 个不同的客户端,使用该类的 30 个变体中的 1 个,它们都有相同的 bug,这些 bug 已经被识别并影响到它们。也就是说,30 个类必须安装、测试、打包和部署相同的修复程序。这需要大量的维护开销,而且在财务上也非常昂贵。

通过引用接口类型,然后使用数据库工厂构建所需的连接对象,可以克服这种特殊情况。然后,客户可以在配置文件中设置连接字符串,并将其传递到工厂。然后,工厂将生成一个具体的连接类,该类为连接字符串中指定的特定类型的数据库实现连接接口。

下面是一个紧耦合代码的糟糕示例:

public class Database
{
    private SqlServerConnection _databaseConnection;

    public Database(SqlServerConnection databaseConnection)
    {
        _databaseConnection = databaseConnection;
    }
}

正如您从示例中看到的,我们的数据库类与使用 SQL Server 绑定,需要硬编码更改才能接受任何其他类型的数据库。在后面的章节中,我们将用实际的代码示例介绍代码重构。

低内聚

低内聚性由不相关的代码组成,这些代码执行各种不同的任务,所有这些任务都分组在一起。例如,一个实用程序类包含许多不同的实用程序方法,用于处理日期、文本、数字、文件输入和输出、数据验证以及加密和解密。

遗留物

当对象在内存中挂起时,它们可能导致内存泄漏。

静态变量可能以多种方式导致内存泄漏。如果您没有使用DependencyObjectINotifyPropertyChanged,那么您实际上是在订阅事件。公共语言运行库CLR通过PropertyDescriptors AddValueChanged事件使用ValueChanged事件创建强引用,从而导致PropertyDescriptor的存储,该PropertyDescriptor引用了绑定到的对象。

除非您取消订阅绑定,否则最终将导致内存泄漏。使用引用未释放对象的静态变量也会导致内存泄漏。静态变量引用的任何对象都被标记为垃圾收集器不收集。这是因为引用对象是垃圾收集GC根)的静态变量,垃圾收集器将任何 GC 根标记为不收集

使用捕获类成员的匿名方法时,将引用该类的实例。这会导致对类实例的引用保持活动状态,而匿名方法保持活动状态。

当使用非托管代码COM时,如果您不释放任何托管和非托管对象并显式释放任何内存,那么您将最终导致内存泄漏。

在不使用弱引用、删除未使用的缓存或限制缓存大小的情况下无限期缓存的代码最终将耗尽内存。

如果要在永不终止的线程中创建对象引用,也会导致内存泄漏。

不是匿名引用类的事件订阅。当这些事件保持订阅状态时,对象将保留在内存中。因此,除非您在不需要时取消订阅事件,否则很可能会导致内存泄漏。

Finalize()方法的使用

虽然终结器有助于从未正确处置的对象中释放资源,并有助于防止内存泄漏,但它们确实有许多缺点。

您不知道何时将调用终结器。它们将由垃圾收集器和图上的所有依赖项一起提升到下一代,并且在垃圾收集器决定这样做之前不会被垃圾收集。这可能意味着对象在内存中保留很长时间。使用终结器可能会出现内存不足异常,因为创建对象的速度比垃圾收集的速度快。

过度工程

过度工程化可能是一场彻头彻尾的噩梦。最大的原因是,作为一个普通人,涉过一个庞大的系统,试图理解它,如何使用它,以及去哪里是一个耗时的过程。更重要的是,当没有文档时,您是该系统的新手,甚至使用时间比您长得多的人也无法回答您的问题。

这可能是一个主要原因的压力,当你被期望在规定的期限内工作。

学着保持简单,傻瓜

我工作过的一个地方就是一个很好的例子。我必须为一个 web 应用编写一个测试,该应用从一个服务接受 JSON,允许一个孩子做一个测试,然后将结果评分传递给另一个服务。我并没有使用 OOP,固体,或干,因为我应该根据公司的政策。但我确实通过在很短的时间内对事件使用 KISS 和程序编程完成了工作。我因此受到处罚,并被迫用他们自己开发的测试播放器重写。

所以我开始学习他们的测试播放器。没有任何文件,也没有遵循他们的枯燥原则,只有极少数人真正理解它。我的新版本需要使用他们的系统,而不是像我的惩罚系统那样用几天的时间来构建,因为它没有完成我需要它做的事情,我也不被允许修改它来完成我需要它做的事情。所以当我等待别人做需要做的事情时,我被拖慢了。

我的第一个解决方案满足了业务需求,是一段独立的代码,不关心其他任何事情。第二个解决方案满足了开发团队的技术要求。这个项目持续的时间比最后期限长。任何超过最后期限的项目都会给企业带来超出计划的成本。

我想对我的惩罚系统提出的另一点是,它比更新的系统简单得多,也更容易理解,更新的系统被重写为使用通用测试播放器。

你不必总是遵循 OOP、SOILD 和 DRY。有时不这样做是值得的。毕竟,您可以编写最漂亮的 OOP 系统。但是在引擎盖下,你的代码被转换成更接近计算机理解的过程代码!

大班缺乏区域

包含大量区域的大型类很难阅读和理解,尤其是当相关方法没有组合在一起时。区域非常适合于将类似于大型类中的成员分组。但是如果你不使用它们,它们是没有用的!

失意代码

如果您正在查看一个类,它正在做一些事情,那么您如何知道它的初衷是什么?例如,如果您正在查找日期方法,并且在代码的输入/输出命名空间的文件类中找到了它,那么日期方法是否位于正确的位置?不知道。其他不了解您的代码的开发人员是否很难找到该方法?当然会的。请看下面的代码:

public class MyClass 
{
    public void MyMethod()
    {
        // ...implementation...
    }

    public DateTime AddDates(DateTime date1, DateTime date2)
    {
        //...implementation...
    }

    public Product GetData(int id)
    {
        //...implementation...
    }
}

这门课的目的是什么?名称没有给出任何指示,MyMethod做什么?该类似乎也在执行日期操作和获取产品数据。AddDates方法应位于仅用于管理日期的类中。而GetData方法应该在产品视图模型中。

直接公开信息

直接公开信息的类是坏的。除了产生可能导致 bug 的紧密耦合之外,如果要更改信息类型,必须在使用信息的任何地方更改类型。另外,如果要在分配之前执行数据验证,该怎么办?下面是一个例子:

public class Product
{
    public int Id;
    public int Name;
    public int Description;
    public string ProductCode;
    public decimal Price;
    public long UnitsInStock
}

在前面的代码中,如果要将UnitsInStocklong类型更改为int类型,则必须在引用的任何地方更改代码。您必须对ProductCode执行同样的操作。如果新产品代码必须遵循严格的格式,那么如果字符串可以由调用类直接分配,您将无法验证产品代码。

好代码

既然您知道了什么是不应该做的,那么现在是时候简单地看一下一些好的编码实践,以便能够编写令人愉悦、性能良好的代码了。

适当压痕

当您使用适当的缩进时,阅读代码就容易多了。您可以通过缩进区分代码块的开始和结束位置,以及哪些代码属于这些代码块:

public void DoSomething()
{
    for (var i = 0; i < 1000; i++)
    {
        var productCode = $"PRC000{i}";
        //...implementation
    }
}

在前面的简单示例中,代码看起来不错,可读性很好。您可以清楚地看到每个代码块的开始和结束位置。

有意义的评论

有意义的注释是表达程序员意图的注释。当代码是正确的,但对代码不熟悉的人,甚至同一个程序员在几周内都不容易理解时,这样的注释是有用的。这样的评论真的很有帮助。

API 文档注释

一个好的 API 是一个具有良好文档且易于遵循的 API。API 注释是可用于生成 HTML 文档的 XML 注释。HTML 文档对于希望使用 API 的开发人员来说很重要。文档越好,就有越多的开发人员希望使用您的 API。下面是一个例子:

/// <summary>
/// Create a new <see cref="KustoCode"/> instance from the text and globals. Does not perform 
/// semantic analysis.
/// </summary>
/// <param name="text">The code text</param>
/// <param name="globals">
///   The globals to use for parsing and semantic analysis. Defaults to <see cref="GlobalState.Default"/>
/// </param>.
 public static KustoCode Parse(string text, GlobalState globals = null) { ... }

这段摘录自 Kusto 查询语言项目,是 API 文档注释的一个很好的例子。

使用名称空间的适当组织

正确组织并放置在适当名称空间中的代码可以在开发人员查找特定代码时节省大量时间。例如,如果您正在寻找与日期和时间相关的类和方法,最好有一个名为DateTime的名称空间,一个名为Time的类用于与时间相关的方法,一个名为Date的类用于与日期相关的方法。

以下是正确组织名称空间的示例:

| 名称 | 说明 | | CompanyName.IO.FileSystem | 命名空间包含定义文件和目录操作的类。 | | CompanyName.Converters | 命名空间包含用于执行各种转换操作的类。 | | CompanyName.IO.Streams | 命名空间包含用于管理流输入和输出的类型。 |

良好的命名约定

遵循 Microsoft C# 命名约定很好。对名称空间、类、接口、枚举和方法使用 Pascal 大小写。变量名和参数名使用 camel 大小写,并确保在成员变量前面加下划线。

请看以下示例代码:

using System;
using System.Text.RegularExpressions;

namespace CompanyName.ProductName.RegEx
{
  /// <summary>
  /// An extension class for providing regular expression extensions 
  /// methods.
  /// </summary>
  public static class RegularExpressions
  {
    private static string _preprocessed;

    public static string RegularExpression { get; set; }

    public static bool IsValidEmail(this string email)
    {
      // Email address: RFC 2822 Format. 
      // Matches a normal email address. Does not check the 
      // top-level domain.
      // Requires the "case insensitive" option to be ON.
      var exp = @"\A(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.
       [a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]
*       [a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\Z";
      bool isEmail = Regex.IsMatch(email, exp, RegexOptions.IgnoreCase);
      return isEmail;
    }

    // ... rest of the implementation ...

  }
}

它显示了名称空间、类、成员变量、类、参数和局部变量的命名约定的适当示例。

只做一项工作的类

好的班级是只做一项工作的班级。当你读这门课时,它的意图是明确的。只有应该在该类中的代码才在该类中,而其他代码则不在该类中。

做一件事的方法

方法应该只做一件事。您不应该有一个方法执行多个操作,例如解密字符串和执行字符串替换。方法的意图应该是明确的。只做一件事的方法更倾向于小的、可读的和有意的。

少于 10 行的方法,最好不超过 4 行

理想情况下,您应该拥有不超过 4 行代码的方法。但是,这并不总是可能的,因此您应该将目标放在长度不超过 10 行的方法上,以便它们易于阅读和维护。

不超过两个参数的方法

最好有没有参数的方法,但是有一个或两个是可以的。如果你开始有两个以上的参数,你需要考虑你的类和方法的责任:它们承担的太多了吗?如果您确实需要两个以上的参数,那么您可以更好地传递对象。

任何具有两个以上参数的方法都可能变得难以阅读和遵循。不超过两个参数使代码可读,而作为对象的单个参数比具有多个参数的方法更可读。

例外情况的正确使用

永远不要使用异常来控制程序流。以不会引发或抛出异常的方式处理可能触发异常的常见条件。一个好的类的设计方式可以避免异常。

使用try/catch/finally异常从异常中恢复和/或释放资源。捕获异常时,请使用可能在代码中引发的特定异常,以便您可以记录或协助处理该异常的更详细信息。

有时,使用预定义的.NET 异常类型并不总是可行的。在这种情况下,有必要生成自己的自定义异常。在自定义异常类后面加上单词Exception,并确保包含以下三个构造函数:

  • Exception():使用默认值
  • Exception(string):接受字符串消息
  • Exception(string, exception):接受字符串消息和内部异常

如果必须抛出异常,请不要返回错误代码,而是返回包含有意义信息的异常。

可读的代码

代码的可读性越强,开发人员就越喜欢使用它。这样的代码更容易学习和使用。随着开发人员在项目中的来来往往,新手将能够轻松地阅读、扩展和维护代码。可读代码也不太容易出现错误和不安全。

松散耦合的代码

松散耦合的代码更容易测试和重构。如果需要,还可以更轻松地交换和更改松散耦合的代码。代码重用是松散耦合代码的另一个好处。

让我们使用一个糟糕的例子,数据库被传递到 SQL Server 连接。我们可以通过引用接口而不是具体类型,使同一个类松散耦合。让我们看一看前面的重构坏例子的一个好例子:

public class Database
{
    private IDatabaseConnection _databaseConnection;

    public Database(IDatabaseConnection databaseConnection)
    {
        _databaseConnection = datbaseConnection;
    }
}

正如您在这个相当基本的示例中所看到的,只要传入的类实现了IDatabaseConnection接口,我们就可以为任何类型的数据库连接传入任何类。因此,如果我们在 SQL Server 连接类中发现错误,则只有 SQL Server 客户端受到影响。这意味着具有不同数据库的客户端将继续工作,我们只需在一个类中修复 SQL Server 客户的代码。这减少了维护开销,从而降低了维护的总体成本。

高内聚

已知正确分组在一起的公共功能具有高度的内聚性。这样的代码很容易找到。例如,如果您查看Microsoft System.Diagnostics名称空间,您会发现它只包含与诊断相关的代码。在Diagnostics名称空间中包含集合和文件系统代码是没有意义的。

物体被干净地处理掉了

当使用一次性类时,您应该始终调用Dispose()方法来干净地处理正在使用的任何资源。这有助于消除内存泄漏的可能性。

有时,您可能需要将对象设置为null以使其超出范围。例如,一个静态变量包含对不再需要的对象的引用。

using语句也是使用一次性对象的一种很好的干净方法,因为当对象不再在范围内时,它会自动处理,因此您不需要显式调用Dispose()方法。让我们看看下面的代码:

using (var unitOfWork = new UnitOfWork())
{
 // Perform unit of work here.
}
// At this point the unit of work object has been disposed of.

该代码在using语句中定义了一个一次性对象,并在开始和结束花括号之间执行它所需的操作。在退出大括号之前,对象将自动释放。因此不需要手动调用Dispose()方法,因为它是自动调用的。

避免 Finalize()方法

使用非托管资源时,最好实现IDisposable接口,避免使用Finalize()方法。无法保证终结器何时运行。它们可能并不总是按照您期望的顺序或在您期望它们运行时运行。相反,在Dispose()方法中处理非托管资源更好、更可靠。

正确的抽象层次

当您只向更高级别公开需要公开的内容时,您就拥有了正确的抽象级别,并且不会在实现中迷失方向。

如果您发现自己在实现细节中迷失了方向,那么您就过度抽象了。如果您发现多个人必须同时在同一个类中工作,那么您的抽象不足。在这两种情况下,都需要重构来将抽象提升到正确的级别。

在大类中使用区域

区域对于在大型类中对项目进行分组非常有用,因为它们可以折叠。阅读一个大型类并在方法之间来回跳转可能会让人望而生畏,因此对类中相互调用的方法进行分组是一种很好的方法。然后,在处理一段代码时,可以根据需要折叠和扩展这些方法。

正如您从我们到目前为止所看到的,良好的编码实践使代码更可读、更易于维护。现在,我们将了解编码标准和原则以及一些软件方法(如 SOLID 和 DRY)的需求。

当今大多数软件都是由多个程序员团队编写的。大家都知道,我们都有自己独特的编码方式,我们都有某种形式的编程思想。您可以很容易地找到关于各种软件开发范例的编程争论。但大家一致认为,如果我们都遵守一套给定的编码标准、原则和方法,那么作为程序员,我们的生活确实会变得更轻松。

让我们更详细地回顾一下这些的含义。

编码标准

编码标准规定了一些必须遵守的注意事项。这些标准可以通过 FxCop 等工具强制执行,也可以通过同行代码审查手动执行。所有公司都有自己必须遵守的编码标准。但在现实世界中,您会发现,当业务部门期望达到最后期限时,这些编码标准可能会被忽略,因为最后期限可能变得比实际的代码质量更重要。这通常通过在 bug 列表中添加任何必需的重构来纠正,作为发布后需要解决的技术债务。

微软有自己的编码标准,大多数情况下,这些标准都是经过修改以适应每项业务需要而采用的标准。以下是一些在线编码标准的示例:

当跨团队或同一团队内的人员遵守编码标准时,您的代码库就会变得统一。统一的代码库更易于阅读、扩展和维护。它也可能不那么容易出错。如果确实存在错误,则更容易发现错误,因为代码遵循所有开发人员都遵守的一套标准准则。

编码原则

编码原则是编写高质量代码、测试和调试代码以及对代码执行维护的一组指导原则。程序员和编程团队的原则可能不同。

即使你是一个孤独的程序员,通过定义自己的编码原则并坚持这些原则,你也会为自己做出光荣的贡献。如果您在一个团队中工作,那么就一组编码标准达成一致,使共享代码的工作更容易,这对所有人都是非常有益的。

在本书中,您将看到一些编码原则的示例,如 SOLID、YAGNI、KISS 和 DRY,所有这些都将被详细解释。但就目前而言,SOLID代表单一责任原则、开闭原则、Liskov 替代、界面分离原则、依赖倒置原则雅格尼代表你不会需要它KISS代表保持简单,愚蠢,而DRY代表不要重复自己

编码方法

编码方法将软件开发过程分解为若干预定义的阶段。每个阶段都有许多与之相关的步骤。不同的开发人员和开发团队将有他们自己遵循的编码方法。编码方法的主要目的是简化从初始概念到编码阶段再到部署和维护阶段的流程。

在本书中,您将习惯于使用 SpecFlow 的测试驱动开发TDD)和行为驱动开发BDD),以及使用 PostSharp 的面向方面编程AOP)。

编码约定

最好实现 Microsoft C# 编码约定。您可以在查看 https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/inside-a-program/coding-conventions

通过采用 Microsoft 的编码约定,您可以保证以正式接受并商定的格式编写代码。这些 C# 编码约定可以帮助人们集中精力阅读代码,减少关注布局的时间。基本上,微软的编码标准促进了最佳实践。

模块化

将大型程序分解为较小的模块非常有意义。小模块易于测试,更易于重用,并且可以独立于其他模块进行工作。小型模块也更易于扩展和维护。

模块化程序可以划分为不同的程序集以及这些程序集中的不同名称空间。模块化程序也更容易在团队环境中使用,因为不同的团队可以使用不同的模块。

在同一个项目中,代码通过添加反映名称空间的文件夹进行模块化。命名空间只能包含与其名称相关的代码。例如,如果您有一个名为FileSystem的名称空间,那么与文件和目录相关的类型应该放在该文件夹中。同样,如果您有一个名为Data的名称空间,那么只有与数据和数据源相关的类型才应该位于该名称空间中。

正确模块化的另一个好处是,如果您保持模块小而简单,那么它们很容易阅读。除了编码,编码人员的大部分生活都花在阅读和理解代码上。因此,代码越小,模块化越正确,阅读和理解代码就越容易。这将提高对代码的理解,并提高开发人员对代码的使用率。

你可能是计算机编程界的超级天才。您可能能够生成如此性感的代码,以至于其他程序员只能敬畏地盯着它看,最后在键盘上流口水。但是其他程序员仅仅通过看代码就知道代码是什么吗?如果您在 10 周内发现了该代码,那么当您深入到堆积如山的不同代码中时,您是否能够绝对清晰地解释代码的功能以及选择编码方法的基本原理?你有没有考虑过,你可能需要在今后的道路上继续研究这些代码?

你是否曾经编写过一些代码,然后离开,几天后看着它,然后想,我没有写这些垃圾,是吗?我在想什么!?我知道我为此感到内疚,我的一些前同事也是如此。

在编写代码时,必须保持代码简单,并采用即使是新手初级程序员也能理解的可读格式。通常情况下,junior 会接触到要阅读、理解和维护的代码。代码越复杂,年轻人获得速度所需的时间就越长。即使是老年人也会在复杂的系统中挣扎,以至于他们离开去别处找工作,这样对大脑和他们的健康都不那么费力。

例如,如果你在一个简单的网站上工作,问自己几个问题。它真的需要使用微服务吗?你正在进行的棕地项目真的很复杂吗?是否可以简化它以使其更易于维护?在开发新系统时,要编写一个性能良好的健壮、可维护和可扩展的解决方案,您需要的移动部件的最小数量是多少?

雅格尼

YAGNI 是敏捷编程领域的一门学科,它规定程序员在绝对需要之前不应添加任何代码。一个诚实的程序员会根据一个设计编写失败的测试,然后编写足够的生产代码使测试正常工作,最后重构代码以消除任何重复。使用 YAGNI 软件开发方法,您可以将类、方法和全部代码行保持在绝对最小。

YAGNI 的主要目标是防止计算机程序员过度设计软件系统。如果不需要,不要增加复杂性。你必须记住只写你需要的代码。不要写你不需要的代码,也不要为了实验和学习而写代码。将实验和学习代码保存在沙盒项目中,专门用于这些目的。

干的

我说不要重复你自己!如果您发现您在多个领域编写相同的代码,那么这就是重构的一个明确候选对象。您应该查看代码,看看它是否可以被泛化并放置在整个系统中使用的助手类中,或者是否可以放置在库中供其他项目使用。

如果同一段代码位于多个位置,并且您发现该代码存在故障并需要修改,则必须在其他区域修改该代码。在这种情况下,很容易忽略需要修改的代码。其结果是,代码在某些区域修复了问题,但在其他区域仍然存在。

这就是为什么在遇到重复代码时立即删除它是一个好主意,因为如果不这样做,它可能会导致更多问题。

固体

SOLID 是一组五条设计原则,旨在使软件更易于理解和维护。软件代码应该易于阅读和扩展,而无需修改现有代码的某些部分。五个实体设计原则如下:

  • 单一责任原则:类和方法只能执行单一责任。构成单一责任的所有要素应分组并封装。
  • 开/关原则:类和方法应该是开放的进行扩展,关闭的进行修改。当需要更改软件时,您应该能够在不修改任何代码的情况下扩展软件。
  • Liskov 替换:您的函数有一个指向基类的指针。它必须能够在不知道的情况下使用从基类派生的任何类。
  • 接口隔离原则:当您有大型接口时,使用它们的客户端可能不需要所有的方法。因此,使用接口隔离原则ISP),您可以提取出不同接口的方法。这意味着,您没有一个大接口,而是有许多小接口。然后类可以只使用它们需要的方法实现接口。
  • 依赖倒置原则:当您有一个高级模块时,它不应该依赖任何低级模块。您应该能够在低级别模块之间自由切换,而不会影响使用它们的高级别模块。高级和低级模块都应该依赖于抽象。

抽象不应该依赖于细节,但细节应该依赖于抽象。

声明变量时,应始终使用静态类型,如接口或抽象类。然后,可以将实现接口或从抽象类继承的具体类分配给变量。

奥卡姆剃刀

Occam 的 Razor 声明如下:实体不应在没有必要的情况下进行乘法。换句话说,这本质上意味着最简单的解决方案很可能是正确的。因此,在软件开发中,打破奥卡姆剃须刀的原则是通过对软件问题做出不必要的假设和采用最简单的解决方案来实现的。

软件项目通常建立在一系列事实和假设的基础上。事实很容易处理,但假设是另一回事。当提出问题的软件项目解决方案时,您通常作为一个团队讨论问题和潜在的解决方案。在选择解决方案时,您应该始终选择假设最少的项目,因为这将是实施的最准确的选择。如果存在一些合理的假设,那么您必须做出的假设越多,您的设计解决方案就越有可能存在缺陷。

具有较少活动部件的项目具有较少可能出错的部件。因此,通过尽可能少的实体来保持项目的小型化,除非必要,否则不做假设,只处理事实,您遵守了 Occam 剃须刀的原则。

在本章中,您已经介绍了好代码和坏代码,希望您现在能够理解为什么好代码很重要。我们还向您提供了指向 Microsoft C# 编码约定的链接,以便您可以遵循 Microsoft 最佳编码实践(如果您还没有这样做的话)。

您还简要介绍了各种软件方法,包括 DRY、KISS、SOLID、YAGNI 和 Occam 的 Razor。

使用模块化,您已经看到了使用名称空间和程序集模块化代码的好处。这些好处包括独立的团队能够处理独立的模块,以及代码的可重用性和可维护性。

在下一章中,我们将研究同行代码评审。它们有时会令人不快,但同行代码评审通过确保程序员遵守公司的编码过程,有助于保持程序员的控制。

  1. 坏代码的一些后果是什么?
  2. 好代码的一些结果是什么?
  3. 编写模块化代码有哪些好处?
  4. 什么是干代码?
  5. 为什么在编写代码时要接吻?
  6. 首字母缩略词 SOLID 代表什么?
  7. 解释一下雅格尼。
  8. 奥卡姆的剃须刀是什么?

教程来源于Github,感谢apachecn大佬的无私奉献,致敬!

技术教程推荐

算法面试通关40讲 -〔覃超〕

MySQL实战45讲 -〔林晓斌〕

10x程序员工作法 -〔郑晔〕

Python核心技术与实战 -〔景霄〕

分布式数据库30讲 -〔王磊〕

Spark性能调优实战 -〔吴磊〕

深入C语言和程序运行原理 -〔于航〕

快手 · 移动端音视频开发实战 -〔展晓凯〕

AI大模型之美 -〔徐文浩〕