C# 应用程序开发的一些最佳实践(已解释)






4.73/5 (83投票s)
在本文中,我将详细讨论 C# 的最佳编码实践。
引言
几天前,在我的一篇早期博文中,我根据我过去几年的经验列出了“C# 应用程序开发的一些最佳实践”,这篇文章受到了读者的热烈欢迎。我也收到了很多反馈。我的许多读者也提出了宝贵的建议。在本文中,我将讨论其中大部分要点。我将定期更新本文,加入新的最佳编码实践。希望在这里也能收到更多反馈和/或建议。让我们开始讨论 C# 应用程序开发的最佳编码实践。以下是一些要点:
使用正确的命名约定
您应该优先使用正确的命名约定来保持代码的一致性。如果整个解决方案都使用一致的命名,代码将非常容易维护。以下是一些 .NET 开发人员通常遵循的命名约定:
- 声明变量时,始终使用驼峰命名法(单词首字母小写,后续每个单词的首字母大写)。
- 声明属性时,使用帕斯卡命名法(单词首字母大写,后续每个单词的首字母大写)。
- 避免为属性、变量或方法名使用全大写或全小写。声明常量变量时使用全大写。
- 切勿使用以数字开头的名称。
- 始终优先为类、属性、方法等使用有意义的名称。这对于您将来维护代码非常有帮助。例如,“P”不能为类提供正确的含义。您会发现很难了解该类。但如果您使用“Person”,您就能轻松理解。
- 切勿构建因大小写不同而变化的名称。这是一个非常糟糕的实践。这在开发代码时没有用处,因为您不知道“person”类和“Person”类是什么!!!但根据上述场景,可以非常清楚地理解“person”是“Person”类的实例变量。
- 不要使用 .NET Framework 中已有的名称。新接触您代码的人很难轻易理解。
- 避免为标识符添加前缀或后缀。虽然在一些指南中,他们使用“m_”作为变量声明的前缀,在另一些指南中,他们使用“_”。我认为这并不是那么有用。但这取决于您组织的编码实践。这一点因不同组织而异,并且没有严格的指导。
- 声明接口时,始终使用“I”作为前缀。这是声明接口的常用做法。
- 为自定义异常类始终添加“Exception”作为后缀。这将使您的异常类更具可见性。
- 切勿在类名前缀或后缀其属性名。这会不必要地增加属性名的长度。如果“
Firstname
”是“Person
”类的属性,您可以直接从该类中轻松识别。无需编写“PersonFirstname
”或“FirstnameOfPerson
”。
- 为布尔属性(如“
IsVisible
”、“HasChildren
”、“CanExecute
”)添加“Is
”、“Has
”或“Can
”前缀。这些为属性提供了正确的含义。
- 不要为控件添加前缀,而是编写合适的名称来标识控件。
决定使用值类型还是引用类型
每当您需要创建一种类型时,首先问自己一个问题:“您想要什么?为什么需要它?”如果您能回答这个问题,您就可以决定要使用哪种类型。如果您想存储数据,请使用值类型;如果您想通过定义行为来创建类型的实例,请使用引用类型。值类型不是多态的,而引用类型可以。在内存利用方面,值类型比引用类型更有效,产生的帮助碎片和垃圾回收更少。如果您想将值传递给方法实现,请决定您想做什么,并根据您的需求在值类型和引用类型之间做出决定。引用类型变量的使用实际上会更改原始值,而值类型的使用会创建原始变量的副本并传递给方法。因此,它保护您的原始值免遭意外更改。让我们在实际示例中看看它们。在下面的代码中,我们将变量“i
”作为值类型传递,并在方法实现中将其增加 10。由于它是按值传递的,您将看到程序代码的输出是原始值“5
”。
static void Main(string[] args)
{
int i = 5;
SetValues(i);
System.Console.WriteLine("Currently i = " + i); // will print "Currently i = 5"
}
static void SetValues(int x)
{
x += 10;
}
在下面的代码中,由于我们将“i
”作为引用发送,它将更改方法内部“i
”的原始值,因此您将在屏幕上看到输出 15。
static void Main(string[] args)
{
int i = 5;
SetValues(ref i);
System.Console.WriteLine("Currently i = " + i); // will print "Currently i = 15"
}
static void SetValues(ref int x)
{
x += 10;
}
因此,在开始实现之前就做出决定,否则一旦实现,在您的庞大应用程序中进行更改将非常困难。
始终使用属性而不是公共变量
这样做的原因是,它使得您的代码在 OOP 环境中得到良好的封装。通过使用 getter 和 setter,您可以限制用户直接访问成员变量。您可以限制显式设置值,从而保护您的数据免遭意外更改。此外,属性为您提供了更轻松的数据验证。让我们看一个小代码。
public class Person
{
public string name;
public string GetNameInLowerCase()
{
return string.IsNullOrWhiteSpace(name) ?
string.Empty : name.ToLower();
}
public string GetNameInUpperCase()
{
return string.IsNullOrWhiteSpace(name) ?
string.Empty : name.ToUpper();
}
}
在上面的示例中,您会看到每次都需要检查 Null
值,因为外部人员可能会轻易意外地更改值并导致代码中的 Bug。如果您不希望代码中出现该 Bug,您应该做的是使用变量的属性实现(使其成为 private
),然后访问该属性。这提供了更高的代码可靠性。让我们看一个示例。
public class Person
{
public string name;
public string Name
{
get
{
return string.IsNullOrWhiteSpace(name) ? string.Empty : name;
}
set { name = value; }
}
public string GetNameInLowerCase()
{
return Name.ToLower();
}
public string GetNameInUpperCase()
{
return Name.ToUpper();
}
}
现在在这种情况下,您不必每次都担心检查值是否为 Null
。属性的 getter 实现会处理。因此,一次实现,多次使用。所以,如果有人显式地将“Name
”属性设置为 null
,您的代码将不受任何影响。这只是一个示例代码,用于与您讨论需要属性而不是 public
成员变量。实际实现可能会根据您的需求而有所不同。
public class Person
{
public string Name { get; private set; }
public string Age { get; }
}
如果您不希望任何人从外部显式设置属性,您只需在属性实现中仅实现 getter,或将 setter 标记为 private
。此外,您可以从任何接口实现属性,从而提供更多的 OOP 环境。
在需要时使用可空数据类型
有时,您可能需要将 null
作为整数、双精度或布尔变量的值来存储。那么您该如何做呢?普通声明不允许您将 null
存储为值。C# 现在具有可空数据类型的特性。只需在声明中稍作改动。这样就可以了!您可以存储 null
值。您只需要使用“?”修饰符。您必须将其放在类型之后。要定义一个整数,您通常会进行如下声明:
int index = 0; // simple declaration of int
要将其转换为可空数据类型,您需要稍作修改,声明如下:
int? index = null; // nullable data type declaration
一旦为数据类型添加了“?”修饰符,您的变量就将变为可空,您就可以为其存储“null
”值。通常,这在与布尔值一起使用时很有帮助。
优先使用运行时常量而不是编译时常量
始终优先使用运行时常量而不是编译时常量。在这里,您可能会问什么是运行时常量,什么是编译时常量。运行时常量是在运行时进行计算并使用“readonly
”关键字声明的。另一方面,编译时常量是 static
的,在编译时进行计算并使用“const
”关键字声明。
public readonly string CONFIG_FILE_NAME = "web.config"; // runtime constant
public const string CONFIG_FILE_NAME = "web.config"; // compile time constant
那么,为什么需要优先使用 readonly
而不是 const
变量呢?编译时常量(const
)必须在声明时初始化,并且以后不能更改。此外,它们仅限于数字和字符串。IL 会将 const
变量替换为其值,从而覆盖整个代码,因此速度稍快。而在另一方面,运行时常量(readonly
)在构造函数中初始化,并且可以在不同的初始化时间进行更改。IL 引用的是 readonly 变量而不是原始值。所以,当您遇到一些关键情况时,请使用 const
来使代码运行更快。当您需要可靠的代码时,请始终优先使用 readonly
变量。
在强制类型转换时,优先使用“is”和“as”运算符
在强制类型转换时,最好使用“is
”和“as
”运算符。与显式强制类型转换相比,使用隐式强制类型转换。让我用代码示例向您说明。
// this line may throw Exception if it is unable to downcast from Person to Employee
var employee = (Employee) person;
在上面的代码中,假设您的 person 是 Customer
类型,当您将其转换为 Employee
类型时,它将抛出 Exception
,在这种情况下,您必须使用 try{} catch{}
块来处理。让我们使用“is
”和“as
”运算符进行相同的转换。请看下面的代码。
// check if the person is Employee type
if(person is Employee)
{
// convert person to Employee type
employee = person as Employee;
}
// check if the person is Customer type
else if(person is Customer)
{
// convert person to Customer type
customer = person as Customer;
}
在上面的代码中,您可以看到,在第二行我正在检查 person 是否为 Employee
类型。如果是 Employee
类型,它将进入该块。否则,如果它是 Customer
类型,它将进入第 12 行的块。现在,使用“as
”运算符进行转换,如第 5 行所示。在这里,如果无法转换,它将返回 null
,但不会抛出任何异常。因此,在下一行,您可以检查转换后的值是否为 null
。根据该值,您可以执行您想要的操作。
字符串连接优先使用 string.Format() 或 StringBuilder
string
中的任何操作都会创建一个新对象,因为 string
是不可变对象。如果您想连接多个 string
,最好使用 string.Format()
方法或 StringBuilder
类进行连接。
string str = "k";
str += "u";
str += "n";
str += "a";
str += "l";
Console.WriteLine(str);
对于 string.Format()
,它不会创建多个对象,而是创建一个单一对象。StringBuilder
作为不可变对象,不会为每个操作创建单独的内存。因此,您的应用程序内存管理将不会处于危险状态。因此,执行此类操作最好使用 string.Format()
或 StringBuilder
。
上面的代码会在每次添加新 string
时创建一个新的 string
对象。使用 string.Format()
,您可以编写以下代码:
string str = string.Format("{0}{1}{2}{3}{4}", "k", "u", "n", "a", "l");
如果您想使用 StringBuilder
,这里是代码:
StringBuilder sb = new StringBuilder();
sb.Append("k");
sb.Append("u");
sb.Append("n");
sb.Append("a");
sb.Append("l");
string str = sb.ToString();
上述两个示例每次都将使用相同的 string
实例。
在需要时使用条件属性
当您只想为调试版本执行某些操作时,条件属性非常有用。我知道您可能会想到 #if
/#endif
块。是的,您可以使用 #if
/#endif
块来执行特定于调试版本的操作,例如跟踪或将内容打印到输出屏幕。那么,为什么我们不使用它们呢?当然,您可以这样做。但是,有一些陷阱。假设您编写了以下代码:
private void PrintFirstName()
{
string firstName = string.Empty;
#if DEBUG
firstName = GetFirstName();
#endif
Console.WriteLine(firstName);
}
在这种情况下,它在调试模式下可以正常工作。但是,看看另一方面。当它转到生产环境时,GetFirstName()
将永远不会被调用,因此它将打印一个空的 string
。这将是您代码中的一个 Bug。那么,该怎么办?我们可以使用条件属性来克服这个问题。将您的代码编写在一个方法中,并将类型为 Conditional 的属性设置为 DEBUG 字符串。当您的应用程序在调试模式下运行时,它将调用该方法;而在其他情况下,它将不调用。让我们看看这里的代码:
[Conditional(“DEBUG”)]
private void PrintFirstName()
{
string firstName = GetFirstName();
Console.WriteLine(firstName);
}
上面的代码实际上是通过 Conditional
属性隔离了函数。如果您的应用程序在调试版本下运行,它将在第一次调用 PrintFirstName()
时将其加载到内存中。但当它进入生产版本(即发布版本)时,该方法将永远不会被调用,因此不会加载到内存中。编译器会处理一旦将条件属性应用到方法后该做什么。因此,始终建议使用条件属性而不是 #if
编译指示块。
[Conditional(“DEBUG”)]
[Conditional(“TRACE”)]
private void PrintFirstName()
{
string firstName = GetFirstName();
Console.WriteLine(firstName);
}
此外,如果您想设置多个条件属性,可以通过添加多个属性来实现,如上所示。
在枚举值类型中,始终使用“0”(零)作为默认值
当我们在编写 enum
定义时,有时会忘记为其设置 DEFAULT
值。在这种情况下,它将自动设置为 0
(零)。
public enum PersonType
{
Customer = 1
}
class Program
{
static void Main(string[] args)
{
PersonType personType = new PersonType();
Console.WriteLine(personType);
}
}
例如,上面的代码将始终输出 0
(零),您必须显式为其设置默认值。但是,如果我们稍作修改并添加默认值,它将始终输出默认值而不是 0
(零)。
public enum PersonType
{
None = 0,
Customer = 1
}
class Program
{
static void Main(string[] args)
{
PersonType personType = new PersonType();
Console.WriteLine(personType);
}
}
在这种情况下,它将输出“None
”。实际上,系统会将所有值类型的实例初始化为 0
。没有办法阻止用户创建所有为 0
(零)的值类型实例。因此,始终建议在 enum
类型中隐式设置默认值。
始终优先使用 foreach(…) 循环
foreach
语句是 do
、while
或 for
循环的变体。它实际上会为您拥有的任何集合生成最佳的迭代代码。当您使用集合时,始终优先使用 foreach
循环,因为 C# 编译器会为您的特定集合生成最佳的迭代代码。请看下面的实现。
foreach(var collectionValue in MyCollection)
{
// your code
}
在这里,如果您的 MyCollection
是 Array
类型,C# 编译器将为您生成以下代码:
for(int index = 0; index < MyCollection.Length; index++)
{
// your code
}
将来,如果您将集合类型更改为 ArrayList
而不是 Array
,foreach
循环仍将编译并正常工作。在这种情况下,它将生成以下代码:
for(int index = 0; index < MyCollection.Count; index++)
{
// your code
}
检查 for
条件。您会看到一些小的差异。Array
有 Length
属性,因此它生成调用 Length
的代码。但对于 ArrayList
,它将 Length
替换为 Count
,因为 ArrayList
有 Count
,它执行相同的功能。在其他情况下,如果您的集合类型实现了 IEnumerator
,它将为您生成不同的代码。让我们看看代码。
IEnumerator enumerator = MyCollection.GetEnumerator();
while(enumerator.MoveNext())
{
// your code
}
因此,您无需考虑使用哪种循环。编译器负责这一点,它将为您选择最佳循环。foreach
循环的另一个优点是遍历一维或多维数组。对于多维数组,您不必编写多行 for
语句。因此,它将减少您需要编写的代码量。编译器内部将生成该代码。从而提高您的生产力。
妥善利用 try/catch/finally 块
是的,妥善利用 try
/catch
/finally
块。如果您知道您编写的代码可能会抛出异常,请使用 try
/catch
块来处理该代码段中的异常。如果您知道您代码的第 5 行可能会抛出异常,建议仅将该行代码用 try
/catch
块包围。不必要地用 try
/catch
包围代码的其余部分会减慢应用程序的速度。有时,我注意到人们用 try
/catch
块包围每个方法,这真的很糟糕,并且会降低代码的性能。所以从现在开始,只在需要时使用它。使用 finally
块在调用后清理任何资源。如果您正在进行任何数据库调用,请在该块中关闭连接。无论您的代码是否正常执行,finally
块都会运行。因此,妥善利用它来清理资源。
只捕获您能处理的异常
这也是一个主要问题。人们总是使用通用的 Exception
类来捕获任何异常,这对于您的应用程序和系统性能都不利。只捕获您期望的异常,并按顺序排列它们。最后,如果需要,可以添加通用的 Exception
来捕获任何其他未知异常。这为您提供了一种妥善处理异常的方式。假设您的代码抛出 NullReferenceException
或 ArgumentException
。如果您直接使用 Exception
类,在您的应用程序中处理起来会非常困难。但通过正确捕获异常,您可以轻松处理问题。
使用 IDisposable 接口
使用 IDisposable
接口来释放内存中的所有资源。一旦您在类中实现了 IDisposable
接口,您将在其中获得一个 Dispose()
方法。在该方法中编写代码来释放资源。如果您实现了 IDisposable
,您可以这样初始化您的类:
using (PersonDataSource personDataSource = DataSource.GetPerson())
{
// write your code here
}
在 using() {}
块之后,它将自动调用 Dispose()
方法来释放类资源。您不必显式调用 Dispose()
来清理类。
将您的逻辑拆分成多个小型简单的方
如果方法过长,有时很难处理。始终最好根据其功能使用多个小型方法,而不是将它们全部放在一个方法中。如果您将它们分解成单独的方法,将来如果您需要调用其中一部分,调用将比复制代码更容易。此外,对小型代码块进行单元测试也更容易。因此,无论何时编写代码片段,首先思考您想做什么。根据 đó,将您的代码提取到小型简单的方法中,并在需要时调用它们。通常,一个方法不应超过 10-15 行。
结束语
希望这些要点能帮助您正确理解 C# 编码实践。还有一些我没有在这里涵盖的要点。我将很快在同一主题下发布它们。如果您有任何建议或意见,请告诉我。祝您一切顺利……