前面几章展示了如何在Main
方法中编写代码。这是程序入口点,但它通常是一个轻量级方法,没有太多代码。在本章中,您将学习如何将代码移出Main
方法并模块化,以便更好地管理代码。您将学习如何定义带有参数和返回值的方法。您还将了解属性,它允许您封装对象状态。
我们将使用上一章中计算器的简单版本来开始。这个计算器只执行加法,一次运算后停止运行。
using System;
class Calculator1
{
static void Main()
{
Console.Write("First Number: ");
string firstNumberInput = Console.ReadLine();
double firstNumber = double.Parse(firstNumberInput);
Console.Write("Second Number: ");
string secondNumberInput = Console.ReadLine();
double secondNumber = double.Parse(secondNumberInput);
double result = firstNumber + secondNumber;
Console.WriteLine($"\n\tYour result is {result}.");
Console.ReadKey();
}
}
代码清单 39
这个程序中可能是新的部分是Main
方法末尾的Console.ReadKey
语句。这允许用户看到结果,并防止程序结束,直到他们按下一个键。内插字符串中的\n
是一个换行符,\t
是一个制表符。
虽然之前的程序很小,但第一眼并不能真正告诉你它是做什么的。想象一下,如果它像第二章中的计算器,甚至更长;最终会变得难以理解。当您稍后必须再次处理这个问题时,您可能需要阅读许多行代码才能理解它。所以,重构这个会更好。重构是改变代码设计而不改变其功能的实践;目的是改进程序。下面的代码示例是将该程序重构为方法的初稿。
using System;
class Calculator2
{
static void Main()
{
double firstNumber = GetFirstNumber();
double secondNumber = GetSecondNumber();
double result = AddNumbers(firstNumber, secondNumber);
PrintResult(result);
Console.ReadKey();
}
static double GetFirstNumber()
{
Console.Write("First Number: ");
string firstNumberInput = Console.ReadLine();
double firstNumber = double.Parse(firstNumberInput);
return firstNumber;
}
static double GetSecondNumber()
{
Console.Write("Second Number: ");
string secondNumberInput = Console.ReadLine();
double secondNumber = double.Parse(secondNumberInput);
return secondNumber;
}
static double AddNumbers(double firstNumber, double secondNumber)
{
return firstNumber + secondNumber;
}
static void PrintResult(double result)
{
Console.WriteLine($"\nYour result is {result}.");
}
}
代码清单 40
看Main
,可以知道程序是做什么的。它读取两个数字,将结果相加,然后向用户显示结果。每一行都是一个方法调用。前三种方法——GetFirstNumber
、GetSecondNumber
和AddNumbers
——返回一个分配给变量的值。最后一个方法PrintResult
执行一个操作,但不返回结果。在进行下一次重构之前,让我们先来看看这些方法。下面的代码清单显示了GetFirstNumber
方法。
static double GetFirstNumber()
{
Console.Write("First Number: ");
string firstNumberInput = Console.ReadLine();
double firstNumber = double.Parse(firstNumberInput);
return firstNumber;
}
代码清单 41
乍一看,这个方法的签名看起来类似于Main
方法。不同的是这个方法的返回类型是double
,方法名为GetFirstNumber
。我们所做的只是编写创建firstNumber
的方法和代码。当方法具有返回类型时,必须返回该类型的值。GetFirstNumber
用return
语句做到这一点。
GetSecondNumber
方法与GetFirstNumber
几乎相同。接下来让我们来看看AddNumbers
。
static double AddNumbers(double firstNumber, double secondNumber)
{
return firstNumber + secondNumber;
}
代码清单 42
请注意,Main
将firstNumber
和secondNumber
变量作为参数传递给AddNumbers
,AddNumbers
方法可以使用这些参数。AddNumbers
的返回类型为double
,所以该方法进行加法运算,返回加法运算的结果。
最后,我们有PrintResult
法。
static void PrintResult(double result)
{
Console.WriteLine($"\nYour result is {result}.");
}
代码清单 43
PrintResult
方法将参数结果写入控制台。注意PrintResult
没有返回类型,如void
关键字所示。
最后一部分改进了程序,因为一大块代码被分成了更有意义的部分。我们可以通过一些额外的重构来改进这段代码。特别是GetFirstNumber
和GetSecondNumber
方法在很大程度上是多余的。下面的示例展示了如何将这两种方法重构为一种,并减少代码量。
using System;
class Calculator3
{
static void Main()
{
double firstNumber = GetNumber("First");
double secondNumber = GetNumber("Second");
double result = AddNumbers(firstNumber, secondNumber);
PrintResult(result);
Console.ReadKey();
}
static double GetNumber(string whichNumber)
{
Console.Write($"{whichNumber} Number: ");
string numberInput = Console.ReadLine();
double number = double.Parse(numberInput);
return number;
}
static double AddNumbers(double firstNumber, double secondNumber)
{
return firstNumber + secondNumber;
}
static void PrintResult(double result)
{
Console.WriteLine($"\nYour result is {result}.");
}
}
代码清单 44
这次我去掉了GetFirstNumber
和GetSecondNumber
,换成了GetNumber
。除了变量名之外,唯一真正的区别是whichNumber
string
参数。
前面的例子执行了同一个类内部的所有操作。它由Main
方法驱动,并通过方法进行服务。如果我想重用该类中的计算器函数,并希望新类保存自己的值或状态,该怎么办?在这种情况下,将计算器方法移到单独的Calculator
类中会很有用。
接下来要问的问题是,“我们如何进入班级的状态?”比如我想读Calculator
课的成绩,最好的方法是什么?一种方法是使用名为GetResult
的方法返回该值。C# 中的另一种方法是使用属性,它可以像字段一样使用,但像方法一样工作。下面版本的计算器程序展示了如何将方法重构到一个单独的类中并添加属性。
| | 注意:重构是改变代码设计而不改变其行为的实践,目的是改进代码。马丁·福勒的书《重构:改进现有代码的设计》是一个很好的参考。 |
using System;
public class Calculator4
{
double[] numbers = new double[2];
public double First
{
get
{
return numbers[0];
}
}
public double Second
{
get
{
return numbers[1];
}
}
double result;
public double Result
{
get { return result; }
set { result = value; }
}
public void GetNumber(string whichNumber)
{
Console.Write($"{whichNumber} Number: ");
string numberInput = Console.ReadLine();
double number = double.Parse(numberInput);
if (whichNumber == "First")
numbers[0] = number;
else
numbers[1] = number;
}
public void AddNumbers()
{
Result = First + Second;
}
public void PrintResult()
{
Console.WriteLine($"\nYour result is {result}.");
}
}
代码清单 45
在前面的代码清单中,First
、Second
和Result
是属性。我将很快分解语法,但是首先看看这些属性在AddNumbers
和PrintResults
方法中是如何使用的。AddNumbers
读取First
和Second
的值,并将这些值相加并写入Result
。
每个属性看起来都像一个字段或变量;你只是读和写给他们。PrintResult
读取Result
属性。但是,查看属性的定义,您可以立即知道它们不是字段。
Result
属性是一个典型的带有get
和set
访问器的读写属性。读取属性时,get
访问器执行。当您写入属性时,set
存取器执行。注意在Result
(大写)属性之前有一个result
字段(小写)。get
存取器读取result
的值,set
存取器写入result
。使用set
时,value
关键字代表正在写入属性的内容。
这种从单个后备存储中读写的模式非常常见,以至于 C# 有一种快捷语法可以替代。下面的代码示例显示了被重写为自动实现的属性的Result
。
public double Result { get; set; }
代码清单 46
自动实现的属性中的后备存储由 C# 编译器在幕后隐含和处理。如果您需要对所分配的值进行验证,或者有一种独特的方法来存储该值,那么您应该使用定义了get
访问器、set
访问器或两者的完整属性。
事实上,First
和Second
属性有一个唯一的后备存储,需要一个完全实现的get
访问器。它们从数组位置读取。注意GetNumber
方法计算出每个数字放入哪个数组位置。
属性使您能够封装类的内部操作,因此您可以自由修改实现,而不会破坏类使用者的接口。下面的代码示例演示了消费代码如何使用这个新的Calculator4
类。
using System;
class Program
{
static void Main()
{
var calc4 = new Calculator4();
calc4.GetNumber("First");
calc4.GetNumber("Second");
calc4.AddNumbers();
PrintResult(calc4);
Console.ReadKey();
}
static void PrintResult(Calculator4 calc)
{
Console.WriteLine();
Console.WriteLine($"Your result is {result}.");
}
}
代码清单 47
Main
方法创建Calculator4
的新实例,并调用公共方法。Calculator4
所有奇怪的内部都是隐藏的,Main
只关心公共接口,暴露Calculator4
服务供重用。PrintResult
方法读取Calculator4
实例Result
属性。同样,这也是方法和属性的好处:调用者可以使用一个类,而不用关心这个类是如何工作的。
C# 有一个称为结构化异常处理的特性,它允许您处理方法无法实现预期目的的情况。管理异常处理的语法是try
- catch
块。所有监控异常的代码都在try
块中,处理潜在异常的代码在catch
块中。下面的代码清单显示了一个示例。
static void HandleNullReference()
{
Program prog = null;
try
{
Console.WriteLine(prog.ToString());
}
catch (NullReferenceException ex)
{
Console.WriteLine(ex.Message);
}
}
代码清单 48
在 C# 中,任何时候你试图使用null
对象的成员,你都会收到一个NullReferenceException
。解决这个问题的方法是给变量赋值。前面的例子通过调用prog
变量null
上的ToString
导致一个NullReferenceException
被扔进try
块。
由于抛出异常的代码在try
块中,因此该代码停止执行try
块中的任何代码,并开始寻找异常处理程序。catch
块parameter
表示如果try
块内部的代码抛出该异常类型,它可以捕捉到一个NullReferenceException
。catch
块的主体是您执行任何异常处理的地方。
您可以使用多个catch
块自定义异常处理。以下示例显示了在try
块中引发异常的代码,该异常随后由catch
块处理。
static void HandleUncaughtException()
{
Program prog = null;
try
{
Console.WriteLine(prog.ToString());
}
catch (ArgumentNullException ex)
{
Console.WriteLine("From ArgumentNullException: " + ex.Message);
}
catch (ArgumentException ex)
{
Console.WriteLine("From ArgumentException: " + ex.Message);
}
catch (Exception ex)
{
Console.WriteLine("From Exception: " + ex.Message);
}
finally
{
Console.WriteLine("Finally always executes.");
}
}
代码清单 49
方法名为HandleUncaughtException
,因为没有具体的catch
块来处理一个NullReferenceException
;例外情况将由Exception
类型的catch
模块处理。
您可以按继承层次列出异常,顶层异常位于catch
块列表的下方。抛出的异常将在处理程序列表中下移,寻找匹配的异常类型,并且只在匹配的第一个处理程序的catch
块中执行。ArgumentNullException
源于ArgumentException
,ArgumentException
源于Exception
。
如果没有catch
块可以处理异常,代码会在栈上寻找调用代码中可以处理异常类型的潜在catch
块。如果调用栈中没有代码能够处理异常,您的程序将终止。
如果程序开始执行try
块中的代码,则finally
块始终执行。如果异常发生并且没有被捕获,finally
块将在程序查看匹配的捕获处理程序的调用代码之前执行。
您可以编写一个try
- finally
块(没有catch
块),以保证一旦try
块开始,某些代码将会执行。这对于打开资源(如文件或数据库连接)非常有用,并且可以保证无论是否发生异常,您都能够关闭该资源。
如果您遇到方法无法实现其预期目的的原因,则throw
会出现异常。中有许多Exception
派生类型。NET FCL,你可以使用。下面的代码示例汇集了一些您可能想要使用的概念,例如验证方法输入和抛出一个ArgumentNullException
。
public class Address
{
public string City { get; set; }
}
internal class Company
{
public Address Address { get; set; }
}
// Inside of a class...
static void ThrowException()
{
try
{
ValidateInput("something", new Company());
}
catch (ArgumentNullException ex) when (ex.ParamName == "inputString")
{
Console.WriteLine("From ArgumentNullException: " + ex.Message);
}
catch (ArgumentException ex)
{
Console.WriteLine("From ArgumentException: " + ex.Message);
}
}
static void ValidateInput(string inputString, Company cmp)
{
if (inputString == null)
throw new ArgumentNullException(nameof(inputString));
if (cmp?.Address?.City == null)
throw new ArgumentNullException(nameof(cmp));
}
代码清单 50
前面的代码显示了一个Address
类和一个Company
类,它们的属性为Address
类型。ThrowException
消息的try
块传递了Company
的新实例,但没有实例化Address
,这意味着Company
实例的Address
属性是null
。
在ValidateInput
中,if
语句使用空引用运算符?.
,检查Company
、Company
的Address
属性或Address
的City
属性之间的值是否为null
。这是一种方便的检查null
的方法,不需要一组单独的检查,产生更少的语法和更简单的代码。如果这些值中有任何一个是null
,代码将抛出ArgumentNullException
。
ArgumentNullException
的参数使用nameof
运算符,该运算符计算传递给它的值的string
表示;正是"
T4"
在这种情况下。这段代码没有包含在try
块中,所以控制返回到调用这个方法的代码。
回到ThrowException
方法,抛出的异常导致代码寻找适合该异常类型的处理程序。异常类型是ArgumentNullException
,但是ArgumentNullException
的catch
块不会执行。这是因为ArgumentNullException
catch
块参数后面的when
子句正在检查"
inputString
"
中的一个ParamName
。这个when
子句叫做异常过滤器。如前所述,实例化时传递给ArgumentNullException
的参数名是"
cmp
"
,所以不匹配。因此,代码继续查看catch
处理程序。
由于ArgumentNullException
来源于ArgumentException
,没有异常过滤器,ArgumentException
的catch
处理程序执行。现在异常被处理了。
| | 提示:抛出和处理特定的异常通常比它们的父异常更好。这为异常增加了更多的保真度和意义,并使调试更加容易。 |
方法帮助您将代码组织成命名函数,您可以调用这些函数来执行操作。他们的名字记录了代码的功能。此外,方法有助于避免在多个地方重复相同的代码。从使用属性的类的代码的角度来看,属性像字段一样使用,看起来像字段。然而,属性更复杂,因为它们有get
和set
访问器,使它们像方法一样工作,并执行更复杂的工作,如验证或特殊值处理。方法和属性都有助于定义类对使用者的接口,并让您封装类的内部操作,这使得它更具可重用性。可以使用try
- catch
块处理异常,使用try
- finally
块保证关键代码执行。每当你正在编写的方法不能实现其预期目的时,使用throw
语句。