C# 简单高效编程教程(三)
九、使用外观简化复杂系统
在第八章中,你看到了如何重新定义多步算法中的一些步骤来完成一项任务。在这一章中,你会看到一个应用,它也执行一系列的任务。但是,您将创建一个简化的界面来执行这些任务,而不是重新定义其中的一些任务。在这种情况下,外观是有用的。
Note
您可能有兴趣了解外观和模板方法之间的区别。一般来说,模板方法属于一个基类,允许子类重新定义一些步骤。您创建一个类的对象并调用这个模板方法来完成您的工作。但是外观经常涉及许多不同类别的多个对象。这一次,您执行一系列步骤来完成涉及所有这些对象的任务。您不需要重新定义这些类中的方法;相反,你可以轻松地给他们打电话。因此,子系统类通常不知道外观的存在。
Facades 为您提供了一个以结构化方式访问不同类的各种方法的入口点。如果您强制执行一个不允许您直接访问单个方法的规则,取而代之的是,您只能通过您的 facade 来访问它们,那么这个 facade 就被称为opaquefacade;否则,它就是一个透明的门面。你也可以让你的门面变得静态。
问题陈述
一个人可以向银行申请贷款。银行在向客户发放贷款之前,必须进行一些背景核实。这种背景验证是一个复杂的过程,由各种子过程组成。银行官员在考虑贷款申请之前也可以参观客户的房产。如果申请人符合所有这些标准,他就可以获得贷款。但这里有一个关键:申请人不知道背景核实的细节,他只对最终结果感兴趣——他是否能获得贷款。银行官员如何做出决定与客户无关。在接下来的示例中,您将看到这样一个过程。为简单起见,我做了以下假设:
-
贷款申请人或客户必须有一些资产。如果资产价值低于他寻求的贷款金额,他就无法获得贷款。
-
如果客户已有贷款,他就不能获得任何新的贷款。
我们的工作是基于这些假设开发一个应用。为了更清楚起见,下面是一个示例输出:
Case-1:
Bob’s current asset value: USD 5000
He claims loan amount: USD 20000
He has an existing loan.
预期结果: Bob 无法获得贷款,原因如下:
-
余额不足。
-
旧贷款存在。
Case-2:
Jack's current asset value: USD 100000
He claims loan amount: USD 30000
He has no existing loan.
**预期结果:**杰克可以拿到贷款。
Case-3:
Tony's current asset value: USD 125000
He claims loan amount: USD 50000
He has an existing loan.
**预期结果:**托尼无法获得贷款,原因如下:
- 旧贷款存在。
让我们来构建应用。
初始程序
在这个例子中,您可以看到三个类:Person、Asset和LoanStatus。Person类的一个实例可以申请贷款。这个类有一个带三个参数的构造函数:name、assetValue和previousLoanExist。为了避免在实例创建期间输入和传递所有三个参数,我将最后两个参数设为可选。下面是Person类:
class Person
{
public string name;
public double assetValue;
public bool previousLoanExist;
public Person(string name,
double assetValue=100000,
bool previousLoanExist = false)
{
this.name = name;
this.assetValue = assetValue;
this.previousLoanExist = previousLoanExist;
}
}
注意这个类的构造函数。因为有两个可选参数,所以可以使用以下任何一行创建实例:
Person jack = new Person("Jack");
Person kate = new Person("Kate", 70000);
Person tony = new Person("Tony", 125000, true);
现在看Asset类。这个类有一个方法HasSufficientAssetValue来验证当前资产值是否大于或等于索赔金额。这里是Asset班:
class Asset
{
public bool HasSufficientAssetValue(Person person, double claimAmount)
{
Console.WriteLine($"Verifying whether {person.name} has sufficient asset value.");
return person.assetValue >= claimAmount ? true : false;
}
}
现在看LoanStatus类。这个类有一个方法HasPreviousLoans来验证一个人是否有贷款。
class LoanStatus
{
public bool HasPreviousLoans(Person person)
{
Console.WriteLine($"Verifying whether {person.name} has any previous loans.");
return person.previousLoanExist;
}
}
在方法体中,我使用了条件操作符。我也可以用链条。你可以选择其中任何一个来使方法HasSufficientAssetValue和HasPreviousLoans工作。
POINT TO NOTE
还要注意我在这里使用的简化语句:
return person.previousLoanExist;
而不是使用下面的行:
return person.previousLoanExist ? true : false;
我可以对Asset类中的HasSufficientAssetValue()方法做同样的事情。我保留了这两种变化,以便你可以熟悉它们。
演示 1
已经显示了Person、Asset和LoanStatus类。为了简单起见,我将所有三个类和下面的客户机代码放在一个文件中。我不会在下面的代码段中重复这些类。
Note
当您从 Apress 网站下载源代码时,请参考“第九章”中的文件夹“ImplementationWithoutFacade”来查看完整的程序。
现在,假设一个程序员新手编写了下面的客户端代码。他创建了一个名为bob的person实例,并显示他是否有资格获得贷款。这是可行的,但这是一个好的解决方案吗?我们接下来将对此进行分析。
class Program
{
static void Main()
{
Console.WriteLine("***Directly interacting with the subsystems.***");
Asset asset = new Asset();
LoanStatus loanStatus = new LoanStatus();
string status = "approved";
string reason = String.Empty;
bool assetValue, previousLoanExist;
// Person-1
Person bob = new Person("Bob", 5000, true);
// Starts background verification
assetValue = asset.HasSufficientAssetValue(bob, 20000);
previousLoanExist = loanStatus.HasPreviousLoans(bob);
if (!assetValue)
{
status = "Not approved.";
reason += "\nInsufficient balance.";
}
if (previousLoanExist)
{
status = "Not approved.";
reason += "\nOld loan exists.";
}
Console.WriteLine($"{bob.name}'s application status: {status}");
Console.WriteLine($"Remarks if any: {reason}");
Console.ReadKey();
}
}
输出
以下是输出:
***Directly interacting with the subsystems.***
Verifying whether Bob has sufficient asset value.
Verifying whether Bob has any previous loans.
Bob's application status: Not approved.
Remarks if any:
Insufficient balance.
Old loan exists.
分析
让我问你几个问题:
-
现在只有一个顾客。如果你有两个或两个以上的贷款申请人,你会怎么办?会在
Main()内部多次重复后台验证逻辑吗? -
你是否注意到在客户端代码中你暴露了你的后台验证逻辑?这是个好主意吗?
-
如果您不需要创建子系统实例(例如,
Asset或LoanStatus实例)来了解结果,您会有什么感觉?相反,您可以假设有一个贷款审批者实例,它是让您了解申请状态的唯一联系点。这将使您能够编写如下内容: -
将来,如果获得贷款有新的标准,让贷款审批者负责处理这种情况。
Person bob = new Person("Bob", 5000,true);
string approvalStatus = loanApprover.CheckLoanEligibility(bob, 20000);
Console.WriteLine($"{bob.name}'s application status:{approvalStatus}");
更好的程序
当你考虑这样的问题时,你意识到你应该寻找一个更好的解决方案。您可以建立一个单一的联系点(比如贷款审批者),以使您的代码更清晰、更易于维护。
类图
图 9-1 显示了演示 2 最重要部分的类图。
图 9-1
客户直接与贷款审批人交谈,以了解他是否能获得贷款
演示 2
这是演示 1 的改进版本:
using System
;
namespace UsingFacade
{
class Person
{
public string name;
public double assetValue;
public bool previousLoanExist;
public Person(string name,
double assetValue=100000,
bool previousLoanExist = false)
{
this.name = name;
this.assetValue = assetValue;
this.previousLoanExist = previousLoanExist;
}
}
class Asset
{
public bool HasSufficientAssetValue(Person person, double claimAmount)
{
Console.WriteLine($"Verifying whether {person.name} has the sufficient asset value.");
return person.assetValue >= claimAmount ? true : false;
}
}
class LoanStatus
{
public bool HasPreviousLoans(Person person)
{
Console.WriteLine($"Verifying whether {person.name} has any previous loans.");
//return person.previousLoanExist ? true : false;
// simplified statement
return person.previousLoanExist;
}
}
class LoanApprover
{
readonly Asset asset;
readonly LoanStatus loanStatus;
public LoanApprover()
{
asset = new Asset();
loanStatus = new LoanStatus();
}
public string CheckLoanEligibility(Person person, double claimAmount)
{
string status = "approved";
string reason = String.Empty;
Console.WriteLine($"\nChecking the loan approval status of {person.name}.");
Console.WriteLine($"[Current asset value:{person.assetValue}," +
$"claim amount:{claimAmount}," +
$"existing loan?:{person.previousLoanExist}.]\n");
if (!asset.HasSufficientAssetValue(person,claimAmount))
{
status = "Not approved.";
reason += "\nInsufficient balance.";
}
if(loanStatus.HasPreviousLoans(person))
{
status = "Not approved.";
reason +="\nOld loan exists.";
}
return string.Concat(status,"\nRemarks if any:",reason);
}
}
class Program
{
static void Main()
{
Console.WriteLine("***Simplifying the usage of a complex system using a facade.***");
// Using a facade
LoanApprover loanApprover = new LoanApprover();
// Person-1
Person bob = new Person("Bob", 5000,true);
string approvalStatus = loanApprover.CheckLoanEligibility(bob, 20000);
Console.WriteLine($"{bob.name}'s application status: {approvalStatus}");
// Person-2
Person jack = new Person("Jack");
approvalStatus = loanApprover.CheckLoanEligibility(jack, 30000);
Console.WriteLine($"{jack.name}'s application status: {approvalStatus}");
// Person-3
Person tony = new Person("Tony", 125000,true);
approvalStatus = loanApprover.CheckLoanEligibility(tony, 50000);
Console.WriteLine($"{tony.name}'s application status: {approvalStatus}");
Console.ReadKey();
}
}
}
输出
以下是输出:
***Simplifying the usage of a complex system using a facade.***
Checking the loan approval status of Bob.
[Current asset value:5000,claim amount:20000,existing loan?:True.]
Verifying whether Bob has sufficient asset value.
Verifying whether Bob has any previous loans.
Bob's application status: Not approved.
Remarks if any:
Insufficient balance.
Old loan exists.
Checking the loan approval status of Jack.
[Current asset value:100000,claim amount:30000,existing loan?:False.]
Verifying whether Jack has sufficient asset value.
Verifying whether Jack has any previous loans.
Jack's application status: approved
Remarks if any:
Checking the loan approval status of Tony.
[Current asset value:125000,claim amount:50000,existing loan?:True.]
Verifying whether Tony has sufficient asset value.
Verifying whether Tony has any previous loans.
Tony's application status: Not approved.
Remarks if any:
Old loan exists.
分析
使用外观,您可以获得以下好处:
-
你为你的客户做了一个简化的界面。
-
您减少了客户端需要处理的对象的数量。
-
如果有许多子系统,用一个外观管理这些子系统可以使通信更容易。
摘要
本章向您展示了如何在应用中使用外观。外观可以帮助您为处理许多子系统的客户开发一个简化的界面。我还讨论了外观和模板方法之间的区别。本章还回顾了不同类型的外观以及在应用中使用它们的优缺点。在考虑在应用中使用外观之前,有必要记住以下几点:
-
您不应该假设在一个应用中只能有一个外观。如果你觉得有用,你可以用两个或更多。
-
一个外观可以显示与另一个外观不同的行为。例如,您可以允许或禁止对子系统的直接访问。当您强制客户端通过 facade 创建实例时,您称之为不透明 facade。当您还允许直接访问子系统时,您正在使用一个透明的外观。
-
如果子系统发生变化,您需要将相应的行为合并到外观层中。
-
使用外观,您可以维护一个额外的编码层。在将产品交付给客户之前,您需要测试这一层。如果外观过于复杂,会产生一些额外的维护成本。
十、内存管理
内存管理是开发人员非常关心的问题,这是一个非常大的话题。本章旨在以简单的方式触及要点,并帮助您理解它们在编程中的重要性。
在创建应用时,仅仅遵循一些设计准则是不够的;这只是等式的一部分。当没有内存泄漏时,应用才是真正高效的。如果一个计算机程序运行了很长时间,但未能释放不再需要的内存资源,您可以猜测任何内存泄漏的影响。以下是一些常见症状:
-
随着时间的推移,机器变慢了。
-
应用中的特定操作需要更长时间来执行。
-
最坏的情况是,应用/系统会崩溃。
初学 C# 的程序员通常认为垃圾收集器(GC)可以在任何可能的情况下负责内存管理。但事实并非如此,不幸的是,这是一个常见的错误。这一章就是为这一讨论而编写的,它建议您防止内存泄漏,以创建更好、更高效的应用。
概观
考虑一个简单的例子。假设您有一个在线应用,用户需要填写一些数据,然后单击提交按钮。现在假设应用的开发人员错误地忘记了在用户按下提交按钮后释放一些不再需要的内存。假设由于这种判断失误,应用每次点击泄漏了 512 字节。在最初的点击过程中,您可能不会注意到任何性能下降。但是,如果成千上万的在线用户同时使用该应用,会发生什么呢?如果 100,000 个用户按下提交按钮,我们最终将损失 48.8 MB 的内存,1000 万(10,000,000)次点击导致 4.76 GB 的损失,等等。
简而言之,即使一个程序为一个普通操作泄漏了非常少量的数据,很明显,随着时间的推移,您将会看到某种类型的故障,例如您的设备因System.OutOfMemoryException而崩溃,或者设备中的操作变得非常慢,以至于您需要经常重启应用。 多快引起你的注意取决于应用 的泄露率。
在像 C++这样的非托管语言中,一旦预期的任务完成,就释放内存以避免内存泄漏。酪 NET 总是试图让你的编程生活变得更容易。它负责清除特定点之后无用的对象。在编程中,我们称之为脏对象或未引用对象。
它是如何清除脏东西的?英寸 NET 中,堆内存是托管的。这意味着公共语言运行库(CLR)会负责这项工作。在托管代码中,CLR 的垃圾回收器会为您完成这项工作,您不必释放托管内存。它移除堆中不用的东西,并重新收集内存以备将来使用。垃圾收集器程序作为低优先级线程在后台运行。它会为您跟踪脏对象。那个。NET 运行时可以定期调用此程序,从内存中移除未引用或脏的对象。在给定的时间点,如果一个对象没有引用,垃圾收集器会标记这个对象,并回收该对象占用的内存,假设不再需要它。
Note
理论上,一旦一个局部变量引用了一个对象,这个对象就可以在最早不再需要它的时候进行垃圾收集。但是如果在调试模式下禁用优化,对象的生存期会延长到块的末尾。但是 GC 可能不会立即回收内存。有各种因素,如可用内存和自上次收集以来的时间。这意味着孤立对象可以立即释放,或者可能会有一些延迟。
然而,有一个问题。一些对象需要特殊的代码来释放它们的资源。下面是一些常见的例子:你打开了一个文件,执行了一些读或写操作,但是忘记关闭文件。当您处理非托管对象、锁定机制、程序中的操作系统(OS)句柄等时,也需要类似的关注。程序员需要显式释放这些资源,以防止内存泄漏。 一般来说,程序员自己清理(或者释放)内存的时候,你说他们把对象处理掉了,但是 CLR 自动释放资源的时候,你说垃圾收集器执行了它的工作。垃圾收集器使用类实例的终结器(或析构器)来执行最后的清理。你很快就会看到关于他们的讨论 。
POINTS TO REMEMBER
程序员可以通过显式释放对象来释放资源,或者 CLR 可以通过垃圾收集机制自动释放资源。我们经常将它们分别称为处置和终结技术。
堆栈内存与堆内存
为了理解接下来的讨论,理解堆栈内存和堆内存之间的区别是很重要的。如果你知道区别,你可以跳过这一节。否则,继续阅读。
为了执行一个程序,操作系统给你一堆内存。该计划分为几个部分,为各种用途。有两大部分:一个是栈,一个是堆。
堆栈用于局部变量并跟踪程序的当前状态。堆栈遵循后进先出(LIFO)机制。它就像一堆框架,一个框架放在另一个框架的上面。你也可以把它想象成一组盒子,一个盒子放在另一个盒子上面。特定方法的所有局部变量都可以放在一个框架中。在特定的时刻,你可以访问栈顶的帧,但是你不能访问栈底的帧。一旦控件从某个方法返回,顶部的框架就会从堆栈中移除并被丢弃。当最下面的框架成为顶部框架时,可以访问它。这个过程可以继续,直到堆栈为空。为了演示这一点,让我们考虑下面的代码:
using System;
class SampleStackDemo
{
static void GetAnotherInt()
{
int c=3;
}
static void Main()
{
int a=1;
double b=2.5;
GetAnotherInt();
}
}
见下图(图 10-1 )。我在一张快照中向您展示了四个不同的阶段。该图显示了各种堆栈状态,如下所示:
图 10-1
程序运行时堆栈存储器的不同状态
-
Main()方法中的前两行已经执行完毕,Main()方法中的第三行(GetAnotherInt();)开始执行。假设控件进入了实际的方法体,并通过了this method,内的行int c=3;,但它没有到达方法体的末尾。您可以看到堆栈在这个阶段不断增长。 -
下图显示控制来自于
GetAnotherInt().,因此c=3不再在堆栈上。 -
下图显示正在清理堆栈。当控制离开
Main()时,a和b变量都被删除。但是遵循 LIFO(后进先出)结构,我将向您逐一展示中间删除。
简而言之,对于堆栈分配,您知道一旦从一个方法返回,顶部的框架就会被丢弃,您可以立即使用该空间。
另一方面,堆内存用于对象/引用类型。在这里,程序状态的跟踪并不重要。相反,它专注于存储数据。程序可以很容易地在堆中分配一些空间,并开始使用这些空间来存储信息。
Note
一旦你学会了多线程编程,你会发现每个线程都有自己的堆栈,但是它们共享同一个堆空间。
对于堆,可以以任何顺序添加或移除分配的空间。下面是一个示例图,便于您理解(图 10-2 )。
图 10-2
代表具有不同分配的堆内存的示例图
在这种情况下,您需要记住分配,并且在您重用空间之前,需要有人清除旧的分配。但是,如果忘记删除之前分配的内存,或者使用已经创建的引用指向堆中的另一个对象,或者将它设置为空,会发生什么情况呢?这些分配的内存空间将不断增加(变成垃圾),您将看到内存泄漏的影响。这就是 C# 中的垃圾收集器(GC)帮助你的地方。GC 会定期检查状态,并试图通过释放未使用的空间来帮助您。
每次创建对象时,CLR 都会在托管堆中分配内存。它可以一直分配内存,直到托管堆中的地址空间可用。GC 有一个优化引擎来决定何时回收未使用的内存。
问答环节
我想到了一个解决办法。我可以 在堆上分配内存 ,一旦我的工作完成,我会立即删除它。这样我可以防止垃圾生长。这种理解正确吗?
回答:
是的,建议的解决方案可以工作,并帮助您防止泄漏。但这并不容易。有些情况下,对象需要保持活动一段时间。考虑一个例子:使用一台高级打印机,你同时发送多封电子邮件和传真给不同的收件人。同时,你开始打印一些大文件。这是非常不可能的,所有的收件人同时收到数据,或一个文件有大量的页面被立即打印。因此,在这些情况下,立即删除不是明智的解决方案。
让我们假设有一个叫做 Test 的类。我理解为下面这一行,Test Test obj = new Test();,对象的空间将在堆内存中分配。但是参考变量呢?
回答:
参考变量将留在堆栈存储器中。图 10-3 描述了该场景。
图 10-3
堆栈上的对象引用指向堆中的实际内存
有时我会对这些推荐信感到疑惑?它们与 C/C++中的指针相似吗?
回答:
概念相似,但不相同。在我回答你的问题之前,让我进一步解释一些事情以便更好地理解。我已经提到过 GC 为您管理堆内存。它是如何管理这些东西的?简单来说:
-
它为您释放垃圾/未使用的空间,以便您可以重用这些空间。
-
其次,它可以应用压缩技术,这意味着它可以将所有分配的空间移至内存的一端,并将所有空闲空间移至内存的另一端。这将产生连续的空闲空间,帮助您分配大块内存。
第一点很重要,也是本章的主题。第二点也很重要,因为堆中可能包含分散的对象(见图 10-2 )。在许多情况下,您可能需要一大块连续的内存,虽然从技术上来说堆中有足够的空间,但在特定时间可能不可用。在这些场景中,压缩有助于获得足够的连续空间。这些引用是由垃圾收集器维护的,当这种洗牌完成时,你并没有意识到。
Note
实际上,你有两种不同类型的堆:一种是大对象堆(LOH),另一种是小对象堆(SOH)。大小为 85,000 字节及以上的对象放在大对象堆中。通常,这些是数组对象。为了便于讨论,我只是简单地提到“堆”这个词,而不是对它进行分类。soh 用于三个不同的代,您将在下一节中读到。
为了用简单的数字详细说明这些,让我们假设这是我们的堆(图 10-4 )。在垃圾收集器的清理操作之后,它可能如下所示(白色块表示为空闲/可用块)。
图 10-4
压缩前内存中的分散分配
您可以看到,如果您需要在我们的堆中分配五个连续的内存块,您现在不能分配它们,尽管总的来说有足够的空间。为了处理这种情况,垃圾收集器可以应用压缩技术,将所有剩余的对象(活动对象)移到一端,形成一个连续的内存块。因此,在压缩后,它可能看起来像图 10-5 。
图 10-5
压缩后修改内存中的分配
现在,您可以轻松地在堆中分配五个连续的内存块。这样,托管堆不同于非托管堆。在这里,我们不需要遍历一个地址链表来为新数据寻找空间,您可以简单地使用堆指针。NET 更快。在线链接 https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals 也声明如下:
从托管堆分配内存比非托管内存分配快。因为运行库通过向指针添加值来为对象分配内存,所以它几乎与从堆栈分配内存一样快。此外,因为连续分配的新对象连续存储在托管堆中,所以应用可以快速访问这些对象。
POINTS TO REMEMBER
我说的非托管堆是什么意思?考虑这样一种情况,您亲自管理堆,并负责分配和释放空间。当在托管堆中分配一个对象时,不是获得实际的指针,而是获得一个“句柄”来表示指向内存地址的方向。这很有帮助,因为实际的内存位置可以在 GC 压缩后更改。但是对于一个本机代码(比如当你在 C/C++代码中使用malloc()函数来分配一个空间时),你得到的是指针,而不是句柄。
压缩后,对象通常停留在同一区域,因此访问它们也变得更容易和更快(因为页面交换更少)。压缩技术成本很高,但总体收益是值得的。微软文档这样写道:
只有当一个集合发现大量不可达对象时,内存才会被压缩。如果托管堆中的所有对象在一次收集后仍然存在,那么就不需要进行内存压缩。
为了提高性能,运行时在单独的堆中为大对象分配内存。垃圾收集器自动为大对象释放内存。但是,为了避免移动内存中的大对象,通常不会压缩内存。
Note
如果您对进一步的细节感兴趣,我鼓励您阅读以下内容。网志文章: https://devblogs.microsoft.com/dotnet/large-object-heap-uncovered-from-an-old-msdn-article/
现在我回到原来的问题。如何解释“指针”这个词很重要在 C/C++中,使用指针指向一个地址,这个地址只是内存中的一个数字槽。但问题是,如果你指向一个无效的地址,你会遇到惊喜!因此,“不安全”上下文中的指针很棘手。
另一方面,C# 中的引用指向托管堆中的有效地址,或者为空。你从 C# 得到的保证。此外,指针非常有用,因为当数据在内存中移动时,您仍然可以使用这些引用来访问这些数据。
运行中的垃圾收集器
分代式垃圾收集器(GC)用于比长期对象更频繁地收集短期对象。我们这里有三代:0,1,2。短期对象存储在第 0 代中。生命周期较长的对象被推送到更高的层代—1 或 2。垃圾收集器在低代中比在高代中工作得更频繁。
一旦创建了对象,它就驻留在第 0 代中。当第 0 代填满时,垃圾收集器被调用。在第 0 代垃圾收集中幸存下来的对象被转移到下一个更高的第 1 代。在第 1 代垃圾收集中幸存下来的对象进入最高的第 2 代。在第 2 代垃圾收集中幸存下来的对象仍属于同一代。当垃圾收集器检测到某一代的存活率太高时,它会提高该代的分配阈值。最后,如果它无法分配更多的内存,您将看到内存泄漏的影响,这一点您将在本章中很快了解到。
Note
有时你会创建一个非常大的对象。这种对象直接进入大对象堆(LOH)。它通常被称为第三代。第 3 代是一个物理代,逻辑上作为第 2 代的一部分收集。在这种情况下,我鼓励你在 https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals 阅读在线微软文档。
我建议您记住 3–3 规则,记住垃圾收集的不同阶段和调用 GC 的不同方式。
垃圾收集的不同阶段
以下是垃圾收集的三个不同阶段:
-
阶段 1:是*标记阶段,*对活体进行标记或识别。
-
阶段 2:这是 r 扩展阶段,在这个阶段中,它更新将在阶段 3 中被压缩的对象的引用。
-
阶段 3:是 c 压缩阶段,从死的(或未引用的)对象中回收内存;压缩操作在活动对象上执行。它将活动对象(在此之前一直存在)移动到分段的旧末端。
调用垃圾收集器的不同情况
下面是调用垃圾收集器的三种不同情况:
-
案例 1:你记忆力差。
-
情况 2:分配的对象(在托管堆中)超过了定义的阈值限制。
-
案例 3:您调用了
System.GC()方法。GC.Collect()有很多重载版本。GC是一个静态类,在系统名称空间中定义。
下面的程序演示了一个简单的案例研究。我在这个例子中使用了GetTotalMemory()方法。我从 Visual Studio 中挑选了摘要,供您立即参考。解释很清楚:
// Summary:
// Retrieves the number of bytes currently thought to be allocated.
// A parameter indicates whether this method can wait for
// a short interval before returning, to allow the system
// to collect garbage and finalize objects.
//
// Parameters:
// forceFullCollection:
// true to indicate that this method can wait for garbage collection to
// occur before returning; otherwise, false.
//
// Returns:
// A number that is the best available
// approximation of the number of bytes currently
// allocated in managed memory.
同样,您可以从 Visual Studio 中看到任何方法的描述。以下是一些附加方法的简要描述。我在接下来的例子中使用它们:
-
GC.Collect(Int32)强制从第0代到指定代立即进行垃圾收集。这意味着当你调用GC.Collect(0),时,垃圾收集将发生在第0;代,如果你调用GC.Collect(1),,垃圾收集将同时发生在第0代和第 1 代,以此类推。 -
CollectionCount方法返回指定代对象的垃圾收集次数。 -
在我调用 GC 之后,我调用了
WaitForPendingFinalizers()方法。Visual Studio 中的方法定义说,这个方法"挂起当前线程,直到正在处理终结器队列的线程清空了该队列。 -
C# 9.0 允许你访问一个对象是否不为空。因此,下面的代码块不会产生编译时错误:
if (sample is not null){// some code} -
在撰写本文时,
Collect()有五个重载方法:
public static void Collect();
public static void Collect(int generation);
public static void Collect(int generation, GCCollectionMode mode);
public static void Collect(int generation, GCCollectionMode mode, bool blocking);
public static void Collect(int generation, GCCollectionMode mode, bool blocking, bool compacting);
在 Visual Studio 中可以很容易地看到它们的定义。为了便于您立即参考,我在此提供了参数说明:
**代:**是被垃圾回收的最老的代的编号。
**模式:**指定垃圾收集是强制(System.GCCollectionMode.Default或System.GCCollectionMode.Forced)还是优化的枚举值
(System.GCCollectionMode.Optimized)。
blocking: 你把它设置为 true 来执行阻塞式垃圾收集;如果为 false,则尽可能执行后台垃圾收集。
**压缩:**你设置为 true 来压缩小对象堆;false 表示仅扫描。
本次演示的目的是:
-
向你展示不同代的垃圾收集
-
演示如果垃圾没有被收集,对象可以从一代进入下一代。
演示 1
运行以下程序,并检查输出和分析:
using System
;
namespace GCDemo
{
class Sample
{
public Sample()
{
// Some code
}
}
class Program
{
public static void Main(string[] args)
{
Console.WriteLine("*** Exploring Garbage Collections.***");
try
{
Console.WriteLine($"Maximum GC Generation is {GC.MaxGeneration}");
Sample sample = new Sample();
CheckObjectStatus(sample);
for (int i = 0; i < 3; i++)
{
Console.WriteLine($"\n After GC.Collect({i})");
GC.Collect(i, GCCollectionMode.Forced, false, true);
System.Threading.Thread.Sleep(5000);
GC.WaitForPendingFinalizers();
ShowAllocationStatus();
CheckObjectStatus(sample);
}
}
catch (Exception ex)
{
Console.WriteLine("Error:" + ex.Message);
}
Console.ReadKey();
}
private static void CheckObjectStatus(Sample sample)
{
if (sample is not null) //C# 9.0 onwards
{
Console.WriteLine($" The {sample} object is in Generation:{GC.GetGeneration(sample)}");
}
}
private static void ShowAllocationStatus()
{
Console.WriteLine("---------");
Console.WriteLine($"Gen-0 collection count:{GC.CollectionCount(0)}");
Console.WriteLine($"Gen-1 collection count:{GC.CollectionCount(1)}");
Console.WriteLine($"Gen-2 collection count:{GC.CollectionCount(2)}");
Console.WriteLine($"Total Memory allocation:{GC.GetTotalMemory(false)}");
Console.WriteLine("---------");
}
}
}
输出
这是一个可能的输出。我用粗体突出了一些重要的行。在您的计算机上,您可能会看到不同的输出。查看分析部分,了解更多关于这种差异的信息。
Maximum GC Generation is 2
The GCDemo.Sample object is in Generation: 0
After GC.Collect(0)
---------
Gen-0 collection count:1
Gen-1 collection count:0
Gen-2 collection count:0
Total Memory allocation:347360
---------
The GCDemo.Sample object is in Generation: 1
After GC.Collect(1)
---------
Gen-0 collection count:2
Gen-1 collection count:1
Gen-2 collection count:0
Total Memory allocation:178984
---------
The GCDemo.Sample object is in Generation: 2
After GC.Collect(2)
---------
Gen-0 collection count:3
Gen-1 collection count:2
Gen-2 collection count:1
Total Memory allocation:178824
---------
The GCDemo.Sample object is in Generation: 2
POINT TO NOTE
如果在这些调用之间发生了额外的垃圾收集,就有可能看到不同的计数器。在这个可能的输出中,您可以看到示例实例没有在任何 GC 调用中收集。于是,它幸存了下来,并逐渐转移到第 2 代。
这个输出中的总内存分配似乎是合理的,因为在每次 GC 调用之后,您会看到总分配在减少。它可能不会发生在每一个可能的输出。这是因为在显示内存状态之前,您可能不允许 GC 完成它的工作。因此,为了获得更一致的结果,我还在调用 GC 之后引入了一个睡眠时间,并且我还调用了WaitForPendingFinalizers()。这给了 GC 更多的时间来完成它的工作。是的,它会导致一些性能损失,但是在我的系统中,它会产生更一致的结果。根据您的系统配置,您可能需要相应地改变睡眠时间。
注意,我使用了下面的重载版本:GC.Collect(i, GCCollectionMode.Forced, false, true)。如果可能的话,我将第三个参数设为 false 来执行后台垃圾收集。
请注意,在垃圾收集开始之前,除了调用 GC 的线程之外,所有托管线程都被挂起。因此,一旦 GC 完成了它的任务,其他线程就可以再次开始分配空间。如果你知道多线程的概念,理解前面一行对你来说很容易。
最后一点:这些代是 GC 堆的逻辑视图。在物理上,这些对象驻留在托管堆上,托管堆是一块内存。GC 通过调用VirtualAlloc向操作系统保留这个。然而,我们在这里并不深入讨论这个问题。
分析
这只是一个样本输出;它可以在每次运行 时发生变化。如果需要,您可以再次回顾前面章节中的理论,然后尝试理解垃圾收集是如何发生的。以下是一些重要的观察结果:
-
你可以看到不同代的 GC。
-
您可以看到,一旦您调用了
GC.Collect(2),其他代也被调用——注意,计数器增加了。同样,当你调用GC.Collect(1)时,1 代和 0 代都被调用。 -
您还可以看到,我创建的对象最初放在第 0 代中。
处理一个对象
通常,程序员需要显式地释放一些资源。一些常见的例子包括当您处理事件、锁定机制、文件处理操作或非托管对象时。也有这样的情况,当你知道你已经使用了一个非常大的内存块,而这个内存块在某个执行点之后是不必要的。下面是一些您希望释放内存或资源来提高系统性能的例子。
Note
非托管对象不受. NET 控制。一个常见的例子是当您包装 OS 资源(如数据库连接或网络连接)时。
英寸 NET 中,你有一个带有Dispose()方法的IDisposable接口。当程序员想要释放资源时,他可以覆盖这个Dispose()方法。这是一个推荐的做法,因为您非常清楚何时要释放内存。图 10-6 显示了一个来自 Visual Studio 的快照,显示你可以使用这个方法释放非托管资源。
图 10-6
中的 IDisposable 接口。网
最终确定与处置
每个类只能有一个终结器(通常称为析构函数),不能重载或继承。它没有修饰符,也没有任何参数。您不能直接调用终结器。它是自动调用的。
下面是一个显示终结器或析构器的示例:
class Sample
{
~Sample() // finalizer
{
// Cleanup statements...
}
}
如果您编译这段代码,然后打开 IL 代码,您会注意到如下内容:
.method family hidebysig virtual instance void
Finalize() cil managed
{
.override [mscorlib]System.Object::Finalize
// Code size 13 (0xd)
.maxstack 1
IL_0000: nop
.try
{
IL_0001: nop
IL_0002: leave.s IL_000c
} // end .try
finally
{
IL_0004: ldarg.0
IL_0005: call instance void [mscorlib]System.Object::Finalize()
IL_000a: nop
IL_000b: endfinally
} // end handler
IL_000c: ret
} // end of method Sample::Finalize
Note
您可以使用 IL 反汇编程序来查看 IL 代码。我经常使用 ildasm.exe,它在 Visual Studio 中是自动可用的。要使用此工具,您可以按照以下步骤操作:打开 Visual Studio ➤类型 ildasm 的开发人员命令提示符(您将看到一个新窗口弹出)➤拖动一个. dll 或一个。exe 到这个窗口➤现在展开/点击代码元素。你可以通过这个在线链接了解这个工具的更多信息: https://docs.microsoft.com/en-us/dotnet/framework/tools/ildasm-exe-il-disassembler
这是因为终结器调用隐式转换为:
protected override void Finalize()
{
try
{
// Cleanup statements...
}
finally
{
base.Finalize();
}
}
对继承链中的所有实例递归调用该方法,调用的方向是从最特殊到最不特殊。
Note
Microsoft 建议不要使用空的终结器,因为在终结队列中会为每个终结器创建一个条目。当调用终结器时,GC 开始处理这个队列。因此,如果终结器为空,就会引入不必要的性能损失。
让我们来看一个程序,在这个程序中,你可以看到一个终结器和一个Dispose()方法同时存在。在你运行这个程序之前,让我告诉你一些事情:
-
静态类
GC在System名称空间中定义。 -
这个类有一个方法,叫做
SuppressFinalize()。如果在GC.SuppressFinalize()方法中传递当前对象,则当前对象的finalize方法不会被调用。 -
我想给你看一个析构函数调用。NET 5 或。NET 6。在。NET 框架,非常容易。一旦你退出程序,它会被自动调用。但是在。NET 核心平台(或。NET 5 或。NET 6)。这就是为什么我引入了另一个名为
A的类,并在构造函数中初始化了一个Sample对象。在我调用GC之前,我也没有在Main()中使用任何Sample引用。这有助于 GC 分析是否不再需要Sample对象,然后收集垃圾。可以实现类似的逻辑来模拟。NET 5/。NET 6/。NET 核心平台。
POINT TO REMEMBER
理想情况下,除非非常需要,否则不要在终结器中编写代码。相反,您可能更喜欢使用Dispose()方法来释放非托管资源并避免内存泄漏。
演示 2
现在运行下面的程序,并遵循输出。然后通过分析。您需要了解。NET 平台。
using System
;
namespace DisposeExample
{
class Sample : IDisposable
{
public void SomeMethod()
{
Console.WriteLine("Sample's SomeMethod is invoked.");
}
public void Dispose()
{
// GC.SuppressFinalize(this);
Console.WriteLine("Sample's Dispose() is called");
// Release unmanaged resource(s) if any
}
~Sample()
{
Console.WriteLine("Sample's Destructor is called.");
}
}
class A : IDisposable
{
public A()
{
Console.WriteLine("Inside A's constructor.");
// C#8 onwards it works.
// using Sample sample = new Sample();
// sample.SomeMethod();
using (Sample sample = new Sample())
{
sample.SomeMethod();
}
}
public void Dispose()
{
// GC.SuppressFinalize(this);
Console.WriteLine("A's Dispose() is called.");
// Release any other resource(s)
}
~A()
{
Console.WriteLine("A's Destructor is Called.");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** Exploring the Dispose() method.***");
A obA = new A();
obA = null;
Console.WriteLine("GC is about to start.");
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine("GC is completed.");
Console.ReadKey();
}
}
}
输出
下面是我使用. NET5 时的输出。NET 6,或者。网络核心 3.1:
*** Exploring the Dispose() method.***
Inside A's constructor.
Sample's SomeMethod is invoked.
Sample's Dispose() is called.
GC is about to start.
Sample's Destructor is called.
GC is completed.
分析
从这个输出中,您可以注意到以下几点:
-
Sample类对象的Dispose()和finalizer方法都被调用。 -
声明
GC.SuppressFinalize(this);是在Sample类的dispose()方法中注释的。这就是为什么也调用了Sample实例的析构函数。如果启用/取消注释该语句,将不会调用Sample实例的终结器。 -
尚未调用 A 对象的终结器方法。
当我在。NET Framework 4.7.2 中,我可以看到靠近末尾的一行额外的内容,说明在这种情况下还调用了一个类对象的析构函数。以下是输出:
*** Exploring the Dispose() method.***
Inside A's constructor.
Sample's SomeMethod is invoked.
Sample's Dispose() is called.
GC is about to start.
Sample's Destructor is called.
A's Destructor is Called.
GC is completed.
Note
我向微软提出了一个关于. NET Framework 和。NET 核心。如果你有兴趣了解这个讨论,可以参考链接: https://github.com/dotnet/docs/issues/24440 微软认为这是一个预期的行为 in.NET 核心/。NET 5/。NET 6 应用。也有不同的意见。
在这方面,我参考了微软的文档( https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/destructors )说:
程序员无法控制何时调用终结器;垃圾收集器决定何时调用它。垃圾收集器检查应用不再使用的对象。如果它认为某个对象适合终结,它将调用终结器(如果有)并回收用于存储该对象的内存。
英寸 NET 框架应用(但不是在。NET 核心应用),当程序退出时也会调用终结器。我得到的解释是:终结器可能会产生死锁,阻止程序退出。因此,在退出时运行终结器的代码被进一步放宽。可以参考前面链接我们的讨论。本网上链接 https://github.com/dotnet/docs/issues/17463 深入描述了这个问题。
在详细讨论内存泄漏之前,让我们回顾一下下面的问答环节。
问答环节
我们如何调用析构函数?
回答:
您不能调用析构函数。垃圾收集器负责这项工作。
10.5 什么是托管堆?如何释放资源呢?
回答:
当 CLR 初始化垃圾回收器时,它会分配一段内存来存储和管理对象。这种内存称为托管堆。
一般情况下,调用Finalize()(或者对象的析构函数)来清理内存。因此,您可以提供析构函数来释放我们的对象所拥有的未引用的资源。在这种情况下,您需要覆盖对象类的Finalize()方法。通常,程序员会尝试使用Dispose()方法来释放非托管资源。为了优化性能,如果需要,他可以取消对对象的终结器调用。在这种情况下,您可能会看到类似如下的 dispose 模式:
class Sample : IDisposable
{
protected virtual void Dispose(bool disposing)
{
if( disposing)
{
// Some code to release managed resources.
}
public void Dispose()
{
Dispose( true);
GC.SuppressFinalize(this);
}
~Sample().
{
Dispose(false);
}
// Some code
}
}
Note
注意,从终结器调用时,disposing参数是false。但当你从 IDisposable.Dispose 法中调用它时,它就是true。换句话说,当它被确定性调用时是true,当它被非确定性调用时是false。这遵循了微软的编程准则。
10.6 垃圾收集器什么时候调用 Finalize()方法 ?
回答:
我们永远不知道。当发现没有引用的对象时,或者稍后当 CLR 需要回收一些内存时,它可能会立即调用它。但是您可以通过调用有许多重载版本的GC.Collect(),来强制垃圾收集器在给定的点运行。当我在之前的演示中使用GC.Collect(Int32)和GC.Collect()时,你已经看到了两种不同的用法。
10.7 当程序在。NET 框架。但在中情况并非如此。网芯还是。5 号网或 6 号网。这背后的原因是什么?
回答:
对于我在 https://github.com/dotnet/docs/issues/24440 的票,答案总结为:终结器会产生死锁,阻止程序退出。因此,在退出时运行终结器的代码被进一步放宽。微软认为这是意料之中的行为。网芯,。NET 5,以及。NET 6 应用。
10.8 我们应该何时调用 GC。Collect()?
回答:
我已经提到过,调用 GC 通常是一个开销很大的操作。但是在一些特殊的场景中,您可能会确信如果您调用 GC,您将获得一些显著的好处。在代码中取消对大量对象的引用后,可能会出现这样的例子。
另一个常见的例子是当您试图通过一些常见的操作来查找内存泄漏时,例如重复执行一个测试来查找系统中的泄漏。在每个操作之后,您可以尝试收集不同的计数器来分析内存增长并获得正确的计数器。我将很快讨论内存泄漏分析。
POINTS TO REMEMBER
当我们看到IDisposable接口的使用时,我们假设程序员会正确调用Dispose()方法。一些专家建议你有一个析构器作为预防措施。当错过了一个打给Dispose()的电话时,它会有所帮助。记住微软的理念(参见 https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose ):为了帮助确保资源总是被适当地清理, Dispose 方法应该是幂等的,这样它可以被多次调用而不会抛出异常。此外,后续的 Dispose 调用应该什么也不做。
10.9 在之前的演示(演示 2)中,您为什么要使用“using”语句?
回答:
C# 在这种情况下提供了特殊的支持。您可以使用using语句来减少代码大小,使其更具可读性。 它是 try/finally block 的语法捷径。为了验证这一点,您可以看到我在演示 2 中使用的 A 的构造函数的 IL 代码。我在这里提出这一点,并做一些大胆的供大家参考:
.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
// Code size 48 (0x30)
.maxstack 1
.locals init (class DisposeExample.Sample V_0)
IL_0000: ldarg.0
IL_0001: call instance void [System.Runtime]System.Object::.ctor()
IL_0006: nop
IL_0007: nop
IL_0008: ldstr "Inside A's constructor."
IL_000d: call void [System.Console]System.Console::WriteLine(string)
IL_0012: nop
IL_0013: newobj instance void DisposeExample.Sample::.ctor()
IL_0018: stloc.0
.try
{
IL_0019: nop
IL_001a: ldloc.0
IL_001b: callvirt instance void DisposeExample.Sample::SomeMethod()
IL_0020: nop
IL_0021: nop
IL_0022: leave.s IL_002f
} // end .try
finally
{
IL_0024: ldloc.0
IL_0025: brfalse.s IL_002e
IL_0027: ldloc.0
IL_0028: callvirt instance void [System.Runtime]System.IDisposable::Dispose()
IL_002d: nop
IL_002e: endfinally
} // end handler
IL_002f: ret
} // end of method A::.ctor
10.9 我可以直接在 1 代或者 2 代分配空间吗?
回答:
不可以。用户代码只能在第 0 代或 LOH 中分配空间。将对象从第 0 代提升到第 1 代(或第 2 代)是 GC 的责任。
内存泄漏分析
你如何检测泄漏?有许多工具可以实现这一目的。例如,windbg.exe 是大型应用中查找内存泄漏的常用工具。除此之外,您可以使用其他图形工具,如微软的 CLR Profiler、SciTech 的 Memory Profiler、Red Gate 的 ANTS Memory Profiler 等来查找系统中的漏洞。许多组织都有特定于公司的内存泄漏工具来检测和分析泄漏。在我之前的组织中,我们的专家开发了这样一个工具。这是一个很好的工具。我很幸运,因为我可以使用它,并了解到许多关于内存泄漏的有趣事情。
在 Visual Studio 的最新版本中,有一个诊断工具可以检测和分析内存泄漏。这是非常用户友好的,你可以采取各种内存快照。工具中的标记表示垃圾收集器活动。这个工具非常有用和有效:您可以在调试会话处于活动状态时实时分析数据。图表中的尖峰会立即吸引你的注意力。以下程序向您展示了一个示例演示。
Note
我假设您知道如何在应用中使用事件。对事件和代表的详细讨论超出了本书的范围。我在我的其他书籍中详细讨论了代表、事件和其他主题,这些书籍是由 Apress 出版的 Interactive C# 和Getting Started with Advanced c#。第一本书展示了如何使用诊断工具以及微软的 CLR Profiler 来分析内存泄漏。第二篇深入讨论了代表和事件。所以,如果你有兴趣,可以看看这些书。在这里,我添加了一些支持性的注释,以帮助您更好地理解代码。我承认在这个程序中很容易发现问题。但是我的核心意图是向您展示如何使用诊断工具来分析泄漏。
在运行该应用之前,确保您启用了启动诊断工具的选项,如图 10-7 所示。在 Visual Studio IDE 中,您可以在工具➤选项➤调试➤常规中看到此选项。
图 10-7
在 Visual Studio 中启用“调试时诊断工具”选项
演示 3
这是完整的演示。在Main()中,您可以看到两个方法:一个注册事件,一个取消注册事件。您可以看到,由于错误,我注册了太多的事件,而我只注册了其中的一个。这些剩余的未注册事件导致了该应用的泄漏。
using System;
namespace MemoryLeakDemo1
{
delegate void IdChangedHandler(object sender, IdChangedEventArgs eventArgs);
class IdChangedEventArgs : EventArgs
{
public int IdNumber { get; set; }
}
class Sender
{
public event IdChangedHandler IdChanged;
private int Id;
public int ID
{
get
{
return Id;
}
set
{
Id = value;
// Raise the event
OnMyIntChanged(Id);
}
}
protected void OnMyIntChanged(int id)
{
if (IdChanged != null)
{
// As suggested by compiler:
// It is the simplified form of the following lines:
// IdChangedEventArgs idChangedEventArgs = new
// IdChangedEventArgs();
// idChangedEventArgs.IdNumber = id;
IdChangedEventArgs idChangedEventArgs =
new IdChangedEventArgs
{
IdNumber = id
};
IdChanged(this, idChangedEventArgs);
}
}
}
class Receiver
{
public void GetNotification(object sender, IdChangedEventArgs e)
{
Console.WriteLine($"Sender changed the id to:{e.IdNumber}");
}
}
class Program
{
static void Main()
{
Console.WriteLine("***Creating custom events and analyzing memory leaks.***");
Sender sender = new Sender();
Receiver receiver = new Receiver();
RegisterNotifications(sender, receiver);
UnRegisterNotification(sender, receiver);
Console.ReadKey();
}
private static void RegisterNotifications(Sender sender, Receiver receiver)
{
for (int count = 0; count < 10000; count++)
{
// Registering too many events.
sender.IdChanged += receiver.GetNotification;
sender.ID = count;
}
}
private static void UnRegisterNotification(Sender sender, Receiver receiver)
{
// Unregistering only one event.
sender.IdChanged -= receiver.GetNotification;
}
}
}
我运行这个程序并拍摄不同的快照。在这里,我给大家呈现一个诊断工具窗口的截图(图 10-8);它包括五个不同的快照,用于分析给定时间点的内存使用情况。这是一个很大的快照,所以移到下一页。
来自诊断工具的快照
图 10-8
使用 Visual Studio 中的诊断工具拍摄不同的快照
我们来分析一下区别(Objects (Diff))。例如,第四行显示与前一个快照相比,对象数增加了 171。如果您将鼠标悬停在此处,它会告诉您可以打开按对象计数排序的所选快照的堆比较视图。让我们点击这个链接。我可以看到图 10-9 中显示的内容。
图 10-9
特定快照中的对象计数差异
我们可以看到堆的大小是如何随着时间的推移而增长的。请注意,由于错误,我在这段代码的for循环中重复注册了一个事件:
sender.IdChanged += receiver.GetNotification;
同样,我可以使用微软的 CLR Profiler 向您展示泄漏。但是展示不同工具的用法并不是本章的目的。相反,您可以使用任何您喜欢的工具来防止内存泄漏。由于 Visual Studio 的最新版本中已经提供了诊断工具,所以我不想错过向您展示其用法的机会。
捕捉内存泄漏需要专业知识,因为这并不容易。在前面的演示中,我们的程序有几个方法,这就是为什么很容易捕捉到泄漏。但是想想一些典型的场景:
-
你使用第三方代码,漏洞就在那里。但是您无法立即找到它,因为您无法访问该代码。
-
当遵循某些特定的代码路径时,泄漏就会暴露出来。如果测试团队错过了路径,就很难找到漏洞。
-
一个专门的内存泄漏套件维护可能需要一个单独的测试团队。此外,您不能在内存泄漏套件中包含所有的回归测试。多次运行测试并收集这些计数器是既耗时又消耗资源的活动。因此,建议您经常调整测试用例,并运行您的内存泄漏测试套件。
-
当一个新的 bug 修复发生时,测试团队使用测试用例来验证这个修复。现在您需要询问他们这些测试是否已经包含在内存泄漏测试套件中。如果没有,您需要将它们包含在内。但是,如果在一天之内出现了多个修复(比如 10 个或更多),由于各种原因(例如,您可能有资源限制),可能无法立即将所有测试包含在您的内存泄漏套件中。此外,由于您很晚才能看到结果,而且是在新的修复程序进入主代码库之间,因此很难发现早期的漏洞。
摘要
内存管理是一个重要的话题。这一章给你一个快速的概述,但仍然是一个很大的章节!在讨论了内存泄漏的重要性之后,我们来看看在 C# 中如何管理内存。
我从 C# 中的两种不同类型的内存开始讨论:堆栈内存和堆内存。然后我讨论了 C# 中的垃圾收集器(GC)。您看到了垃圾收集的不同阶段,并了解了 GC 可以启动其操作的不同情况。
稍后,您学习了如何以编程方式处置对象。您看到了关于dispose方法与最终确定方法的讨论。在这种情况下,你看到了。NET Framework 显示了一种不同的行为。网芯,。NET 5,或者。NET 6。我提了一张票和微软的专家讨论这个区别,大家可以在 https://github.com/dotnet/docs/issues/24440 看到讨论。
在最后一部分,我向您展示了 Visual Studio 中诊断工具的用法,并使用 C# 中的事件分析了内存泄漏。
简而言之,本章回答了以下问题:
-
堆内存和栈内存有什么不同?
-
什么是垃圾收集(GC)?在 C# 中是如何工作的?
-
有哪些不同的 GC 代?
-
调用垃圾收集器有哪些不同的方法?
-
怎样才能强制 GC 调用?
-
在 C# 中,处置和终结有什么不同?
-
什么是内存泄漏?
-
内存泄漏的可能原因是什么?
-
怎样才能有效的使用
Dispose()的方法来收集内存? -
我们如何在 Visual Studio 的诊断工具中使用内存泄漏分析?
十一、遗留的讨论
这是这本书的最后一章。在这里你会看到一些有趣的讨论,学习一些常用术语。
有时,如果你的应用运行良好,可以变通一些广为接受的规则,但是当你继续编码和开发应用时,你会发现专家的建议有很大的价值。如果你听从他们的建议,你会明白一个简单的选择从长远来看会有很大的影响。本章简要讨论了其中的一些主题。
静态方法还是实例方法?
静态方法很容易使用。一个程序员新手可能会认为在他的程序中使用静态方法还是实例方法没有多大关系。他知道他可以在不实例化对象的情况下调用方法。他喜欢这个。当他看到一些非常有用的静态实用程序方法时,印象更加深刻。但是一个有经验的程序员经常发现很难理解他是否应该使用静态方法。在每一个可能的设计中,他可能会问:哪个更好?简而言之,没有放之四海而皆准的规则。我相信这纯粹取决于你使用的应用。让我们核实一下事实。
概述
还记得第六章的简单工厂(演示 1)吗?你之前看过代码了。为了便于您立即参考,我提供了一个来自 Visual Studio 的屏幕截图。注意图 11-1 中的箭头尖端。
图 11-1
第六章演示 1 中的 AnimalFactory 类
如果您对此进行调查,您会看到以下消息:
这是部分快照;我为你展开完整的信息:CA1822 Member 'CreateAnimal' does not access instance data and can be marked as static.
它还不停地说:
Active Members that do not access instance data or call instance methods can be marked as static. After you mark the methods as static, the compiler will emit non-virtual call sites to these members. This can give you a measurable performance gain for performance-sensitive code.
Note
当我将目标框架设置为。NET 5 或。NET 6。但是当我使用目标框架时,它是不可见的。NET 3.1。
我在第六章没有采纳这个建议。原因很明显:
-
不能用关键字
virtual或abstract标记静态方法。 -
因此,您不能重写静态方法。所以,你也不能使用
override关键字。 -
当您不能使用
override关键字重定义一个方法时,您不会得到多态行为。
在第六章中,我增强了最初的实现,并将一些责任委托给子类,因为我想实现多态行为。当您认为您的应用可能需要做同样的事情时,最好将该方法设为非静态的。让我给你总结一下要点:
-
If you use a method that can get all information from its parameters and does not operate on any instance of a class, you can make the method static. For example, look into the following
MyUtilityclass with static methodShowGreaterNumber():class MyUtility { public static double ShowGreaterNumber( double firstNumber, double secondNumber) { return firstNumber >= secondNumber ? firstNumber : secondNumber; } }对我来说,把它用作
MyUtility.ShowGreaterNumber(24.7, 75.2)是有意义的,这样可以打印 24.7 和 75.2 之间较大的数字。在打印 24.7 和 75.2 之间的最大值之前,没有必要创建一个MyUtility的实例。你可以参考内置的Math类来了解好的静态方法。比如用Math.Max(2,3)可以得到 3,或者用Math.Abs(-2.52)可以得到 2.52。 -
如果您不希望看到多态行为,或者您只关心应用的性能,您可以考虑将您的方法设为静态。
-
有时候你看到一部分代码,然后觉得静态方法对你更有意义。但是您已经看到,将来很可能需要增强您的程序,并且您将需要多态来使您的代码更加灵活。每当你有疑问的时候,选择一个非静态的方法而不是它的对应方法(例如,静态方法)。
学习设计模式
让我们乘坐时光机游览一下。使用这台机器让我带你回到软件开发的早期,了解那个时代的一个普遍问题:
没有标准来指导开发者如何设计应用。我们是独一无二的生物。因此,每个公司团队都遵循自己的编码风格。一个新成员加入这样一个团队。对于这个成员来说,理解当前的架构是一项艰巨的任务。因此,他不断向团队的高级成员寻求帮助,并请求他们解释现有的架构。他不停地问他们:为什么在这段代码中遵循这种特殊的设计?有经验的开发人员回答他的问题。他还解释了为什么在之前的团队会议中没有考虑到常见的替代方案。他还建议新成员重用现有的构造,以减少未来的开发工作。
你问:这有什么问题?其实没有问题,这是标准做法,即使在当今世界。但是从不同的角度考虑:假设有经验的人告诉新成员:我们遵循这个代码段的外观模式,或者我们遵循那个代码段的单例模式。如果新加入者已经知道这些编码模式,他的学习会有多容易?不仅如此——因为他知道这些编码风格,遵循一个已知的模式,他很容易更快地为团队做出贡献。我希望您对了解一些标准模式的重要性有所了解!
软件设计模式解决了这类问题,并为所有开发人员提供了一个公共平台。你可以把它们看作是该领域专家的经验记录。这些模式最初是为了重用而应用于面向对象的设计中。
设计模式简史
设计模式的最初想法来自建筑建筑师 Christopher Alexander,他是伯克利的教授。他面临许多性质相似的问题。所以,他用类似的方法解决了这些问题。
“每个模式都描述了一个在我们的环境中反复出现的问题,然后描述了该问题解决方案的核心,以这样一种方式,你可以使用这个解决方案一百万次,而不必以同样的方式做两次。”
—克里斯托夫·亚历山大
他最初的想法是在一个规划良好的城镇中建造建筑物。后来,这些概念进入了软件工程社区。这个社区开始相信,尽管这些模式是针对建筑物和城镇描述的,但同样的概念也可以应用于面向对象的设计。所以,他们用物体和界面取代了墙和门的原始概念。想法是一样的:你可以用一个已知的解决方案来解决一个常见的问题。
这些概念通过像沃德·坎宁安和肯特·贝克这样的前沿软件开发人员开始流行起来。1994 年,通过一个名为程序设计模式语言(PLoP)关于设计模式的行业会议,设计模式的思想进入了面向对象软件开发的主流。它是由 Hillside Group 主办的,Jim Coplien 的论文“一种开发过程生成模式语言”是这一背景下的著名论文。
1994 年,Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 出版了《设计模式:可重用面向对象软件的元素》一书(Addison-Wesley,1994)。随着这本经典教材的推出,设计模式的思想变得非常流行。在这本书中,他们介绍了软件开发的 23 种设计模式。这些作者被称为“四人帮” 。我们通常称它们为 GoF 。他们谈论的模式是由软件开发人员的共同经验开发出来的。
值得注意的是,GoF 讨论了 C++环境中的设计模式。但是 C# 1.0 是 2002 年发布的,之后经历了各种变化。它发展迅速,在很短的时间内就跻身于世界顶级编程语言的行列,并且在今天的市场上,它总是需求量很大。在撰写本文时,C# 9.0 已在 Visual Studio 2019 中可用。因为设计模式的概念是通用的,所以它们总是有价值的。因此,练习基本的设计模式总是能让你成为更好的程序员,并帮助你“升级”自己。以下是一些需要记住的要点:
-
设计模式描述了软件设计问题的通用可重用解决方案。基本思想是,在开发软件时,你可以用相似的解决方案解决相似的问题。提议的解决方案经过了长时间的测试。
-
模式实际上是模板。他们向你建议如何解决问题。对模式的良好理解可以帮助你更快地实现最好的设计。
-
从 OOP 的角度来看,这些模式描述了如何创建对象和类,并定制它们来解决特定环境中的一般设计问题。
23 种 GoF 设计模式中的每一种都专注于特定的面向对象设计。他们每个人都可以描述使用的后果和权衡。GoF 根据它们的目的对这 23 种模式进行了分类,如下所示。
A .创作模式
这些模式抽象了实例化过程。您使系统独立于对象的组成、创建和表示方式。这里你会问:“我应该在应用中的什么地方放置‘new’关键字?”这个决定可以帮助您确定类的耦合程度。以下五种模式属于这一类别:
-
单一模式
-
原型模式
-
工厂方法模式
-
构建器模式
-
抽象工厂模式
B .结构模式
使用这些模式,您可以组合类和对象来形成一个相对较大的结构。通常使用继承或组合来对不同的接口或实现进行分组。在第七章中,你看到了优先选择对象组合而不是继承(反之亦然)会影响软件的灵活性。以下七种模式属于这一类别:
-
代理模式
-
轻量级模式
-
复合模式
-
桥接模式
-
外观图案
-
装饰图案
-
适配器模式
C .行为模式
这些模式关注算法和对象间的责任分配。在这里,您将注意力集中在对象的通信和它们的相互联系上。以下 11 种模式属于这一类别:
-
观察者模式
-
战略模式
-
模板方法模式
-
命令模式
-
迭代器模式
-
纪念品图案
-
状态模式
-
中介模式
-
责任链模式
-
访问者模式
-
解释程序模式
GoF 做了另一个基于范围的分类,即模式主要关注类还是对象。类模式处理类和子类。它们使用继承机制,所以在编译时是静态的。对象模式处理可以在运行时改变的对象。所以,对象模式是动态的。
要快速参考,您可以参考 GoF 推出的下表:
好消息来了!
您已经实现了一些模式!不仅如此,你实际上至少从每个类别中学到了一种模式。本书的第三部分帮助你理解它们:
-
在第六章中,你学习了工厂方法模式。其实你也学了简单工厂模式,这是这个模式的基础。
-
在第七章中,你学习了装饰模式。
-
在第八章中,你学习了模板方法模式。
-
在第九章中,你学习了 facade 模式。
-
等等!还有一个问题:第一部分的第二章为战略模式奠定了基础。
这些模式在 C# 应用中非常常见。恭喜你!你走在正确的道路上。
问答环节
11.1 类模式和对象模式 有什么区别?
回答:
一般来说,类模式侧重于静态关系,而对象模式侧重于动态关系。顾名思义,类模式关注于类及其子类,而对象模式关注于对象的关系。
下表是 GoF 名著中讨论的总结内容:
| |班级模式
|
对象模式
| | --- | --- | --- | | 创作 | 可以将对象创建推迟到它的子类 | 可以将对象创建推迟到另一个对象 | | 结构性 | 侧重于类的组成(主要使用继承的概念) | 聚焦于物体的不同构成方式 | | 行为 | 描述算法和执行流程。他们也使用继承机制。 | 描述不同的对象如何协同工作并完成一项任务。 |
11.2 我可以在一个应用中组合两个或多个模式吗?
回答:
是的,在现实世界中,这种类型的活动很常见。
这些模式依赖于特定的编程语言吗?
回答:
编程语言可以发挥重要作用。但基本思想是一样的;模式就像模板一样,给你一些如何解决问题的预先想法。假设您选择了 c 之类的其他语言,而不是任何面向对象的编程语言,在这种情况下,您可能需要实现核心的面向对象原则,如继承、多态、封装、抽象等等。因此,选择一种特定的语言是很重要的,因为它可能有一些专门的功能,可以使你的生活更容易。
11.4 我是否应该将数组和链表等常见的数据结构视为不同的设计模式?
回答:
GoF 明确排除了这些,称“它们并不复杂 ,是针对整个应用或子系统的特定领域设计它们可以编码在类中,并按原样重用。所以,在这种情况下,他们不是你要关心的。
11.5 如果没有特定的模式 100%适合我的问题,我应该如何继续?
回答:
毫无疑问,无限数量的问题无法用有限数量的模式来解决。但是如果你知道这些常见的模式和它们的权衡,你可以选择一个相近的匹配。最后,没有人阻止你使用你的模式来解决你自己的问题。但你必须应对风险,并需要考虑你的投资回报。
请记住,世界总是在变化,新的模式也在不断发展。为了理解新模式的必要性,您可能还需要理解为什么旧的/现有的模式不足以满足需求。这些模式试图为你打下 SOLID 基础。这些概念可以帮助你在职业生涯中顺利前进。
避免反模式
设计模式可以帮助你开发更好的应用。但是人们经常误用它们,导致反模式。
一个吸引人的解决方案经常会导致严重的问题。这里有一个常见的例子:一个开发人员在没有分析潜在缺陷的情况下实现了一个快速修复,以满足交付计划。现在考虑一下,如果客户因为快速修复而发现一个大错误,公司的声誉会受到什么影响。
反模式在类似的情况下提醒您,并帮助您采取预防措施。它们让你想起一句谚语:预防胜于治疗。
POINTS TO REMEMBER
反模式不仅警告常见错误,还建议更好的解决方案。有些解决方案在开始时可能并不吸引人,但从长远来看,它们会节省你的时间、精力和声誉。
反模式简史
毫无疑问,设计模式已经帮助了(并且仍在帮助)数百万程序员。然而,渐渐地,人们开始注意到过度使用这些模式带来的负面影响。例如:许多开发人员想要展示他们的专业知识,而没有真正评估在他们的特定领域中使用这些模式的后果。作为一个明显的副作用,模式被植入了错误的环境,低质量的软件被生产出来,最终对他们或他们的组织有很大的惩罚。
软件业需要关注类似错误的负面后果,最终反模式的思想得到了发展。许多专家开始在这一领域做出贡献,但是第一个结构良好的模型来自 Michael Akroyd 题为“反模式:针对对象误用的疫苗”的演讲。“这是 GoF 设计模式的对外观。维基百科称该术语是由计算机程序员安德鲁·克尼格在 1995 年创造的。
随着威廉·j·布朗、拉斐尔·c·马尔沃、海斯·w·麦考密克三世、托马斯·j·莫布雷的名著反模式:重构软件、架构和危机中的项目(罗伯特·益普生/威利,1998),术语“反模式”开始流行起来。后来,斯科特·托马斯加入了他们的小组。他们说:
“因为反模式有如此多的贡献者,将反模式的最初想法分配给单一来源是不公平的。相反,反模式是补充设计模式运动的工作和扩展设计模式模型的自然步骤。”
反模式的例子
以下是一些反模式及其背后的概念/思维模式的例子:
-
过度使用模式: 开发人员可能会不惜任何代价尝试使用模式,不管它是否合适。
-
神类: 用许多不相关的方法控制几乎一切的大物体。
-
不是这里发明的: 我是一家大公司,我想从零开始打造一切。虽然有一个由小公司开发的库,但我不会使用它。我将自己制作一切,一旦开发出来,我将利用我的品牌价值宣布:“嘿,伙计们,我们在这里为你们提供终极图书馆,满足你们的每一个需求。”
-
零表示空: 程序员可能会使用一些特殊的数字,比如-1 或者 999(或者任何类似的东西),来表示一个不合适的整数值。当他在应用中将类似“09/09/9999”的日期视为空日期时,可以看到类似的例子。在这些情况下,如果用户需要这些值,他将不会得到这些值。
-
***金锤:***X 先生相信技术 T 永远是最好的。所以,如果他需要开发一个新的系统(这需要新的学习),他仍然会选择 T,即使它不合适。他想,“我已经够大了,也很忙。如果我能以某种方式用 t 来管理它,我就不需要再学习任何技术了。”
-
射信使: 你相信测试员“约翰”总是给你找硬缺陷,因为他不喜欢你。你说你已经有压力了,项目的截止日期也快到了。所以,你不希望他把自己卷入这个关键的阶段,以避免更多的缺陷。
-
瑞士军刀: 能满足顾客各种需求的产品的公司目标。或者,想象一下,一家公司试图制造一种可以治愈所有疾病的药物。或者,有人想设计一种软件,可以服务于具有不同需求的各种客户。对他来说,界面有多复杂并不重要。
-
复制粘贴编程:我需要解决一个问题,但是我已经有一段代码可以处理类似的情况。因此,我可以复制一份当前正在运行的旧代码,然后在需要时开始修改它。但是当你从现有的拷贝开始时,你基本上继承了所有与之相关的潜在缺陷。此外,如果将来需要修改原始代码,您需要在多个地方实现修改。这种做法违反了不重复自己的原则。
-
架构师不编码: 我是一名架构师。我的时间很宝贵。我将只展示路径或给出一个关于编码的伟大演讲。有足够多的实施者应该实施我的想法。架构师打高尔夫也是这个反模式的姐妹。
-
伪装的链接和广告: 这来自于一种心态,当用户点击一个链接或广告时,这种心态会愚弄用户并获得收入。顾客经常得不到他/她真正想要的东西。它通常被称为黑暗图案。
-
数字管理: 有人认为更高数量的提交,更高数量的代码行,或者更高数量的缺陷修复等。是伟大开发者的标志。
“用代码的行数来衡量编程进度,就像用重量来衡量飞机的建造进度。”
—比尔·盖茨
反模式的类型
反模式可以属于不同的类别。甚至一个典型的反模式也可以属于多个类别。以下是一些常见的分类:
-
**架构反模式:**瑞士军刀就是这一类的例子。
-
**开发反模式:**神类和过度使用模式就是这一类的例子。
-
**管理反模式:**射杀信使可以属于这一类。
-
**组织反模式:**架构师不编码,架构师打高尔夫可以属于这一类。
-
**用户界面反模式:**例子包括伪装的链接和广告。
POINTS TO NOTE
-
您可以从不同的网站/来源了解各种反模式。例如,下面的维基百科链接谈到了各种反模式:
https://en.wikipedia.org/wiki/Anti-pattern -
您也可以在
http://wiki.c2.com/?AntiPatternsCatalog获得反模式目录的详细列表以了解更多信息。 -
反模式的概念不限于面向对象编程。
问答环节
11.6 反模式和设计模式有什么关系?
回答:
当你使用设计模式时,你重用了在你之前的人的经验。当你开始仅仅为了使用而盲目地使用这些概念时,你就陷入了重复使用循环解决方案的陷阱。这可能会导致您在未来陷入糟糕的境地,然后您发现您的投资回报率(ROI)不断下降,但您的维护成本不断增加。简而言之,表面上简单而有吸引力的解决方案(或模式)可能会在将来给你带来更多的问题。
11.7 设计模式可能会变成反模式。这种理解正确吗?
回答:
是的,如果你在错误的环境中应用一个设计模式,它会带来比它所解决的问题更多的麻烦,最终它会变成一个反模式。所以,在你开始之前,确保你了解问题的性质和背景。例如,不恰当地使用 mediator 模式可能会导致 God 类反模式。
11.8 反模式仅与软件开发人员相关。这种理解正确吗?
回答:
不。您已经看到了各种类型的反模式。所以,反模式的用处不仅限于开发人员;它可能也适用于其他人。例如,它对经理和技术架构师也很有用。
11.9 即使你现在没有从反模式中获得太多好处,它们也能帮助你在将来以更少的维护成本轻松适应新的特性。这种理解正确吗?
回答:
是的。
11.10 反模式的可能原因是什么?
回答:
它们可能来自不同的来源或心态。下面列出了一些常见的某人可能会说(或想)的例子:
-
“我们需要尽快交付产品。”
-
“我们与客户的关系非常好。因此,目前我们不需要对未来的影响进行太多分析。”
-
“我是重用专家。我非常了解设计模式。”
-
“我们将使用最新的技术和功能来打动我们的客户。我们不需要关心传统系统。”
-
"更复杂的代码将反映我在这方面的专业知识."
11.11 你能提到一些反模式的症状吗?
回答:
在面向对象编程(OOP)中,最常见的症状是您的系统无法轻松采用新功能。此外,维护成本持续增加。您可能还会注意到,您已经失去了关键的面向对象功能,如继承、多态等。
除此之外,您可能会注意到以下部分或全部症状:
-
全局变量的使用
-
代码复制
-
有限/没有代码重用
-
一大类(神类)
-
存在大量无参数方法等。
11.12 如果检测到反模式,有什么补救措施?
回答:
你可能需要重构你的代码,找到一个更好的解决方案。例如,以下是一些避免以下反模式的解决方案:
-
*金锤:*你可以尝试通过一些适当的训练来教育 X 先生。
-
零表示空:你可以使用一个额外的布尔变量,这个变量对你来说更合理,可以正确地表示空值。
-
*数字管理:*如果你能明智地使用数字,数字就是好的。你不能仅仅根据一个程序员每周修复的缺陷数量来判断他/她的能力。质量也很重要。例如,修复简单的 UI 布局比修复系统中的严重内存泄漏要容易得多。考虑另一个例子。“更多的测试正在通过”并不意味着您的系统更加稳定,除非这些测试使用不同的代码路径/分支。
-
*拍摄信使:*欢迎测试员“John”并立即让他参与进来。不要把他当作你的对手。你可以正确地分析他的所有发现,并尽早修复真正的缺陷,以避免最后一刻的意外。
-
*复制粘贴编程:*不用去寻找快速的解决方案,你可以重构你的代码。你也可以把经常使用的方法进行日常维护,这样可以避免重复,更容易维护。
-
*架构师不编码:*让架构师参与实现阶段的某些部分。这对组织和他们自己都有帮助。这项活动可以让他们更清楚地了解产品的真正功能。说真的,他们应该重视你的努力。
11.13 什么叫 重构 ?
回答:
在编码领域,术语“重构”意味着改进现有代码的设计,而不改变系统/应用的外部行为。这个过程有助于您获得可读性更好的代码。同时,这些代码应该更能适应新的需求(或者变更请求),并且更易于维护。
一些常用术语
仅仅从一个好的设计开始并不是开发者的责任。保持一个好的设计同样重要。如果我们专注于快速修复,而不维护最初的设计目标或架构,我们可能会遇到新的问题。
不恰当的设计会使应用变得僵化。即使您从一个好的设计开始,对该应用的持续快速修复也会使其效率低下。那么一个简单的改变可能需要很多努力。在最坏的情况下,你会看到的脆弱性问题。这是什么意思?简而言之:一个位置的一个小的变更会导致多个位置的变更,在最坏的情况下,您会发现其中一些区域与最初的变更请求毫无关系。
如果重用您或其他人以前开发的一些内置部分,您可以非常快速地开发应用。当入门级程序员听说重用时,他认为继承是他唯一可用的选择,但这不是真的。您已经看到,在许多情况下,对象组合提供了比继承更好的解决方案。但是,如果您引入的代码段依赖于许多其他东西,或者已经有潜在的错误,那么继承或组合的使用就变得次要了;你确实在损害质量。无法重用软件通常被称为不动。
粘性是 OOP 中另一个需要考虑的重要事情。维基百科将其描述为开发者可以轻松地向系统添加保留设计的代码。在维护设计的时候,如果你能很容易的给你的程序添加新的代码,你的程序粘度就低。相反的是显而易见的:在一个高粘度的设计中,添加 hacks 是容易的,而不是努力保持原来的设计。你可以肯定地看到,通过使用这些黑客,你使你的系统更加僵化。这是粘度的一种形式,也称为设计粘度。
还有一种不同的形式叫做环境粘度。考虑这样一种情况,开发人员在主代码库进行变更之前使用了一个支柱构建。我说的支柱建筑是什么意思?让我给你举个例子:假设你的公司开发了一个有很多组件或模块的大型应用。我称它们为支柱。例如,如果应用可以发送电子邮件和传真,我们就有一个电子邮件支柱和一个传真支柱。由于这些组件很大,为了维护单独的组件,公司需要单独的团队。每个团队可以使用批处理文件编译一个特定的支柱,以确保支柱中的新变化不会破坏同一支柱/组件的其他部分。这是一个柱子建筑。所以,你可以把它看作是一个单模块的构建,或者一个单组件的构建。当完整的构建(即所有支柱的完整编译)是一项耗时的活动时,这是很有吸引力的,但是您需要尽快验证一个关键的修复。让我们举一个例子:你在发布你的软件之前发现了电子邮件的一个漏洞。让我们假设如果你只编译邮件专栏,大约需要 1 个小时。但是如果你触发一个完整的构建(将电子邮件、传真和其他支柱编译在一起),需要将近 5 个小时。因此,为了维持您的交付时间表,您倾向于只运行电子邮件支柱构建。我相信我不需要告诉你,如果你在互连的模块上工作,依赖这种支柱构建是有风险的。这是因为除非你不触发完全构建,否则你不知道最后一分钟的修复是否会导致其他支柱断裂。
内聚和耦合是另外两个重要概念,是由拉里·康斯坦丁在 20 世纪 60 年代末发明的。我们所说的凝聚力是什么意思?衔接的字典含义是相互联系或统一。在 OOP 中,当你设计一个类的时候,它测量类的方法和数据之间的关系的强度。如果你能记住第四章的单一责任原则(SRP ),对你来说会很容易。这些概念是联系在一起的,尽管内聚是一个更一般的概念。
相反的是耦合。维基百科说耦合是软件模块和之间相互依赖的程度。所以,在 OOP 中,你可以说它是两个类之间相互依赖的度量。假设有两个独立的类,A 和 B。现在考虑 A 在它的一个方法中使用 B 对象的情况,或者你在 A 类构造函数中创建一个 B 类对象并对其进行处理。在这些情况下,A 和 B 是紧密耦合的。即使 B 是 A 的子类,使用 A 的方法,你也可以说它们是紧耦合的。记住,我们想要高内聚和低耦合。我用 Robert C. Martin 在“干净的代码博客”(你可以在 https://blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html )中的话来结束这一章:
“如果你思考这个(SRP),你会意识到这只是定义内聚和耦合的另一种方式。我们希望增加因相同原因而变化的事物之间的凝聚力,我们希望减少因不同原因而变化的事物之间的耦合。”
问答环节
11.14 你在这本书中没有谈到 C# 的最新特性。有什么具体原因吗?
回答:
我们都知道,变化是软件行业唯一的“不变”。今天新的东西,明天就可能过时。是的,一些新功能很有趣。例如,在最新版本的 C# 中,可以使用顶级语句执行没有Main()方法的程序。这些语句按照它们在文件中出现的顺序执行。例如,考虑以下代码:
using System;
namespace WithoutUsingTopLevelStatements
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello World!");
}
}
}
在早期版本的 C# 和。NET 中,namespace和args参数是可选的。现在进来了。NET 5,你可以有一个进一步简化的版本:
using System;
Console.WriteLine("Hello World!");
记住,要编译它,你必须使用 C# 9.0(目标框架。NET 5.0);否则,您会看到以下内容:Error CS8400 Feature 'top-level statements' is not available in C# 8.0.
的确,这是一个很大的简化。这个特性对于脚本场景很有用。现在,您输入的内容更少了,代码也更小了,但是您可以毫无问题地获得想要的输出。在这种情况下。NET 平台为你提供了所有幕后必需的东西。今天,如果初学者从这条捷径开始,他可能很难想象背景材料和遗留代码。(但有可能几年后,所有初学者都会更喜欢从这里开始。)
事实是,当你加入一个公司团队时,不太可能每次都使用编程语言的最新特性。相反,大多数时候,您使用遗留版本来支持现有客户。除非公司决定你应该进行更新,否则你也可能会继续修复旧版本中的错误。所以,我总是努力保持平衡。在我的其他书中,我也使用了编程语言的基本特征,这样你就可以很容易地理解它们。我更喜欢编写各种版本都支持的代码。
我想知道你是否在这本书里使用了其他最新的 特征 。
回答:
这是你在这本书里看到的另外两个例子:
-
C# 9.0 允许你检查一个对象是否不为空。因此,下面的代码块不会产生任何编译时错误:
if (sample is not null){ // some code } -
从 C# 8.0 开始,您可以使用以下语句:
using Sample sample = new Sample();
你能给我一些一般性的建议吗?
回答:
我喜欢跟随我的前辈和老师的脚步,他们是这方面的专家。以下是他们的一些一般性建议:
-
编程到超类型(抽象类/接口),而不是实现。
-
除了少数情况之外,尽可能选择组合而不是继承。
-
尽量做一个松散耦合的系统。
-
隔离可能与代码其余部分不同的代码。
-
封装变化的内容。
摘要
跟随专家的足迹并从记录的经验中学习是一个非常好的策略。所以,理解设计模式非常重要。同时,建议你明智地使用它们;否则,您可能会注意到反模式的影响。一个明显的影响是,您需要投入时间来重构代码或从头开始实现新的设计。无论如何,你应该更喜欢一个没有吸引力的更好的解决方案,而不是一个有吸引力的快速解决方案。
我还在本章末尾描述了一些常用术语。这些通常有助于你理解一个演讲者在技术会议上说了什么,或者一个技术作者在他的书中写了什么。
第一部分:基础知识
Fundamentals
第一部分由三章组成,其中我们将讨论以下问题:
-
我们如何使用多态的力量,为什么它是有益的?
-
我们如何将抽象类和接口结合在一起,以创建一个高效的应用?
-
如何在程序中使用有意义的代码注释,避免不必要的注释?
几乎每个 C# 应用都使用注释、多态的概念以及抽象类和接口。当我们以更好的方式实现这些技术时,程序就会更好。我认为它们是高效应用的基本技术。
第二部分:重要原则
Important Principles
第二部分由两章组成,其中我们将检查以下内容的使用:
-
SOLID 原则。这是五个设计准则的组合。
-
不重复自己(干)的原则
在面向对象编程的世界里,并不缺少原则,但是我们将在接下来的两章中讨论的是更好的应用的基本设计准则。人们无法预先预测所有未来的需求,因此在企业应用中经常需要更改。这就是为什么能够轻松适应未来需求的灵活应用被认为是更好的应用。这一部分将回顾使用(和不使用)这些原则的案例研究,并帮助你思考它们的重要性。对这些原则的详细研究可以帮助您创建高效灵活的应用。
第三部分:高效应用
Make Efficient Applications
第三部分由四章组成,其中我们将按照几个重要的设计模式开发一些有用的应用。这一部分将包括以下内容:
-
我们如何使用工厂将一个更有可能变化的代码段与一个不太可能变化的代码段分开?
-
我们如何使用包装器向应用添加新特性?
-
怎样才能把一个模板方法和一个挂钩方法结合起来做一个高效的应用呢?
-
我们如何使用外观来简化一个复杂的系统?
软件行业充满了模式和设计指南。随着您继续编写和创建不同的应用,您将发现它们的重要性,并理解何时选择一种技术而不是另一种。在书的序言中,我告诉过你 帕累托 原理,或者说 80-20 法则 ,它陈述了 80%的结果来自所有原因的 20%。这就是为什么在这一部分中,我向您展示了构建现实世界应用常用的技术。一旦你掌握了这些技巧,你就会得到以下问题答案的提示:一个职业程序员在写一段代码之前是怎么想的?