C#程序构建基块
本文内容
成员
字段
方法
其他函数成员
表达式
语句
此C#系列教程的前一篇文章中介绍的类型是使用一下构建基块生成的:
成员,如属性、字段、方法和事件。
表达式
语句
成员
class的成员要么是静态成员,要么是实例成员。
静态成员属于类,而实例成员则属于对象(类实例)。
以下列表概述了类可以包含的成员类型。
常量:与类相关联的常量值
字段:与类关联的变量
方法:类可执行的操作
属性:与读取和写入类的已命名属性相关联的操作
索引器:与将类实例编入索引(像处理数组一样)相关联的操作
事件:类可以生成的通知
运算符:类支持的转换和表达式运算符
构造函数:初始化类实例或类本身所需的操作
类型:类声明的嵌套类型
终结器:永久放弃类的实例之前完成的操作
辅助功能
每个类成员都有关联的可访问性,用于控制能够访问成员的成员程序文本区域。
可访问性有六种可能的形式。
以下内容对访问修饰符进行了汇总。
public:访问不受限制。
private:访问仅限于此类。
protected:访问仅限于此类或派生自此类的类。
internal:仅可访问当前程序集(.exe或.dll).。
protected internal:仅可访问此类、从此类中派生的类,或者同一程序集中的类。
private protected:仅可访问此类或同一程序集中从此类中派生的类。
字段
字段是与类或类实例相关联的变量。
使用静态修饰符声明的字段定义的是静态字段。
静态字段只指明一个存储位置。
无论创建多少个类实例,永远只有一个静态字段副本。
不使用静态修饰符声明的字段定义的是实例字段。
每个类实例均包含相应类的所有实例字段的单独副本。
在以下示例中,每个Color类实例均包含R、G和B实例字段的单独副本,但只包含Black、White、Red、Green和Blue静态字段的一个副本:
public class Color
{
public static readonly Color Black = new(0, 0, 0);
public static readonly Color White = new(255, 255, 255);
public static readonly Color Red = new(255, 0, 0);
public static readonly Color Green = new(0, 255, 0);
public static readonly Color Blue = new(0, 0, 255);
public byte R;
public byte G;
public byte B;
public Color(byte r, byte g, byte b)
{
R = r;
G = g;
B = b;
}
}
可以使用readonly修饰符声明只读字段。
只能在字段声明期间或在同一个类的构造函数中向只读字段赋值。
方法
方法时实现对象或类可执行的计算或操作的成员。
静态方法时通过类进行访问。
实例方法是通过类实例进行访问
方法可能也包含一个参数列表,这些参数表示传递给方法的值或变量引用。
方法具有返回类型,它用于指定方法计算和返回的值的类型。
如果方法未返回值,则它的返回类型为void。
方法可能也包含一组类型参数,必须在调用方法时指定类型自变量,这一点与类型一样。
与类型不同的是,通常可以根据方法调用的自变量推断出类型自变量,无需显示指定。
在声明方法的类中,方法的签名必须是唯一的。
方法签名包含方法名称、类型参数数量以及参数的数量、修饰符和类型。
方法签名不包含返回类型。
当方法主体是单个表达式时,可使用紧凑表达式格式定义方法,如下例中所示:
public override string ToString() => "This is an object";
参数
参数用于将值或变量引用传递给方法。
方法参数从调用方法时指定的自变量中获取其实际值。
有四类参数:值参数、引用参数、输出参数和参数数组。
值参数用于传递输入自变量。
值参数对于局部变量,从为其传递的自变量中获取初始值。
修改值形参不会影响为其传递的实参。
可以指定默认值,从而省略相应的自变量,这样值参数就是可选的。
引用参数用于按引用传递自变量。
为引用参数传递的自变量必须是一个带有明确值的变量。
在方法执行期间,引用参数指出的存储位置与自变量相同。
引用参数使用ref修饰符进行声明。
下面的示例展示了如何使用ref参数。
static void Swap(ref int x, ref int y)
{
int temp = x;
x = y;
y = temp;
}
public static void SwapExample()
{
int i = 1, j = 2;
Swap(ref i, ref j);
Console.WriteLine($"{i} {j}"); // "2 1"
}
输出参数用于按引用传递自变量。
输出参数与引用参数类似,不同之处在于,不要求向调用方提供的自变量显示赋值。
输出参数使用out修饰符进行声明。
下面的示例展示了如何使用out参数。
static void Divide(int x, int y, out int quotient, out int remainder)
{
quotient = x / y;
remainder = x % y;
}
public static void OutUsage()
{
Divide(10, 3, out int quo, out int rem);
Console.WriteLine($"{quo} {rem}"); // "3 1"
}
参数数组允许向方法传递数量不定的自变量。
参数数组使用params修饰符进行声明。
参数数组只能是方法的最后一个参数,且参数数组的类型必须是一维数组类型。
System.Console类的Write和WriteLine方法是参数数组用法的典型示例。
它们的声明方式如下。
public class Console
{
public static void Write(string fmt, params object[] args) { }
public static void WriteLine(string fmt, params object[] args) { }
// ...
}
在使用参数数组的方法中,参数数组的行为与数组类型的常规参数完全相同。
不过,在调用包含形参数组的方法时,要么可以传递形参数组类型的一个实参,要么可以传递形参数组的元素类型的任意数量实参。
在后一种情况中,数组实例会自动创建,并初始化为包含给定的自变量。
以下示例:
int x, y, z;
x = 3;
y = 4;
z = 5;
Console.WriteLine("x={0} y={1} z={2}", x, y, z);
等同于编写以下代码:
int x = 3, y = 4, z = 5;
string s = "x={0} y={1} z={2}";
object[] args = new object[3];
args[0] = x;
args[1] = y;
args[2] = z;
Console.WriteLine(s, args);
方法主体和局部变量
方法主体指定了在调用方法时执行的语句。
方法主体可以声明特定于方法调用的变量。
此变量称为局部变量。
局部变量声明指定了类型名称、变量名称以及可能的初始值。
下面的示例声明了初始值为零的局部变量i和无初始值的局部变量j。
class Squares
{
public static void WriteSquares()
{
int i = 0;
int j;
while (i < 10)
{
j = i * i;
Console.WriteLine($"{i} x {i} = {j}");
i++;
}
}
}
C#要求必须报告先明确赋值局部变量,然后才能获取其值。
例如,如果上述i的声明未包含初始值,那么编译器会在后续使用i时报告错误,因为在后续使用时i不会在程序中得到明确赋值。
方法可以使用return语句将控制权返回给调用方。
在返回void的方法中,return语句无法指定表达式。
在不返回void的方法中,return语句必须包括用于计算返回值的表达式。
静态和实例方法
使用static修饰符声明的方法是静态方法。
静态方法不对特定的实例起作用,只能直接访问静态成员。
未使用static修饰符声明的方法时实例方法。
实例方法对特定的实例起作用,并能够访问静态和实例成员。其中调用实例方法的实例可以作为this显示访问。
在静态方法中引用this会生成错误。
以下Entity类包含静态和实例成员。
class Entity
{
static int s_nextSerialNo;
int _serialNo;
public Entity()
{
_serialNo = s_nextSerialNo++;
}
public int GetSerialNo()
{
return _serialNo;
}
public static int GetNextSerialNo()
{
return s_nextSerialNo;
}
public static void SetNextSerialNo(int value)
{
s_nextSerialNo = value;
}
}
每个Entity实例均有一个序列号(很可能包含此处未显示的其他一些信息)。
Entity构造函数(类似于实例方法)将新实例初始化为包含下一个可用的序列号。
由于构造函数是实例成员,因此可以访问_serialNo实例字段和s_nextSerialNo静态字段。
GetNextSerialNo和SetNextSerialNo静态方法可以访问s_nextSerialNo静态字段,但如果直接访问_serialNo实例字段,则会生成错误。
下例显示了Entity类的用法。
Entity.SetNextSerialNo(1000);
Entity e1 = new();
Entity e2 = new();
Console.WriteLine(e1.GetSerialNo()); // Outputs "1000"
Console.WriteLine(e2.GetSerialNo()); // Outputs "1001"
Console.WriteLine(Entity.GetNextSerialNo()); // Outputs "1002"
SetNextSerialNo和GetNextSerialNo静态方法在类中进行调用,而GetSerialNo实例方法则是在类实例中进行调用。
虚方法、重写方法和抽象方法
可使用虚方法、重写方法和抽象方法来定义类类型层次结构的行为。
由于类可从基类派生,因此这些派生类可能需要修改在基类中实现的行为。
虚方法是在基类中声明和实现的方法,其中任何派生类都可提供更具体的实现。
重写方法是在派生类中实现的方法,可修改基类实现的行为。
抽象方法时在基类中声明的方法,必须在所有派生类中重写。
事实上,抽象方法不在基类中定义实现。
对实例方法的方法调用可解析为基类或派生类实现。
变量的类型确定了其编译时类型。
编译时类型是编译器用于确定其成员的类型。
但是,可将变量分配给从其编译时类型派生的任何类型的实例。
运行时间类型是变量所引用的实际实例的类型。
调用虚方法时,为其调用方法的实例的运行时类型决定了要调用的实际方法实现代码。
调用非虚方法时,实例的编译时类型是决定性因素。
可以在派生类中重写虚方法。
如果实例方法声明中有override修饰符,那么实例方法可以重写签名相同的继承虚方法。
虚方法声明引入了新方法。
重写方法声明通过提供现有继承的虚方法的新实现,专门针对该方法。
抽象方法时没有实现代码的虚方法。
抽象方法使用abstract修饰符进行声明,仅可在抽象类中使用。
必须在所有非抽象派生类中重写抽象方法
下面的示例声明了一个抽象类Expression,用于表示表达式树节点;还声明了三个派生类(Constant、VariableReference和Operation),用于实现常量、变量引用和算术运算的表达式树节点。(该示例与表达式树类型相似,但与它无关)。
public abstract class Expression
{
public abstract double Evaluate(Dictionary<string, object> vars);
}
public class Constant : Expression
{
double _value;
public Constant(double value)
{
_value = value;
}
public override double Evaluate(Dictionary<string, object> vars)
{
return _value;
}
}
public class VariableReference : Expression
{
string _name;
public VariableReference(string name)
{
_name = name;
}
public override double Evaluate(Dictionary<string, object> vars)
{
object value = vars[_name] ?? throw new Exception($"Unknown variable: {_name}");
return Convert.ToDouble(value);
}
}
public class Operation : Expression
{
Expression _left;
char _op;
Expression _right;
public Operation(Expression left, char op, Expression right)
{
_left = left;
_op = op;
_right = right;
}
public override double Evaluate(Dictionary<string, object> vars)
{
double x = _left.Evaluate(vars);
double y = _right.Evaluate(vars);
switch (_op)
{
case '+': return x + y;
case '-': return x - y;
case '*': return x * y;
case '/': return x / y;
default: throw new Exception("Unknown operator");
}
}
}
上面的四个类可用于算术表达式建模。
例如,使用这些类的实例,可以按如下方式表示表达式x+3。
Expression e = new Operation(
new VariableReference("x"),
'+',
new Constant(3));
调用Expression实例的Evaluate方法可以计算给定的表达式并生成double值。
此方法需要使用自变量Dictionary,其中包含变量名称(作为项键)和值(作为项值)。
因为Evaluate是一个抽象方法,因此派生自Expression的非抽象类必须代替Evaluate。
Constanrt的Evaluate实现代码只返回存储的常量。
VariableReference实现代码查找字典中的变量名称,并返回结果值。
Operation实现代码先计算左右操作数(以递归方式调用其Evaluate方法),然后执行给定的算术运算。
以下程序使用Expression类根据不同的x和y值计算表达式 x *(y+2)。
Expression e = new Operation(
new VariableReference("x"),
'*',
new Operation(
new VariableReference("y"),
'+',
new Constant(2)
)
);
Dictionary<string, object> vars = new();
vars["x"] = 3;
vars["y"] = 5;
Console.WriteLine(e.Evaluate(vars)); // "21"
vars["x"] = 1.5;
vars["y"] = 9;
Console.WriteLine(e.Evaluate(vars)); // "16.5"
方法重载
借助方法重载,同一类中可以有多个同名的方法,只要这些方法具有唯一签名即可。
编译如何调用重载的方法时,编译器使用重载决策来确定要调用的特定方法。
重载决策会查找与自变量匹配度最高的一种方法。
如果找不到任何最佳匹配项,则会报告错误。
下面的示例展示了重载决策的实际工作方式。
UsageExample方法中每个调用的注释指名了调用的方法。
class OverloadingExample
{
static void F() => Console.WriteLine("F()");
static void F(object x) => Console.WriteLine("F(object)");
static void F(int x) => Console.WriteLine("F(int)");
static void F(double x) => Console.WriteLine("F(double)");
static void F<T>(T x) => Console.WriteLine($"F<T>(T), T is {typeof(T)}");
static void F(double x, double y) => Console.WriteLine("F(double, double)");
public static void UsageExample()
{
F(); // Invokes F()
F(1); // Invokes F(int)
F(1.0); // Invokes F(double)
F("abc"); // Invokes F<T>(T), T is System.String
F((double)1); // Invokes F(double)
F((object)1); // Invokes F(object)
F<int>(1); // Invokes F<T>(T), T is System.Int32
F(1, 1); // Invokes F(double, double)
}
}
如示例所示,可将自变量显示转换成确切的参数类型和类型自变量,随机选择特定的方法。
其他函数成员
包含可执行代码的成员统称为类的函数成员。
上一部分介绍了作为主要函数成员类型的方法。此部分将介绍C#支持的其他类型函数成员:
构造函数、属性、索引器、事件、运算符和终结器。
下面的示例展示了MyList<T>泛型类,用于实现对象的可扩充列表。此类包含最常见类型函数成员的多个示例。
public class MyList<T>
{
const int DefaultCapacity = 4;
T[] _items;
int _count;
public MyList(int capacity = DefaultCapacity)
{
_items = new T[capacity];
}
public int Count => _count;
public int Capacity
{
get => _items.Length;
set
{
if (value < _count) value = _count;
if (value != _items.Length)
{
T[] newItems = new T[value];
Array.Copy(_items, 0, newItems, 0, _count);
_items = newItems;
}
}
}
public T this[int index]
{
get => _items[index];
set
{
if (!object.Equals(_items[index], value)) {
_items[index] = value;
OnChanged();
}
}
}
public void Add(T item)
{
if (_count == Capacity) Capacity = _count * 2;
_items[_count] = item;
_count++;
OnChanged();
}
protected virtual void OnChanged() =>
Changed?.Invoke(this, EventArgs.Empty);
public override bool Equals(object other) =>
Equals(this, other as MyList<T>);
static bool Equals(MyList<T> a, MyList<T> b)
{
if (Object.ReferenceEquals(a, null)) return Object.ReferenceEquals(b, null);
if (Object.ReferenceEquals(b, null) || a._count != b._count)
return false;
for (int i = 0; i < a._count; i++)
{
if (!object.Equals(a._items[i], b._items[i]))
{
return false;
}
}
return true;
}
public event EventHandler Changed;
public static bool operator ==(MyList<T> a, MyList<T> b) =>
Equals(a, b);
public static bool operator !=(MyList<T> a, MyList<T> b) =>
!Equals(a, b);
}
构造函数
C#支持实例和静态构造函数。
实例构造函数是实现初始化类实例所需执行的操作的成员。
静态构造函数时实现在首次加载是初始化类本身所需执行的操作的成员。
构造函数的声明方式与方法一样,都没有返回类型,且与所含类同名。
如果构造函数声明包含static修饰符,则声明的是静态构造函数。
否则,声明的是实例构造函数。
实例构造函数可重载并且可具有可选参数。
例如,MyList<T>类声明一个具有单个可选int参数的实例构造函数。
实例构造函数使用new运算符进行调用。
下面的语句使用包含和不包含可选自变量的MyList类构造函数来分配两个MyList<string>实例。
MyList<string> list1 = new();
MyList<string> list2 = new(10);
与其他成员不同,实例构造函数不会被继承。
类中只能包含实际上已在该类声明的实例构造函数。如果没有为类提供实例构造函数,则会自动提供不含参数的空实例构造函数。
“属性”
属性是字段的自然扩展。
两者都是包含关联类型的已命名成员,用于访问字段和属性的语法也是一样的。
不过,与字段不同的是,属性不指名存储位置。
相反,属性包含访问器,用于指定在读取或写入属性值时执行的语句。
get访问器读取该值。
set访问器写入该值。
属性的声明方式与字段类似,区别是属性声明以在分隔符{和}之间写入的get访问器或set访问器结束,而不是以分号结束。
同时具有get访问器和set访问器的属性是“读写属性”。
只有get访问器的属性是“只读属性”。只有set访问器的属性是“只写属性”。
get访问器对应于包含属性类型的返回值的无参数方法。
set访问器对应于包含名为value的参数但不含返回类型的方法。
get访问器会计算属性的值。
set访问器会为属性提供新值。
当属性是赋值的目标,或者是++或--的操作数时,会调用set访问器。
在引用了属性的其他情况下,会调用get访问器。
MyList<T>类声明以下两个属性:Count和Capacity(分别为只读和读写)。
以下示例代码展示了如何使用这些属性:
MyList<string> names = new();
names.Capacity = 100; // Invokes set accessor
int i = names.Count; // Invokes get accessor
int j = names.Capacity; // Invokes get accessor
类似于字段和方法,C#支持实例属性和静态属性。静态属性使用静态修饰符进行声明,而实例属性则不使用静态修饰符进行声明。
属性的访问器可以是虚的。如果属性声明包含Virtual、abstract或override修饰符,则适用于属性的访问器。
索引器
借助索引器成员,可以将对象编入索引(像处理数组一样)。
索引器的声明方式与属性类似,不同之处在于,索引器成员名称格式为this后跟在分隔符[和]内写入的参数列表。
这些参数在索引器的访问中可用。
类似于属性,索引器分为读写、只读和只写索引器,且索引器的访问器可以是虚的。
MyList<T>类声明一个需要使用int参数的读写索引器。借助索引器,可以使用int值将MyList<T>实例编入索引。例如:
MyList<string> names = new();
names.Add("Liz");
names.Add("Martha");
names.Add("Beth");
for (int i = 0; i < names.Count; i++)
{
string s = names[i];
names[i] = s.ToUpper();
}
索引器可被重载。一个类可声明多个索引器,只要其参数的数量或类型不同即可。
事件
借助事件成员,类或对象可以提供通知。
事件的声明方式与字段类似,区别是事件声明包括event关键字,且类型必须是委托类型。
在声明事件成员的类中,事件的行为与委托类型的字段完全相同(前提是时间不是抽象的,且不声明访问器)。
字段存储对委托的引用,委托表示已添加到事件的时间处理程序。
如果没有任何事件处理程序,则字段为null。
MyList<T>类声明一个Changed事件成员,指明已向列表添加了新项,或者已使用索引器集访问器更改了列表项。
Changed事件由onChanged虚方法引发,此方法会先检查事件是否是null(即不含任何处理程序)。
引发事件的概念恰恰等同于调用由事件表示的委托。
不存在用于引发事件的特殊语言构造。
客户端通过事件处理程序相应事件。使用+=和-=运算符分别可以附加和删除事件处理程序。
下面的示例展示了如何向MyList<String>的Changed事件附加事件处理程序。
class EventExample
{
static int s_changeCount;
static void ListChanged(object sender, EventArgs e)
{
s_changeCount++;
}
public static void Usage()
{
var names = new MyList<string>();
names.Changed += new EventHandler(ListChanged);
names.Add("Liz");
names.Add("Martha");
names.Add("Beth");
Console.WriteLine(s_changeCount); // "3"
}
}
对于需要控制事件的基础存储的高级方案,事件声明可以显示提供add和remove访问器,这与属性的set访问器类似。
运算符
运算符是定义向类实例应用特定表达式运算符的含义的成员。
可以定义三种类型的运算符:一元运算符、二元运算符和转换运算符。
所有运算符都必须声明public和static。
MyList<T>类会声明两个运算符:operator ==和operator !=。
对于向MyList实例应用这些运算符的表达式来说,这些重写的运算符向它们赋予了新的含义。
具体而言,这些运算符定义的是两个MyList<T>实例的相等性(使用其Equals方法比较所包含的每个对象)。下面的示例展示了如何使用==运算符比较两个MyList<int>实例。
MyList<int> a = new();
a.Add(1);
a.Add(2);
MyList<int> b = new();
b.Add(1);
b.Add(2);
Console.WriteLine(a == b); // Outputs "True"
b.Add(3);
Console.WriteLine(a == b); // Outputs "False"
第一个Console.WriteLine输出True,因为两个列表包含的对象不仅数量相同,而且值和顺序也相同。如果MyList<T>未定义operator ==,那么第一个Console.WriteLine会输出False,因为a和b引用不同的MyList<int>实例。
终结器
终结器是实现完成类实例所需的操作的成员。
通常,需要使用终结器来释放非托管资源。
终结器既不能包含参数和可访问性修饰符,也不能进行显示调用。
实例的终结器在垃圾回收期间自动调用。
有关详细信息,请参阅有关终结器的文章。
垃圾回收器在决定何时收集对象和运行终结器时有很大自由度。
具体而言,终结器的调用时间具有不确定性,可以在任意线程上执行终结器。
因为这样或那样的原因,只有在没有其他可行的解决方案时,类才能实现终结器。
处理对象析构的更好方法是使用using语句。
表达式
表达式是在操作数和运算符的基础之上构造而成。
表达式的运算符指明了向操作数应用的运算。
运算符的示例包括+
、-
、*
、/
和new。
操作数的示例包括文本、字段、局部变量和表达式。
如果某个表达式包含多个运算符,则运算符的优先顺序控制各个运算符的计算顺序。
例如,表达式x+y*z相当于计算x+(y*z),因为*运算符的优先级高于+运算符。
如果操作数两把的两个运算符的优先级相同,那么运算符的结合性决定了运算的执行顺序;
除了赋值运算符和null合并运算符之外,所有二元运算符均为左结合运算符,即从左向右执行运算。例如,x+y+z将计算为(x+y)+z。
赋值运算符、null合并??和??=运算符和条件运算符?:为右结合运算符,即从右向左执行运算。例如,x=y=z将计算为x=(y=z)。
可以使用括号控制优先级和结合性。例如,x+y*z先计算y乘z,并将结果与x想加,(x+y)*z则计算x加y,然后将结果与z相乘。
大部分运算符可重载。借助运算符重载,可以为一个或两个操作数为用户定义类或结构类型的运算指定用户定义运算符实现代码。
C#提供了运算符,用于执行算术、逻辑、按位和移位运算以及相等和排序比较。
要了解按优先级排序的完整C#运算符列表,请参阅C#运算符。
语句
程序操作使用语句进行表示。
C#支持几种不同的语句,其中许多语句是从嵌入语句的角度来定义的。
使用代码块,可以在允许编写一个语句的上下文中编写多个语句。代码块是由一系列在分隔符{和}内编写的语句组成。
声明语句用于声明局部变量和常量。
表达式语句用于计算表达式。可用于语句的表达式包括方法调用、使用new运算符的对象分配、使用=和复合赋值运算符的赋值、使用++和--运算符和await表达式的递增和递减运算。
选择语句用于根据一些表达式的值从多个可能的语句中选择一个以供执行。此组包含if和switch语句。
迭代语句用于重复执行嵌入语句。此组包含while、do、for和foreach语句
跳转语句用于转译控制权。此组包含break、continue、goto、throw、return和yield语句。
try..catch语句用于捕获在代码执行期间发生的异常,try...finally语句用于指定始终执行的最终代码,无论异常发生与否。
checked和unchecked语句用于控制整型类型算术运算和转换的一处检查上下文。
lock语句用于获取给定对象的相互排斥锁定,执行语句,然后解除锁定。
using语句用于获取资源,执行语句,然后释放资源。
下面列出了可使用的语句类型:
局部变量声明。
局部常量声明。
表达式语句。
if语句。
switch语句。
while语句。
do语句。
for语句。
foreach语句。
break语句。
continue语句。
goto语句。
return语句。
yield语句。
return语句。
yield语句。
throw和try语句。
checked和unchecked语句。
lock语句。
using语句。