C# 类、对象和数据结构详解

在本章中,我们将介绍类的组织、格式和注释。我们还将研究如何编写符合德米特定律的干净 C# 对象和数据结构。此外,我们将研究不可变对象和数据结构,以及在System.Collections.Immutable名称空间中定义不可变集合的接口和类。

我们将涵盖以下广泛的主题:

随着本章的学习,您将学习以下技能:

  • 如何使用名称空间有效地组织类。
  • 你的课程将变得更小,更有意义,因为你学会了用一个单一的责任来编程。
  • 在编写自己的 API 时,您将能够通过提供有助于文档生成工具的注释来提供良好的开发人员文档。
  • 您编写的任何程序都很容易修改和扩展,因为它们具有高内聚性和低耦合性。
  • 最后,您将能够应用德米特定律,编写和使用不变的数据结构。

因此,让我们先看看如何使用名称空间有效地组织类。

您可以访问 GitHub 上本章的代码,网址为https://github.com/PacktPublishing/Clean-Code-in-C-/tree/master/CH03

你会注意到一个干净的项目的特点是它会有组织良好的课程。和文件夹将用于将属于一起的类分组。此外,文件夹中的类将包含在与程序集名称和文件夹结构匹配的名称空间中。

每个接口、类、结构和枚举都应该在正确的命名空间中有自己的源文件。源文件应在适当的文件夹中按逻辑分组,并且源文件的命名空间应与程序集名称和文件夹结构匹配。以下屏幕截图演示了一个干净的文件夹和文件结构:

It is a bad idea to have more than one interface, class, struct, or enum in an actual source file. The reason for this is that it can make locating items difficult, despite the fact that we have IntelliSense to assist us.

在考虑名称空间时,最好遵循 Pascal 大小写顺序,即公司名称、产品名称、技术名称,然后是用空格分隔的组件的复数名称。请参见以下示例:

FakeCompany.Product.Wpf.Feature.Subnamespace {} // Product, technology and feature specific.

以公司名称开头的原因是它有助于避免命名空间类。因此,如果 Microsoft 和 FakeCompany 都有一个名为System的名称空间,那么您希望使用的System可以通过公司名称来区分。

接下来,可以在多个项目中重用的任何代码项最好放在可由多个项目访问的单独程序集中:

FakeCompany.Wpf.Feature.Subnamespace {} /* Technology and feature specific. Can be used across multiple products. */

在代码中使用测试时,例如在执行测试驱动开发TDD时),最好将测试类保存在单独的程序集中。应始终为测试程序集指定其正在测试的程序集的名称,并在程序集名称的末尾附加名称空间Tests

FakeCompany.Core.Feature {} /* Technology agnostic and feature specific. Can be used across multiple products. */

决不能将不同程序集的测试彼此放在同一测试程序集中。始终将它们分开。

此外,命名空间和类型不应使用相同的名称,因为这可能会产生编译器冲突。当名称空间多元化时,您可以放弃公司名称、产品名称和首字母缩略词的多元化。

总而言之,组织课程时要记住以下规则:

  • 遵循 Pascal 大小写顺序,即公司名称、产品名称、技术名称以及用空格分隔的组件的复数名称。
  • 将可重用的代码项放在单独的程序集中。
  • 名称空间和类型不要使用相同的名称。
  • 不要将公司、产品名称和首字母缩略词多元化。

我们将继续讨论课程的责任。

责任是分配给班级的工作。在坚实的原则集合中,S 代表单一责任原则SRP)。当应用于一个类时,SRP 声明该类只能在所实现的特性的一个方面工作。这个方面的职责应该完全封装在类中。因此,您不应该对一个类应用多个职责。

让我们看一个例子来了解原因:

public class MultipleResponsibilities() 
{
    public string DecryptString(string text, 
     SecurityAlgorithm algorithm) 
    { 
        // ...implementation... 
    }

    public string EncryptString(string text, 
     SecurityAlgorithm algorithm) 
    { 
        // ...implementation... 
    }

    public string ReadTextFromFile(string filename) 
    { 
        // ...implementation... 
    }

    public string SaveTextToFile(string text, string filename) 
    { 
        // ...implementation... 
    }
}

正如您在前面的代码中所看到的,对于MultipleResponsibilities类,我们使用DecryptStringEncryptString方法实现了加密功能。我们还通过ReadTextFromFileSaveTextToFile方法实现了文件访问。这门课违反了 SRP 原则。

所以我们需要将这个类分为两个类,一个用于加密,另一个用于文件访问:

namespace FakeCompany.Core.Security
{
    public class Cryptography
    {    
        public string DecryptString(string text, 
         SecurityAlgorithm algorithm) 
        { 
            // ...implementation... 
        }

        public string EncryptString(string text, 
         SecurityAlgorithm algorithm) 
        { 
            // ...implementation... 
        }  
    }
}

正如我们现在可以从前面的代码中看到的,通过将EncryptStringDecryptString方法移动到核心安全名称空间中它们自己的Cryptography类中,我们可以很容易地重用代码来跨不同的产品和技术组加密和解密字符串。Cryptography等级也符合 SRP。

在下面的代码中,我们可以看到Cryptography类的SecurityAlgorithm参数是一个枚举,并且已经放在它自己的源文件中。这有助于保持代码干净、最少和组织良好:

using System;

namespace FakeCompany.Core.Security
{
    [Flags]
    public enum SecurityAlgorithm
    {
        Aes,
        AesCng,
        MD5,
        SHA5
    }
}

现在,在下面的TextFile类中,我们再次遵守 SRP,并且在适当的核心文件系统名称空间中有一个很好的可重用类。TextFile类可跨不同的产品和技术组重用:

namespace FakeCompany.Core.FileSystem
{
    public class TextFile
    {
        public string ReadTextFromFile(string filename) 
        { 
            // ...implementation... 
        }

        public string SaveTextToFile(string text, string filename) 
        { 
            // ...implementation... 
        }
    }
}

我们已经了解了班级的组织和职责。现在让我们来看一看如何为其他开发者提供课堂评论。

无论是内部项目还是其他开发人员将使用的外部软件,记录源代码始终是一个好主意。内部项目因为开发人员的流动而受到影响,而且往往很差,或者几乎没有帮助新开发人员跟上进度的文档。许多第三方 API 未能起步,或者推广速度比预期的要慢,通常由于开发人员文档的糟糕状态,采用者因沮丧而放弃了 API。

最好在每个源代码文件的顶部包含版权声明,并对名称空间、接口、类、枚举、结构、方法和属性进行注释。您的版权注释应首先出现在源文件中,位于using语句上方,并采用多行注释的形式,以/*开头,以*/结尾:

/**********************************************************************************
 * Copyright 2019 PacktPub
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy of 
 * this software and associated documentation files (the "Software"), to deal in 
 * the Software without restriction, including without limitation the rights to use, 
 * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the 
 * Software, and to permit persons to whom the Software is furnished to do so, 
 * subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all 
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 
 * SOFTWARE. 
 *********************************************************************************/

using System;

/// <summary>
/// The CH3.Core.Security namespace contains fundamental types used 
/// for the purpose of implementing application security.
/// </summary>
namespace CH3.Core.Security
{
    /// <summary>
    /// Encrypts and decrypts provided strings based on the selected 
    /// algorithm.
    /// </summary>
    public class Cryptography
    {
        /// <summary>
        /// Decrypts a string using the selected algorithm.
        /// </summary>
        /// <param name="text">The string to be decrypted.</param>
        /// <param name="algorithm">
        /// The cryptographic algorithm used to decrypt the string.
        /// </param>
        /// <returns>Decrypted string</returns>
        public string DecryptString(string text, 
         SecurityAlgorithm algorithm)
        {
            // ...implementation... 
            throw new NotImplementedException();
        }

        /// <summary>
        /// Encrypts a string using the selected algorithm.
        /// </summary>
        /// <param name="text">The string to encrypt.</param>
        /// <param name="algorithm">
        /// The cryptographic algorithm used to encrypt the string.
        /// </param>
        /// <returns>Encrypted string</returns>
        public string EncryptString(string text, 
         SecurityAlgorithm algorithm)
        {
            // ...implementation... 
            throw new NotImplementedException();
        }
    }
}

前面的代码示例提供了一个带有文档化方法的文档化名称空间和类的示例。您将看到名称空间和包含的成员的文档注释以文档注释///开始,并直接位于被注释项的上方。键入三个正斜杠时,VisualStudio 会根据下面的行自动生成 XML 标记。

例如,在前面的代码中,命名空间和类都只有摘要,但这两个方法都包含摘要、两个参数注释和一个返回注释。

下表包含可以在文档注释中使用的不同 XML 标记。

| 标签 | 节 | 目的 | | <c> | <c> | 将文本格式化为代码 | | <code> | <code> | 提供源代码作为输出 | | <example> | <example> | 提供了一个例子 | | <exception> | <exception> | 描述该方法可以引发的异常 | | <include> | <include> | 包含来自外部文件的 XML | | <list> | <list> | 添加列表或表格 | | <para> | <para> | 为文本添加结构 | | <param> | <param> | 描述构造函数或方法的参数 | | <paramref> | <paramref> | 标记一个单词以标识它是一个参数 | | <permission> | <permission> | 描述成员的安全可访问性 | | <remarks> | <remarks> | 提供其他信息 | | <returns> | <returns> | 描述返回类型 | | <see> | <see> | 添加超链接 | | <seealso> | <seealso> | 添加一个项,另请参见项 | | <summary> | <summary> | 总结类型或成员 | | <value> | <value> | 描述值 | | <typeparam> | | 描述类型参数 | | <typeparamref> | | 标记一个单词以将其标识为类型参数 |

从上表可以明显看出,您有足够的空间来记录源代码。因此,最好利用可用的标记来记录代码。文档越好,其他开发人员就越能更快、更容易地使用代码。

现在是研究内聚和耦合的时候了。

在设计良好的 C# 汇编中,代码将正确地分组在一起。这被称为高内聚低内聚是指将不属于一起的代码分组在一起。

您希望相关类尽可能独立。一个类对另一个类的依赖性越强,耦合度越高。这被称为紧耦合。相互独立的类越多,内聚性越低。这被称为低内聚。

因此,在定义良好的类中,您需要高内聚和低耦合。我们现在来看紧耦合和低耦合的例子。

紧耦合的一个例子

在下面的代码示例中,TightCouplingA类破坏了封装,使_name变量可以直接访问。_name变量应该是私有的,并且只能通过其封闭类中方法的属性进行修改。Name属性提供了getset方法来验证_name变量,但这是毫无意义的,因为这些检查可以绕过,并且属性不被调用:

using System.Diagnostics;

namespace CH3.Coupling
{
    public class TightCouplingA
    {
        public string _name;

        public string Name
        {
            get
            {
                if (!_name.Equals(string.Empty))
                    return _name;
                else
                    return "String is empty!";
            }
            set
            {
                if (value.Equals(string.Empty))
                    Debug.WriteLine("String is empty!");
            }
        }
    }
}

另一方面,在下面的代码中,TightCouplingB类创建了一个TightCouplingA的实例。然后通过直接访问_name成员变量并将其设置为null,然后直接访问并将其值打印到调试输出窗口,引入两个类之间的紧密耦合:

using System.Diagnostics;

namespace CH3.Coupling
{
    public class TightCouplingB
    {
        public TightCouplingB()
        {
            TightCouplingA tca = new TightCouplingA();
            tca._name = null;
            Debug.WriteLine("Name is " + tca._name);
        }
    }
}

现在,让我们看一下使用低耦合的同一个简单示例。

低耦合的一个例子

在本例中,我们有两个类,LooseCouplingALooseCouplingBLooseCouplingA声明一个名为_name的私有实例变量,该变量通过公共属性设置。

LooseCouplingB创建LooseCouplingA实例,获取并设置Name的值。由于无法直接设置_name数据成员,因此会执行设置和获取该数据成员值的检查。

我们有一个松耦合的例子。让我们看看两个名为LooseCouplingALooseCouplingB的类,它们显示了这一点:

using System.Diagnostics;

namespace CH3.Coupling
{
    public class LooseCouplingA
    {
        private string _name;
        private readonly string _stringIsEmpty = "String is empty";

        public string Name
        {
            get
            {
                if (_name.Equals(string.Empty))
                    return _stringIsEmpty;
                else
                    return _name;
            }

            set
            {
                if (value.Equals(string.Empty))
                    Debug.WriteLine("Exception: String length must be 
                     greater than zero.");
            }
        }
    }
}

LooseCouplingA类中,我们将_name字段声明为私有字段,从而防止直接修改数据。_name数据由Name属性间接访问:


using System.Diagnostics;

namespace CH3.Coupling
{
    public class LooseCouplingB
    {
        public LooseCouplingB()
        {
            LooseCouplingA lca = new LooseCouplingA();
            lca = null;
            Debug.WriteLine($"Name is {lca.Name}");
        }
    }
}

LooseCouplingB类无法直接访问LooseCouplingB类的_name变量,因此通过属性修改数据成员。

我们已经研究了耦合,现在知道了如何避免紧耦合代码并实现松耦合代码。现在,我们来看看一些低内聚和高内聚的例子。

低内聚性的一个例子

当一个班级有不止一个职责时,就称之为低内聚班级。请查看以下代码:

namespace CH3.Cohesion
{
    public class LowCohesion
    {
        public void ConnectToDatasource() { }
        public void ExtractDataFromDataSource() { }
        public void TransformDataForReport() { }
        public void AssignDataAndGenerateReport() { }
        public void PrintReport() { }
        public void CloseConnectionToDataSource() { }
    }
}

正如我们所看到的,前面的类至少有三个职责:

  • 连接到数据源和断开与数据源的连接
  • 提取数据并进行转换,以便插入报告
  • 生成报告并打印出来

您将清楚地看到这是如何打破 SRP 的。接下来,我们将把这个类分解为三个遵循 SRP 的类。

高内聚性的一个例子

在本例中,我们将把LowCohesion类分解为三个遵循 SRP 的类。这些将被称为ConnectionDataProcessorReportGenerator。让我们看看在实现这三个类之后,代码有多干净。

在以下类中,您可以看到该类中唯一与连接到数据源相关的方法:

namespace CH3.Cohesion
{
     public class Connection
     {
         public void ConnectToDatasource() { }
         public void CloseConnectionToDataSource() { }
     }
}

类本身被命名为Connection,因此这是一个高内聚类的示例。

在下面的代码中,DataProcessor类包含两个方法,通过从数据源提取数据并转换该数据以插入报表来处理数据:

namespace CH3.Cohesion
{
     public class DataProcessor
     {
         public void ExtractDataFromDataSource() { }
         public void TransformDataForReport() { }
     }
}

这是另一个高度内聚类的例子。

在下面的代码中,ReportGenerator类只有与生成和输出报告相关联的方法:

namespace CH3.Cohesion
{
    public class ReportGenerator
    {
        public void AssignDataAndGenerateReport() { }
        public void PrintReport() { }
    }
}

同样,这是另一个高度内聚类的例子。

查看这三个类中的每一个,我们可以看到它们只包含与其单一职责相关的方法。所以前面的三个类都是高度内聚的。

现在是时候来看看我们是如何通过使用接口来代替类来设计代码的,这样代码就可以通过依赖注入和控制反转注入到构造函数和方法中。

设计变更时,应将什么变更为如何

什么是业务的需求。正如任何参与软件开发的经验丰富的人都会告诉您,需求经常变化。因此,软件必须能够适应这些变化。业务部门对软件和基础设施团队如何实现需求不感兴趣,只对准时、按预算满足需求感兴趣。

另一方面,软件和基础设施团队更关注如何满足这些业务需求。无论项目采用何种技术和过程来实现需求,软件和目标环境都必须能够适应不断变化的需求。

但这还不是全部。你看,软件版本经常会随着错误修复和新功能而改变。随着新功能的实现和重构的进行,软件代码变得不受欢迎,并最终过时。最重要的是,软件供应商有他们的软件路线图,这是他们应用生命周期管理的一部分。最终,软件版本将退役,不再受供应商支持。这可能会迫使将不再受支持的当前版本迁移到新的受支持版本,这可能会带来必须解决的破坏性更改。

面向接口编程

面向接口编程IOP帮助我们编写多态代码。OOP 中的多态性定义为具有相同接口的各自实现的不同类。因此,通过使用接口,我们可以修改软件以满足业务需求。

让我们考虑一个数据库连接示例。可能需要应用连接到不同的数据源。但是,无论使用何种数据库,数据库代码如何保持不变?答案在于使用接口。

您有不同的数据库连接类来实现相同的数据库连接接口,但它们都有各自版本的实现方法。这就是所谓的多态性。然后,数据库接受数据库连接接口类型的数据库连接参数。然后,可以将实现数据库连接接口的任何数据库连接类型传递到数据库中。让我们编写这个示例,使其更加清晰。

首先创建一个简单的.NET Framework 控制台应用。然后更新Program类,如下所示:

static void Main(string[] args)
{
    var program = new Program();
    program.InterfaceOrientedProgrammingExample();
}

private void InterfaceOrientedProgrammingExample()
{
    var mongoDb = new MongoDbConnection();
    var sqlServer = new SqlServerConnection();
    var db = new Database(mongoDb);
    db.OpenConnection();
    db.CloseConnection();
    db = new Database(sqlServer);
    db.OpenConnection();
    db.CloseConnection();
}

在此代码中,Main()方法创建Program类的新实例,然后调用InterfaceOrientedProgrammingExample()方法。在该方法中,我们实例化了两个不同的数据库连接,一个用于 MongoDB,另一个用于 SQL Server。然后用 MongoDB 连接实例化数据库,打开数据库连接,然后关闭它。然后,我们使用相同的变量实例化一个新数据库,并传入一个 SQL Server 连接,然后打开连接并关闭连接。如您所见,我们只有一个带有单个构造函数的Database类,而Database类将与实现所需接口的任何数据库连接一起工作。那么,让我们添加IConnection接口:

public interface IConnection
{
    void Open();
    void Close();
}

接口只有两种方法,分别称为Open()Close()。添加将实现此接口的 MongoDB 类:

public class MongoDbConnection : IConnection
{
    public void Close()
    {
        Console.WriteLine("Closed MongoDB connection.");
    }

    public void Open()
    {
        Console.WriteLine("Opened MongoDB connection.");
    }
}

我们可以看到该类实现了IConnection接口。每个方法都会向控制台打印一条消息。现在添加SQLServerConnection类:

public class SqlServerConnection : IConnection
{
    public void Close()
    {
        Console.WriteLine("Closed SQL Server Connection.");
    }

    public void Open()
    {
        Console.WriteLine("Opened SQL Server Connection.");
    }
}

Database类也是如此。它实现了IConnection接口,对于每个方法调用,都会向控制台打印一条消息。下面是Database课程,内容如下:

public class Database
{
    private readonly IConnection _connection;

    public Database(IConnection connection)
    {
        _connection = connection;
    }

    public void OpenConnection()
    {
        _connection.Open();
    }

    public void CloseConnection()
    {
        _connection.Close();
    }
}

Database类接受IConnection参数。这将设置_connection成员变量。OpenConnection()方法打开数据库连接,CloseConnection()方法关闭数据库连接。好了,是时候运行这个程序了。您应该在控制台窗口中看到以下输出:

Opened MongoDB connection.
Closed MongoDB connection.
Opened SQL Server Connection.
Closed SQL Server Connection.

现在,您可以看到编程到接口的好处了。您可以看到它们如何使我们能够在不修改现有代码的情况下扩展程序。这意味着,如果我们需要支持更多的数据库,那么我们所要做的就是编写更多实现IConnection接口的连接对象。

现在您已经知道了接口是如何工作的,我们可以看看如何将它们应用于依赖项注入和控制反转。依赖注入帮助我们编写松散耦合且易于测试的干净代码,而控制反转可以根据需要实现软件实现的交换,只要这些实现实现相同的接口。

依赖注入与控制反转

在 C# 中,我们能够使用依赖注入DI)和控制反转IoC来满足不断变化的软件需求。这两个术语确实有不同的含义,但通常互换使用以表示同一事物。

使用 IoC,您可以编写一个框架,通过调用模块来完成任务。IoC 容器用于保存模块的寄存器。这些模块在用户请求或配置请求时加载。

DI 从类中删除内部依赖项。依赖对象然后由外部调用方注入。IoC 容器使用 DI 将依赖对象注入到对象或方法中。

在本章中,您将找到一些有用的资源,帮助您了解 IoC 和 DI。然后,您将能够在程序中使用这些技术。

让我们看看如何在没有任何第三方框架的情况下实现我们自己的简单 DI 和 IoC。

DI 的一个例子

在本例中,我们将推出我们自己的简单 DI。我们将有一个ILogger接口,该接口将有一个带有字符串参数的单一方法。然后,我们将生成一个名为TextFileLogger的类,该类实现ILogger接口并将字符串输出到文本文件。最后,我们将有一个Worker类来演示构造函数注入和方法注入。让我们看看代码。

以下接口有一个方法,用于实现类,以根据该方法的实现输出消息:

namespace CH3.DependencyInjection
{
     public interface ILogger
     {
         void OutputMessage(string message);
     }
}

TexFileLogger类实现ILogger接口并将消息输出到文本文件:

using System;

namespace CH3.DependencyInjection
{
    public class TextFileLogger : ILogger
    {
        public void OutputMessage(string message)
        {
            System.IO.File.WriteAllText(FileName(), message);
        }

        private string FileName()
        {
            var timestamp = DateTime.Now.ToFileTimeUtc().ToString();
            var path = Environment.GetFolderPath(Environment
             .SpecialFolder.MyDocuments);
            return $"{path}_{timestamp}";
        }
    }
}

Worker类提供了构造函数 DI 和方法 DI 的示例。请注意,该参数是一个接口。因此,任何实现该接口的类都可以在运行时注入:

namespace CH3.DependencyInjection
{
     public class Worker
     {
         private ILogger _logger;

         public Worker(ILogger logger)
         {
             _logger = logger;
             _logger.OutputMessage("This constructor has been injected 
              with a logger!");
         }

         public void DoSomeWork(ILogger logger)
         {
             logger.OutputMessage("This methods has been injected 
              with a logger!");
         }
     }
}

DependencyInject方法运行示例以显示 DI 的作用:

        private void DependencyInject()
        {
            var logger = new TextFileLogger();
            var di = new Worker(logger);
            di.DoSomeWork(logger);
        }

正如您刚才看到的代码所示,我们首先生成一个TextFileLogger类的新实例。然后将该对象注入到辅助对象的构造函数中。然后调用DoSomeWork方法并传入TextFileLogger实例。在这个简单的示例中,我们看到了如何通过类的构造函数和方法将代码注入到类中。

这段代码的优点在于它消除了 worker 和TextFileLogger实例之间的依赖关系。这使得我们可以很容易地将TextFileLogger实例替换为实现ILogger接口的任何其他类型的记录器。例如,我们可以使用事件查看器记录器,甚至数据库记录器。使用 DI 是减少代码耦合的好方法。

既然我们已经看到了 DI 的作用,我们也应该看看国际奥委会。我们现在就开始。

国际奥委会的一个例子

在本例中,我们将向 IoC 容器注册依赖项。然后,我们将使用 DI 注入必要的依赖项。

在下面的代码中,我们有一个 IoC 容器。容器将要注入的依赖项注册到字典中,并从配置元数据中读取值:

using System;
using System.Collections.Generic;

namespace CH3.InversionOfControl
{
    public class Container
    {
        public delegate object Creator(Container container);

        private readonly Dictionary<string, object> configuration = new 
         Dictionary<string, object>();
        private readonly Dictionary<Type, Creator> typeToCreator = new 
         Dictionary<Type, Creator>();

        public Dictionary<string, object> Configuration
        {
            get { return configuration; }
        }

        public void Register<T>(Creator creator)
        {
            typeToCreator.Add(typeof(T), creator);
        }

        public T Create<T>()
        {
            return (T)typeToCreator[typeof(T)](this);
        }

        public T GetConfiguration<T>(string name)
        {
            return (T)configuration[name];
        }
    }
}

然后,我们创建一个容器,并使用该容器配置元数据、注册类型和创建依赖项的实例:

private void InversionOfControl()
{
    Container container = new Container();
    container.Configuration["message"] = "Hello World!";
    container.Register<ILogger>(delegate
    {
        return new TextFileLogger();
    });
    container.Register<Worker>(delegate
    {
        return new Worker(container.Create<ILogger>());
    });
}

接下来,我们将研究如何利用德米特定律将物体的知识限制为只知道其近亲。这将帮助我们编写一个干净的 C 代码,避免使用导航列车。

Demeter 定律的目的是消除导航序列(点计数),它还旨在提供松散耦合代码的良好封装。

理解导航列车的方法违反了德米特定律。例如,请查看以下代码:

report.Database.Connection.Open(); // Breaks the Law of Demeter.

每个代码单元都应该有有限的知识。这些知识只应该是密切相关的相关代码。根据得墨忒尔定律,你必须说,而不是问。使用此法则,只能调用以下一个或多个对象的方法:

  • 作为参数传递
  • 本地创建
  • 实例变量
  • 全球的

实施得墨忒尔法可能很困难,但说出来比问出来更有好处。其中一个好处是代码的解耦。

很高兴看到一个坏的例子,它违反了德米特定律,同时也遵守了德米特定律,所以我们将在下面的章节中看到这一点。

德米特定律的好例子和坏例子(链锁)

在这个好的示例中,我们使用了 report 实例变量。在报表变量对象实例上,调用打开连接的方法。这并不违法。

下面的代码是一个Connection类,它有一个打开连接的方法:

namespace CH3.LawOfDemeter
{
    public class Connection
    {
        public void Open()
        {
            // ... implementation ...
        }
    }
}

Database类创建一个新的Connection对象并打开一个连接:

namespace CH3.LawOfDemeter
{
    public class Database
    {
        public Database()
        {
            Connection = new Connection();
        }

        public Connection Connection { get; set; }

        public void OpenConnection()
        {
            Connection.Open();
        }
    }
}

Report类中,实例化Database对象,然后打开与数据库的连接:

namespace CH3.LawOfDemeter
{
    public class Report
    {
        public Report()
        {
            Database = new Database();
        }

        public Database Database { get; set; }

        public void OpenConnection()
        {
            Database.OpenConnection();
        }
    }
}

到目前为止,我们已经看到了遵守德米特定律的好代码。但以下是违反这项法律的法规。

Example类中,Demeter 定律被打破,因为我们引入了方法链接,如report.Database.Connection.Open()中所述:

namespace CH3.LawOfDemeter
{
    public class Example
    {
        public void BadExample_Chaining()
        {
            var report = new Report();
            report.Database.Connection.Open();
        }

        public void GoodExample()
        {
            var report = new Report();
            report.OpenConnection();
        }
    }
}

在这个糟糕的示例中,对 report 实例变量调用了Databasegetter。这是可以接受的。但随后会调用返回不同对象的Connectiongetter。这违反了德米特定律,就像打开连接的最后一次呼叫一样。

不可变类型通常被认为只是值类型。对于值类型,设置它们时,您不希望它们更改是有意义的。但也可以有不可变的对象类型和不可变的数据结构类型。不可变类型是一种内部状态在初始化后不会改变的类型。

不可变类型的行为不会让其他程序员感到惊讶或惊讶,因此符合最不惊讶的原则POLA。不可变类型的 POLA 一致性遵守客户机之间订立的任何契约,并且因为它是可预测的,程序员将发现很容易对其行为进行推理。

因为不可变类型是可预测的,并且不会改变,所以您不会遇到任何令人讨厌的意外。因此,您不必担心由于它们以某种方式被改变而产生的任何不良影响。这使得不可变类型非常适合在线程之间共享,因为它们是线程安全的,并且不需要防御性编程。

创建不可变类型并使用对象验证时,在该对象的生存期内,您拥有一个有效的对象。

让我们来看一个 C# 中不可变类型的示例。

不可变类型的示例

我们现在来看一个不可变对象。下面代码中的Person对象有三个私有成员变量。只能在构造函数中的创建期间设置这些参数。设置后,在对象的剩余生命周期内无法对其进行修改。每个变量只能通过只读属性读取:

namespace CH3.ImmutableObjectsAndDataStructures
{
    public class Person
    {
        private readonly int _id;
        private readonly string _firstName;
        private readonly string _lastName;

        public int Id => _id;
        public string FirstName => _firstName;
        public string LastName => _lastName;
        public string FullName => $"{_firstName} {_lastName}";
        public string FullNameReversed => $"{_lastName}, {_firstName}";

        public Person(int id, string firstName, string lastName)
        {
            _id = id;
            _firstName = firstName;
            _lastName = lastName;
        }
    }
}

现在我们已经看到了编写不可变对象和数据结构是多么容易,我们将研究对象中的数据和方法。

对象的状态存储在成员变量中。这些成员变量是数据。数据不应直接访问。您应该只通过公开的方法和属性提供对数据的访问。

为什么要隐藏数据并公开方法?

在 OOP 世界中,隐藏数据和公开方法称为封装。封装对外部世界隐藏类的内部工作。这使得在不破坏依赖于类的现有实现的情况下更改值类型变得容易。可以将数据设置为可读/写、可写或只读,从而在数据访问和使用方面为您提供更大的灵活性。您还可以验证输入,从而防止数据接收无效值。封装还使测试类变得更加容易,并且可以使类更加可重用和可扩展。

让我们看一个例子。

封装的一个例子

下面的代码示例显示了一个封装的类。Car对象是可变的。它具有一些属性,这些属性在构造函数初始化数据值后获取并设置数据值。构造函数和集合属性执行参数参数的验证。如果该值无效,将引发无效参数异常,否则将传回该值并设置数据值:

using System;

namespace CH3.Encapsulation
{
    public class Car
    {
        private string _make;
        private string _model;
        private int _year;

        public Car(string make, string model, int year)
        {
            _make = ValidateMake(make);
            _model = ValidateModel(model);
            _year = ValidateYear(year);
        }

        private string ValidateMake(string make)
        {
            if (make.Length >= 3)
                return make;
            throw new ArgumentException("Make must be three 
             characters or more.");
        }

        public string Make
        {
            get { return _make; }
            set { _make = ValidateMake(value); }
        }

        // Other methods and properties omitted for brevity.
    }
}

前面代码的好处是,如果需要更改获取或设置数据值的代码的验证,则可以在不破坏实现的情况下进行更改。

结构与类的不同之处在于它们使用值相等代替引用相等。除此之外,结构和类之间没有太大区别。

关于数据结构是应该将变量公开还是将其隐藏在 get 和 set 属性后面,存在着争论。选择哪一个完全取决于您自己,但我个人始终认为最好将数据隐藏在结构中,并且只通过属性和方法提供访问。在拥有安全的干净数据结构方面有一个警告,即一旦创建,结构就不应该允许自己被方法和获取属性所改变。原因是对临时数据结构的更改将被丢弃。

现在让我们看一个简单的数据结构示例。

数据结构示例

以下代码是一个简单的数据结构:

namespace CH3.Encapsulation
{
    public struct Person
    {
        public int Id { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }

        public Person(int id, string firstName, string lastName)
        {
            Id = id;
            FirstName = firstName;
            LastName = lastName;
        }
    }
}

如您所见,数据结构与类没有太大区别,因为它具有构造函数和属性。

至此,我们来到本章的结尾,现在将回顾我们所学到的知识。

在本章中,我们学习了如何在文件夹和包中组织名称空间,以及良好的组织如何帮助防止名称空间类。然后我们继续讨论课程和责任,看看为什么课程应该只有一个责任。我们还研究了内聚和耦合,以及为什么高内聚和低耦合很重要。

好的文档要求公共成员在文档工具中得到正确的注释,我们看到了如何使用 XML 注释来实现这一点。还通过 DI 和 IoC 的基本示例讨论了为什么应该为变更而设计的重要性。

得墨忒尔定律告诉你如何不与陌生人交谈,而只与直接的朋友交谈,以及如何避免被锁链锁住。最后,我们研究了对象和数据结构,以及它们应该隐藏什么和应该公开什么。

在下一章中,我们将简要介绍 C# 中的函数编程以及如何编写干净的小方法。我们还将学习避免在我们的方法中使用两个以上的参数,因为具有多个参数的方法可能会变得笨拙。另外,我们将学习避免重复,当在一个位置修复时,重复可能是一个麻烦的错误源,但仍然存在于代码的其他位置。

  1. 我们如何用 C 语言组织我们的课程?
  2. 一个班级应该承担多少责任?
  3. 您对文档生成器的代码有何评论?
  4. 衔接意味着什么?
  5. 耦合是什么意思?
  6. 凝聚力是高还是低?
  7. 联轴节是紧还是松?
  8. 有哪些机制可以帮助您针对变化进行设计?
  9. 什么是 DI?
  10. 什么是国际奥委会?
  11. 说出使用不可变对象的一个好处。
  12. 对象应该隐藏和显示什么?
  13. 结构应该隐藏和显示什么?

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

技术教程推荐

左耳听风 -〔陈皓〕

Elasticsearch核心技术与实战 -〔阮一鸣〕

DevOps实战笔记 -〔石雪峰〕

性能工程高手课 -〔庄振运〕

Electron开发实战 -〔邓耀龙〕

.NET Core开发实战 -〔肖伟宇〕

互联网人的英语私教课 -〔陈亦峰〕

Web安全攻防实战 -〔王昊天〕

结构写作力 -〔李忠秋〕