C# 基础、核心概念和模式交互式指南(二)
五、接口:面向对象的艺术
接口介绍
教师开始讨论:接口是 C# 中的一种特殊类型。一个接口只包含定义一些规范的方法签名。子类型需要遵循这些规范。当你使用一个接口时,你会发现它和一个抽象类有很多相似之处。
通过接口,我们声明了我们试图实现的内容,但是我们没有指定如何实现它。也可能出现接口类似于不包含任何实例变量的类。他们所有的方法都是在没有主体的情况下声明的(也就是说,方法实际上是抽象的)。关键字 interface 用于声明接口类型;它前面是您想要的接口名称。
Points to Remember
-
简单地说,接口帮助我们将“什么部分”和“如何部分”分开
-
要声明它们,请使用 interface 关键字。
-
接口方法没有主体。我们简单地用分号替换主体,就像这样:
void Show(); -
接口方法没有附加访问修饰符。
-
建议您在接口名称前面加上大写字母 I,例如
interface I MyInterface{..}
借助于接口,我们可以在运行时支持动态方法解析。一旦定义,一个类可以实现任意数量的接口。像往常一样,让我们从一个简单的例子开始。
演示 1
using System;
namespace InterfaceEx1
{
interface IMyInterface
{
void Show();
}
class MyClass : IMyInterface
{
public void Show()
{
Console.WriteLine("MyClass.Show() is implemented.");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Interfaces.Example-1***\n");
MyClass myClassOb = new MyClass();
myClassOb.Show();
Console.ReadKey();
}
}
}
输出
Points to Remember
如果我们试图实现一个接口,我们需要匹配方法签名。
学生问:
先生,如果这些方法不完整,那么使用接口的类需要实现接口中的所有方法。这是正确的吗?
老师说:正是。如果类不能全部实现,它会通过将自己标记为抽象来宣布它的不完整性。下面的例子将帮助你更好地理解这一点。
这里,我们的接口有两个方法。但是一个类只实现了一个。所以,类本身变得抽象了。
interface IMyInterface
{
void Show1();
void Show2();
}
//MyClass becomes abstract. It has not implemented Show2() of //IMyInterface
abstract class MyClass2 : IMyInterface
{
public void Show1()
{
Console.WriteLine("MyClass.Show1() is implemented.");
}
public abstract void Show2();
}
分析
公式是一样的:一个类需要实现接口中定义的所有方法;否则,它就是一个抽象类。
如果你忘记实现Show2()并且没有用abstract keyword标记你的类,如下…
编译器将引发以下错误。
学生问:
先生,在这种情况下,MyClass2 的一个子类只需实现 Show2()就可以完成任务。这是正确的吗?
老师说:是的。“演示 2”是一个完整的例子。
演示 2
using System;
namespace InterfaceEx2
{
interface IMyInterface
{
void Show1();
void Show2();
}
//MyClass becomes abstract. It has not implemented Show2() of IMyInterface
abstract class MyClass2 : IMyInterface
{
public void Show1()
{
Console.WriteLine("MyClass.Show1() is implemented.");
}
public abstract void Show2();
}
class ChildClass : MyClass2
{
public override void Show2()
{
Console.WriteLine("Child is completing -Show2() .");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Interfaces.Example-2***\n");
//MyClass is abstract now
//MyClass myClassOb = new MyClass();
MyClass2 myOb = new ChildClass();
myOb.Show1();
myOb.Show2();
Console.ReadKey();
}
}
}
输出
学生问:
先生,您之前说过接口可以帮助我们实现多重继承的概念。我们的类可以实现两个或者更多的接口吗?
老师说:是的。下面的例子向您展示了如何做到这一点。
演示 3
using System;
namespace InterfaceEx3
{
interface IMyInterface3A
{
void Show3A();
}
interface IMyInterface3B
{
void Show3B();
}
class MyClass3 :IMyInterface3A, IMyInterface3B
{
public void Show3A()
{
Console.WriteLine("MyClass3 .Show3A() is completed.");
}
public void Show3B()
{
Console.WriteLine("MyClass3 .Show3B() is completed.");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Interfaces.Example-3***\n");
MyClass3 myClassOb = new MyClass3();
myClassOb.Show3A();
myClassOb.Show3B();
Console.ReadKey();
}
}
输出
学生问:
在前面的程序中,方法名称在接口中是不同的。但是如果两个接口包含相同的方法名,我们如何实现它们呢?
老师说:很好的问题。我们需要使用显式接口实现的概念。在显式接口实现中,方法名前面是接口名,比如。methodname (){…}。让我们看一下下面的实现。
演示 4
using System;
namespace InterfaceEx4
{
//Note: Both of the interfaces have the same method name //"Show()".
interface IMyInterface4A
{
void Show();
}
interface IMyInterface4B
{
void Show();
}
class MyClass4 : IMyInterface4A, IMyInterface4B
{
public void Show()
{
Console.WriteLine("MyClass4 .Show() is completed.");
}
void IMyInterface4A.Show()
{
Console.WriteLine("Explicit interface Implementation.IMyInterface4A .Show().");
}
void IMyInterface4B.Show()
{
Console.WriteLine("Explicit interface Implementation.IMyInterface4B .Show().");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Interfaces.Example-4***\n");
//All the 3 ways of callings are fine.
MyClass4 myClassOb = new MyClass4();
myClassOb.Show();
IMyInterface4A inter4A = myClassOb;
inter4A.Show();
IMyInterface4B inter4B = myClassOb;
inter4B.Show();
Console.ReadKey();
}
}
输出
Points to Remember
-
我们必须注意一个有趣的事实。当我们显式地实现接口方法时,我们不会将关键字 public 附加到它们上面。但是在隐式实现中,这是必要的。
-
根据 MSDN 的说法:“显式接口成员实现包含访问修饰符是编译时错误,包含修饰符抽象、虚拟、重写或静态是编译时错误。”
-
如果一个类(或结构)实现一个接口,那么它的实例隐式转换为接口类型。这就是为什么我们可以毫无错误地使用下面的行:
IMyInterface4A inter4A = myClassOb;或
IMyInterface4B inter4B = myClassOb;
在此示例中,myClassOb 是 MyClass4 类的一个实例,它实现了两个接口—IMyInterface4A 和 IMyInterface4B。
学生问:
一个接口可以继承或实现另一个接口吗?
老师说:可以继承但不能实现(按定义)。考虑下面的例子。
演示 5
using System;
namespace InterfaceEx5
{
interface Interface5A
{
void ShowInterface5A();
}
interface Interface5B
{
void ShowInterface5B();
}
//Interface implementing multiple inheritance
interface Interface5C :Interface5A, Interface5B
{
void ShowInterface5C();
}
class MyClass5 : Interface5C
{
public void ShowInterface5A()
{
Console.WriteLine("ShowInterface5A() is completed.");
}
public void ShowInterface5B()
{
Console.WriteLine("ShowInterface5B() is completed.");
}
public void ShowInterface5C()
{
Console.WriteLine("ShowInterface5C() is completed.");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Interfaces.Example-5***");
Console.WriteLine("***Concept of multiple inheritance through
interface***\n");
MyClass5 myClassOb = new MyClass5();
Interface5A ob5A = myClassOb;
ob5A.ShowInterface5A();
Interface5B ob5B = myClassOb;
ob5B.ShowInterface5B();
Interface5C ob5C = myClassOb;
ob5C.ShowInterface5C();
Console.ReadKey();
}
}
}
输出
恶作剧
预测产量。
using System;
namespace InterfaceEx6
{
interface Interface6
{
void ShowInterface6();
}
class MyClass6 : Interface6
{
void Interface6.ShowInterface6()
{
Console.WriteLine("ShowInterface6() is completed.");
}
}
class Program
{
static void Main(string[] args)
{
MyClass6 myClassOb = new MyClass6();
myClassOb.ShowInterface6();//Error
//Interface6 ob6 = myClassOb;
//ob6.ShowInterface6();
Console.ReadKey();
}
}
}
输出
存在编译错误。
分析
您可以看到,我们已经显式地实现了接口。根据语言规范,要访问显式接口成员,我们需要使用接口类型。要克服这个错误,您可以使用以下代码行(即,取消前面显示的两行的注释):
Interface6 ob6 = myClassOb;
ob6.ShowInterface6();
然后,您将获得以下输出:
或者,您可以使用以下代码行获得相同的输出:
((Interface6)myClassOb).ShowInterface6();
学生问:
我们可以从一个类扩展,同时实现一个接口吗?
老师说:是的。你总是可以从一个类扩展(只要它不是密封的或者没有其他类似的约束)。在这种情况下,建议您使用位置符号。首先定位父类,然后是逗号,最后是接口名称,如下所示:
Class ChildClass: BaseClass,IMyinterface{...}
学生问:
为什么我们需要显式接口方法?
老师说:如果你仔细观察,你会发现显式接口的真正威力是当我们在两个或更多不同的接口中有相同的方法签名时。虽然他们的签名相同,但目的可能不同;例如,如果我们有两个接口——ITriangle 和 I rectangle——并且都包含一个具有相同签名的方法(例如,BuildMe()),您可以假设 I triangle 中的BuildMe()可能想要构建一个三角形;而 IRectangle 中的BuildMe()可能想要构建一个矩形。因此,希望您能够根据情况调用适当的BuildMe()方法。
标签/标记/标记界面
老师继续说:一个空的接口被称为标签/标记/标记接口。
//Marker interface example
interface IMarkerInterface
{
}
学生问:
先生,为什么我们需要一个标记界面?
老师说:
- 我们可以创造一个共同的父母。(值类型不能从其他值类型继承,但可以实现接口。我们将很快了解值类型)。
- 如果一个类(或一个结构)实现了一个接口,那么它的实例将隐式转换为接口类型。如果一个类实现了一个标记接口,就不需要定义一个新的方法(因为接口本身没有任何这样的方法)。
- 我们可以使用带有标记接口的扩展方法来克服程序中的一些挑战。
Note
MSDN 建议你不要使用标记接口。他们鼓励你使用属性的概念。属性和扩展方法的详细讨论超出了本书的范围。
老师问:
你能告诉我抽象类和接口的区别吗?
学生说:
- 抽象类可以完全实现,也可以部分实现;也就是说,在抽象类中,我们可以有具体的方法,但是接口不能有。接口包含行为的契约。(虽然在 Java 中,这个定义略有修改。从 Java 8 开始,我们可以在接口中使用默认关键字来提供方法的默认实现。
- 一个抽象类只能有一个父类(它可以从另一个抽象类或具体类扩展而来)。一个接口可以有多个父接口。一个接口只能从其他接口扩展。
- 默认情况下,接口的方法是公共的。抽象类可以有其他风格(例如,私有的、受保护的等等。).
- 在 C# 中,接口中不允许有字段。抽象类可以有字段(静态的和非静态的,有不同种类的修饰符)。
所以,如果你写了这样的东西:
interface IMyInterface
{
int i;//Error:Cannot contain fields
}
您将收到一个编译器错误。
但是,下面的代码没有问题:
abstract class MyAbstractClass
{
public static int i=10;
internal int j=45;
}
学生问:
先生,我们如何决定我们应该使用抽象类还是接口呢?
老师说:好问题。我相信如果我们想要集中的或者默认的行为,抽象类是一个更好的选择。在这些情况下,我们可以提供默认实现,它在所有子类中都可用。另一方面,接口实现从零开始。它们指明了某种关于要做什么的规则/契约(例如,您必须实现该方法),但是它们不会强制您执行该方法的哪一部分。此外,当我们试图实现多重继承的概念时,接口是首选。
但与此同时,如果我们需要向一个接口添加一个新方法,那么我们需要跟踪该接口的所有实现,并且我们需要将该方法的具体实现放在所有这些地方。前面是一个抽象类。我们可以在具有默认实现的抽象类中添加一个新方法,我们现有的代码将会顺利运行。
MSDN 提供以下建议:(可以参考这个在线讨论: https://stackoverflow.com/questions/20193091/recommendations-for-abstract-classes-vs-interfaces )
- 如果您希望创建组件的多个版本,请创建一个抽象类。抽象类为组件版本化提供了一种简单易行的方法。通过更新基类,所有继承类都会随着更改而自动更新。另一方面,接口一旦创建就不能更改。如果需要接口的新版本,您必须创建一个全新的接口。
- 如果您正在创建的功能将对各种不同的对象有用,请使用接口。抽象类应该主要用于密切相关的对象;而接口最适合为不相关的类提供公共功能。
- 如果你正在设计小而简洁的功能,使用接口。如果您正在设计大型功能单元,请使用抽象类。
- 如果希望在组件的所有实现中提供通用的已实现功能,请使用抽象类。抽象类允许您部分实现您的类;而接口不包含任何成员的实现。
学生问:
先生,我们能把接口密封起来吗?
老师说:实现一个接口的责任完全留给了开发者。那么,如果你把接口密封了,那么谁来实现那个接口的不完整的方法呢?基本上,你试图同时实现两个相反的构造。
在下面的声明中,Visual Studio IDE 会引发错误。
演示 6
using System;
namespace Test_Interface
{
sealed interface IMyInterface
{
void Show();
}
class Program
{
static void Main(string[] args)
{
//some code
}
}
}
输出
学生问:
先生,我们可以在接口方法前使用关键字“抽象”吗?
老师说:有必要这样做吗?微软明确声明接口不能包含方法的实现;也就是说,它们是抽象的。在 Visual Studio IDE 中,如果您编写如下代码,您将会看到一个编译时错误:
演示 7
interface IMyInterface
{
abstract void Show();
}
输出
现在从前面的示例中删除关键字 abstract,构建您的程序,然后打开 ILcode。你可以看到它已经被标记为虚拟和抽象。
学生问:
先生,我们知道界面非常强大。但与此同时,我们也看到了许多与之相关的限制。您能总结一下与界面相关的主要限制吗?
老师说:以下是其中的一些,不包括最基本的。
我们不能在接口中定义任何字段、构造函数或析构函数。此外,您不应该使用访问修饰符,因为它们隐含地是公共的。
不允许嵌套类型(例如,类、接口、枚举和结构)。所以,如果你像这样写代码:
interface IMyInterface
{
void Show();
class A { }
}
编译器会报错,如下所示:
不允许接口从类或结构继承,但它可以从另一个接口继承。所以,如果你像这样写代码:
class A { }
interface IB : A { }
编译器会报错,如下所示:
学生问:
先生,您能总结一下使用界面的好处吗?
老师说:在很多情况下,界面是非常有用的,比如在下面:
- 当我们试图实现多态时
- 当我们试图实现多重继承的概念时
- 当我们试图开发松散耦合的系统时
- 当我们试图支持平行发展时
学生问:
先生,为什么我们需要这样的限制,“一个接口不能从一个类继承”?
老师说:一个类或结构可以有一些实现。所以,如果我们允许一个接口从它们继承,接口可能包含实现,这违背了接口的核心目标。
摘要
本章回答了以下问题:
- 什么是接口?
- 你如何设计一个界面?
- 接口的基本特征是什么?
- 如何实现多个接口?
- 如何处理拥有同名方法的接口?
- 有哪些不同类型的接口?
- 你如何处理显式接口技术?
- 为什么我们需要显式接口方法?
- 什么是标记接口?
- 抽象类和接口的区别是什么?
- 我们如何决定我们应该使用抽象类还是接口?
- 与接口相关的主要限制是什么?
六、将属性和索引器用于封装
属性概述
教师开始讨论:我们已经知道封装是面向对象编程的关键特征之一。在 C# 中,属性非常重要,因为它们有助于封装对象状态。属性是提供灵活机制来读取、写入或计算私有字段值的成员。最初,属性可能看起来类似于字段,但实际上它们要么附加了 get,要么附加了 set,或者同时附加了这两个块。这些特殊的块/方法被称为访问器。简单地说,get 块用于读取目的,set 块用于分配目的。
在下面的代码中,我们将研究如何获得对获取或设置私有成员值的完全控制。除了这种类型的控制和灵活性,我们还可以对属性施加一些约束,这些特征使它们在本质上是独一无二的。
演示 1
using System;
namespace PropertiesEx1
{
class MyClass
{
private int myInt; // also called private "backing" field
public int MyInt // The public property
{
get
{
return myInt;
}
set
{
myInt = value;
}
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Properties.Example-1***");
MyClass ob = new MyClass();
//ob.myInt = 10;//Error: myInt is inaccessible
//Setting a new value
ob.MyInt = 10;//Ok.We'll get 10
//Reading the value
Console.WriteLine("\nValue of myInt is now:{0}", ob.MyInt);
//Setting another value to myInt through MyInt
ob.MyInt = 100;
Console.WriteLine("Now myInt value is:{0}", ob.MyInt);//100
Console.ReadKey();
}
}
}
输出
分析
如果使用ob.myInt=10;,编译器将会引发一个问题,如下所示:
但是,您可以看到,使用 myInt 属性,我们可以完全控制获取或设置私有字段 MyInt。
- 请注意命名约定:为了更好的可读性和理解,我们只是将私有字段名称的开头字母替换为相应的大写字母(在本例中,myInt 的 M 替换为 M)。
- 注意上下文关键字值。它是与属性相关联的隐式参数。我们通常用它来做作业。
- 有时,存储由公共属性公开的数据的私有字段被称为后备存储或后备字段。因此,myInt 是前面示例中的私有支持字段。
- 对于属性,可以使用以下任何修饰符:public、private、internal、protected、new、virtual、abstract、override、sealed、static、unsafe 和 extern。
学生问:
主席先生,我们如何透过物业来施加约束/限制?
老师说:假设您想要一个约束,如果想要的值在 10 到 25 之间,用户可以设置一个值(在前面的程序中)。否则,系统将保留以前的值。这种类型的约束可以通过属性轻松实现。在这种情况下,要实现此约束,我们可以按如下方式修改 set 块:
set
{
//myInt = value;
/*Imposing a condition:
value should be in between 10 and 25.
Otherwise, you'll retain the old value*/
if ((value >= 10) && (value <= 25))
{
myInt = value;
}
else
{
Console.WriteLine("The new value {0} cannot be set", value);
Console.WriteLine("Please choose a value between 10 and 25");
}
}
现在,如果您再次运行该程序,您会收到以下输出:
老师继续说:当我们处理一个只有一个访问器的属性时,我们称之为只读属性。
以下是只读属性:
......
private int myInt;
public int MyInt
{
get
{
return myInt;
}
//set accessor is absent here
}
只设置了访问器的属性称为只写属性。以下是只写属性的示例:
......
private int myInt;
public int MyInt
{
//get accessor is absent here
set
{
myInt = value;
}
}
通常,我们有两个访问器,这些属性被称为读写属性。在演示 1 中,我们使用了读写属性。
从 C# 3.0 开始,我们可以减少与属性相关的代码长度。考虑以下代码:
//private int myInt;
public int MyInt
{
//get
//{
// return myInt;
//}
//set
//{
// myInt = value;
//}
get;set;
}
我们用一行代码替换了演示 1 中使用的九行代码。这种声明被称为自动属性声明。在这种情况下,编译器将为我们输入预期的代码,以使我们的生活更加轻松。
减少代码大小
假设您有一个只读属性,如下所示:
class MyClass
{
private double radius = 10;
public double Radius
{
get { return radius; }
}
}
您可以通过使用表达式主体属性(在 C# 6.0 中引入)来减少代码大小,如下所示:
class MyClass
{
private double radius = 10;
//Expression bodied properties (C#6.0 onwards)
public double Radius => radius;
}
您可以看到,两个大括号和关键字 get 和 return 被替换为符号'=>'。
如果您打开 IL 代码,您会看到这些属性访问器在内部被转换为get_MethodName()和set_MethodName()。您可以检查它们的返回类型;例如,在这里我们得到的方法有
public int get_MyInt() {...}
和
public void set_MyInt(){...}
考虑下面的代码和相应的 IL 代码。
演示 2
using System;
namespace Test4_Property
{
class MyClass
{
//private int myInt;
public int MyInt
{
//automatic property declaration
get;set;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Properties.Example-1***");
MyClass ob = new MyClass();
//ob.myInt = 1;//Error:myInt is inaccessible
//Setting a new value
ob.MyInt = 106;//Ok.We'll get 106
//Reading the value
Console.WriteLine("\nValue of myInt is now:" + ob.MyInt);
Console.ReadKey();
}
}
}
密码是什么
Points to Remember
从 C# 6 开始,我们可以使用如下的属性初始化器:
public int MyInt2
{
//automatic property declaration
get; set;
} = 25;//Automatic initialization
这意味着 MyInt2 是用值 25 初始化的。我们也可以通过移除 set 访问器来使它成为只读的。
在 C# 7.0 中,我们可以进一步减少代码,如下所示:
您必须检查编译器版本是否设置为版本 7。如果您的编译器设置为 C#6.0 或更低版本,对于该代码块,您将得到以下错误:
在撰写本文时,我不需要做任何更改,因为对我来说,C# 7.0 是默认设置。
要检查您的语言版本,您可以转到项目属性,然后构建,然后高级构建设置,然后语言版本。也可以参考下面的截图,供大家参考。
学生问:
先生,似乎如果我们有一个 set 访问器,我们可以使它成为私有的,在这种情况下,它的行为就像一个只读属性。这是正确的吗?
老师说:是的。即使您使它受到保护,也意味着您不希望将它公开给其他类型。
学生问:
主席先生,为什么我们要选择公共财产而不是公共领域?
老师说:用这种方法,你可以促进封装,这是面向对象的关键特性之一。
学生问:
先生,我们什么时候应该使用只读属性?
老师说:创建不可变类型。
虚拟财产
老师继续说:我们之前说过,我们可以用不同类型的修饰符创建不同类型的属性。我们在这里挑选了其中的两个。考虑下面的代码。
演示 3
using System;
namespace VirtualPropertyEx1
{
class Shape
{
public virtual double Area
{
get
{
return 0;
}
}
}
class Circle : Shape
{
int radius;
public Circle(int radius)
{
this.radius = radius;
}
public int Radius
{
get
{
return radius;
}
}
public override double Area
{
get
{
return 3.14 * radius * radius;
}
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Case study with a virtual Property***");
Circle myCircle = new Circle(10);
Console.WriteLine("\nRadius of the Cricle is {0} Unit", myCircle.Radius);
Console.WriteLine("Area of the Circle is {0} sq. Unit",myCircle.Area);
Console.ReadKey();
}
}
}
输出
抽象属性
如果用以下代码替换 VirtualPropertyEx1 中的 Shape 类:
abstract class
Shape
{
public abstract double Area
{
get;
}
}
再次运行这个程序,你会得到同样的输出。但是这次你使用了一个抽象属性。
Points to Remember
我们已经使用了继承修饰符,例如 abstract、virtual 和 override,以及属性的例子。我们也可以使用其他继承修饰符,比如 new 和 sealed。
除此之外,属性可以与所有的访问修饰符相关联(公共的、私有的、受保护的和内部的);静态修饰符(static);和非托管代码修饰符(不安全的、外部的)。
恶作剧
你能预测产量吗?
using System;
namespace QuizOnPrivateSetProperty
{
class MyClass
{
private double radius = 10;
public double Radius => radius;
public double Area => 3.14 * radius * radius;
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Quiz on Properties***");
MyClass ob = new MyClass();
Console.WriteLine("Area of the circle is {0} sq. unit", ob.Area);
Console.ReadKey();
}
}
}
输出
分析
你可以看到,这里我们使用了表达式体属性(从 C# 6.0 开始可用)。
恶作剧
你能预测产量吗?
using System;
namespace QuizOnPrivateSet
{
class MyClass
{
private double radius = 10;
public double Radius
{
get
{
return radius;
}
private set
{
radius = value;
}
}
public double Area => 3.14 * radius * radius;
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Quiz on Properties***");
MyClass ob = new MyClass();
ob.Radius = 5;
Console.WriteLine("Radius of the circle {0} unit", ob.Radius);
Console.WriteLine("Area of the circle is {0} sq. unit", ob.Area);
Console.ReadKey();
}
}
}
输出
分析
请注意,set 访问器前面有关键字 private。
索引器
考虑下面的程序和输出。
演示 4
using System;
namespace IndexerEx1
{
class Program
{
class MySentence
{
string[] wordsArray;
public MySentence( string mySentence)
{
wordsArray = mySentence.Split();
}
public string this[int index]
{
get
{
return wordsArray[index];
}
set
{
wordsArray[index] = value;
}
}
}
static void Main(string[] args)
{
Console.WriteLine("***Exploring Indexers.Example-1***\n");
string mySentence = "This is a nice day.";
MySentence sentenceObject = new MySentence(mySentence);
for (int i = 0; i < mySentence.Split().Length; i++)
{
Console.WriteLine("\t sentenceObject[{0}]={1}",i,sentenceObject[i]);
}
Console.ReadKey();
}
}
}
输出
分析
我们看到了这个项目的一些有趣的特点。
- 程序类似于 properties,但关键区别在于 property 的名字是这个。
- 我们像数组一样使用索引参数。这些被称为索引器。我们可以把一个类或结构或接口的实例看作数组。this 关键字用于引用实例。
Points to Remember
-
所有的修饰符——私有的、公共的、受保护的、内部的——都可以用于索引器(就像属性一样)。
-
返回类型可以是任何有效的 C# 数据类型。
-
我们可以创建一个有多个索引器的类型,每个索引器有不同类型的参数。
-
通常,我们可以通过消除 set 访问器来创建只读索引器。尽管它在语法上是正确的,但是建议您在这些场景中使用方法(例如,使用方法来检索与雇员 ID 相对应的雇员信息总是好的)。所以,你应该避免这样:
//NOT a recommended style Class Employee{ //using indexers to get employee details public string this[int empId] { get { //return Employee details } } }
老师继续说:让我们来看另一个演示。在下面的程序中,我们使用了一个字典来保存一些雇员的名字和他们的薪水。然后我们就在想办法看他们工资的上限。如果在我们的字典中找不到员工,我们会说没有找到记录。
要使用字典类,我们需要在程序中包含下面一行,因为该类是在那里定义的:
using System.Collections.Generic;
字典是一个集合和一对。它使用散列表数据结构来存储一个键及其相应的值。该机制非常快速和高效。建议你多了解字典。你应该首先明白
employeeWithSalary = new Dictionary<string, double>();
employeeWithSalary.Add("Amit",20125.87);
在前面两行代码中,我们创建了一个字典,然后使用了它的Add方法。在这个字典中,每当我们想要添加数据时,第一个参数应该是字符串,第二个应该是双精度。因此,我们可以添加一个 Employee Amit(一个字符串变量)和 salary(一个 double 变量)作为字典元素。对于字典中的其余元素,我们遵循相同的过程。浏览程序,然后分析输出。
演示 5
using System;
using System.Collections.Generic;
namespace IndexerQuiz1
{
class EmployeeRecord
{
Dictionary<string, double> employeeWithSalary;
public EmployeeRecord()
{
employeeWithSalary = new Dictionary<string, double>();
employeeWithSalary.Add("Amit",20125.87);
employeeWithSalary.Add("Sam",56785.21);
employeeWithSalary.Add("Rohit",33785.21);
}
public bool this[string index, int predictedSalary]
{
get
{
double salary = 0.0;
bool foundEmployee = false;
bool prediction = false;
foreach (string s in employeeWithSalary.Keys)
{
if (s.Equals(index))
{
foundEmployee = true;//Employee found
salary = employeeWithSalary[s];//Employees
//actual salary
if( salary>predictedSalary)
{
//Some code
prediction = true;
}
else
{
//Some code
}
break;
}
}
if(foundEmployee == false)
{
Console.WriteLine("Employee {0} Not found in our database.", index);
}
return prediction;
}
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Quiz on Indexers***\n");
EmployeeRecord employeeSalary = new EmployeeRecord();
Console.WriteLine("Is Rohit's salary is more than 25000$ ?- {0}", employeeSalary["Rohit",25000]);//True
Console.WriteLine("Is Amit's salary is more than 25000$ ?- {0}", employeeSalary["Amit",25000]);//False
Console.WriteLine("Is Jason's salary is more than 10000$ ?-{0}", employeeSalary["Jason",10000]);//False
Console.ReadKey();
}
}
}
输出
分析
我介绍这个程序是为了展示我们可以使用不同类型参数的索引器。
学生问:
先生,我们可以在同一个类中有多个索引器吗?
老师说:是的。但是在这种情况下,方法签名必须彼此不同。
学生问:
先生,那么索引器可以重载吗?
老师说:是的。
学生问:
先生,到目前为止,索引器看起来像数组。数组和索引器有什么区别?
老师说:是的。甚至有时开发人员将索引器描述为虚拟数组。但是这里有一些关键的区别:
- 索引器可以接受非数字下标。我们已经测试过了。
- 索引器可以重载,但数组不能。
- 索引器值不属于变量。因此,它们不能用作 ref 或 out 参数,而数组可以。
接口索引器
学生问:
先生,我们如何使用带接口的索引器?
老师说:接下来是一个例子,我们用索引器隐式实现了一个接口。在我们继续之前,我们需要记住接口索引器和类索引器之间的关键区别。
- 接口索引器没有主体。(注意,在下面的演示中,get 和 set 只带有分号。)
- 接口索引器没有修饰符。
演示 6
using System;
namespace IndexerEx2
{
interface IMyInterface
{
int this[int index] { get; set; }
}
class MyClass : IMyInterface
{
//private int[] myIntegerArray;
private int[] myIntegerArray = new int[4];
public int this[int index]
{
get
{
return myIntegerArray[index];
}
set
{
myIntegerArray[index] = value;
}
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Indexers with interfaces***\n");
MyClass obMyClass = new MyClass();
//Initializing 0th, 1st and 3rd element using indexers
obMyClass[0] = 10;
obMyClass[1] = 20;
obMyClass[3] = 30;
for (int i = 0; i <4; i++)
{
// Console.WriteLine("\t obMyClass[{0}]={1}", i, obMyClass[i]);
System.Console.WriteLine("Element #{0} = {1}", i, obMyClass[i]);
}
Console.ReadKey();
}
}
}
输出
恶作剧
代码会编译吗?
using System;
namespace IndexerQuiz2
{
interface IMyInterface
{
int this[int index] { get; set; }
}
class MyClass : IMyInterface
{
private int[] myIntegerArray = new int[4];
//Explicit interface implementation
int IMyInterface.this[int index]
{
get => myIntegerArray[index];
set => myIntegerArray[index] = value;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Quiz on Indexers with explicit interface technique***\n");
MyClass obMyClass = new MyClass();
IMyInterface interOb = (IMyInterface)obMyClass;
//Initializing 0th, 1st and 3rd element using indexers
interOb[0] = 20;
interOb[1] = 21;
interOb[3] = 23;
for (int i = 0; i < 4; i++)
{
Console.WriteLine("\t obMyClass[{0}]={1}", i,interOb[i]);
}
Console.ReadKey();
}
}
}
回答
是的。以下是输出:
分析
这是一个我们用索引器显式实现接口的例子。在这个例子中,我们使用了最新的 C# 7.0 特性(注意 get、set 主体)。
我们正在接触元素。
索引器的显式实现是非公共的(也是非虚拟的;即不能被覆盖)。因此,如果我们尝试使用 MyClass 对象obMyClass,而不是接口对象,就像前面的演示一样,我们将得到编译错误。
Points to Remember
-
接口索引器没有主体。
-
接口索引器没有修饰符。
-
从 C# 7.0 开始,我们可以这样写代码:
public int MyInt { get => myInt; set => myInt = value; } -
索引器的显式实现是非公共和非虚拟的。
摘要
本章涵盖了
- 不同类型的属性
- 自动属性
- 具有最新 c# 7.0 特性的表达式体属性
- 虚拟和抽象属性
- 为什么我们应该更喜欢公共财产而不是公共领域
- 属性与数组有何不同
- 如何通过属性施加约束/限制
- 何时使用只读属性,何时避免使用只读属性
- 索引。
- 索引器与属性有何不同
- 如何使用带有显式和隐式接口的索引器,以及要记住的限制
- 接口索引器与类索引器有何不同
七、理解类变量
老师开始讨论:有时我们不想通过一个类型的实例来操作。相反,我们更喜欢研究类型本身。在这些场景中,我们想到了类变量或类方法的概念。它们通常被称为静态变量或静态方法。在 C# 中,类本身可以是静态的。一般来说,当我们将关键字 static 标记为一个类时,它就是一个静态类;当它被标记上一个方法时,它被称为静态方法;当我们把它和一个变量联系起来时,我们称之为静态变量。
类别变量
让我们从一个简单的例子开始。
演示 1
using System;
namespace StaticClassEx1
{
static class Rectangle
{
public static double Area(double len, double bre)
{
return len * bre;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring class variables.Example-1***\n");
double length = 25;
double breadth = 10;
Console.WriteLine("Area of Rectangle={0} sq. unit", Rectangle.Area(length, breadth));
Console.ReadKey();
}
}
}
输出
分析
可以看到我们通过类名调用了 Rectangle 类的Area (..)方法。我们在这里没有创建 Rectangle 类的任何实例。
学生问:
我们是否也可以创建 Rectangle 类的一个实例,然后调用 Area(..)方法?
老师说:不行,这里不允许。如果允许,那么还有必要引入静态类的概念吗?因此,如果您在 Visual Studio 中编写代码,并尝试引入如下代码行:
Rectangle rect = new Rectangle();//Error
您将得到以下编译错误。
学生问:
但是如果我们在 Rectangle 类中有一个非静态方法,我们如何访问这个方法呢?这一次,我们需要一个实例来访问该方法。
老师说:这就是为什么静态类有限制:它们只能包含静态成员。所以,如果你试着在我们的 Rectangle 类中放一个非静态方法,比如说ShowMe(),就像这样:
您将得到以下编译错误。
学生问:
我们不能从静态类创建实例。但是子类可以创建一个实例。在这种情况下,实际的概念可能会被误用。这种理解正确吗?
老师说:C# 的设计者已经注意到这个事实,因为我们不允许从静态类创建子类;也就是说,静态类不能被继承。因此,在我们之前的示例中,如果您尝试以下列方式创建非静态派生类(例如,ChildRectangle ):
您将得到一个编译错误。
学生问:
然后静态类被密封。这是正确的吗?
老师说:是的。如果您打开 IL 代码,您将看到以下内容:
老师继续说:你可能也注意到了,我们几乎在每个地方都使用控制台课程。这个类也是一个静态类。如果您右键单击控制台,然后按“转到定义”(或按 F12),您将看到以下内容:
Points to Remember
- 静态类是密封的(即,它们不能被继承或实例化)。
- 它们只能包含静态成员。
- 静态类不能包含实例构造函数。
- 系统。控制台和系统。数学是静态类的常见例子。
关于静态方法的讨论
老师继续说:到目前为止,我们已经看到了带有一些静态方法的静态类。你知道关键字 static 是用来表示“奇异事物”的。在设计模式中,有一种模式叫做单例模式,它可以使用静态类。
有时,我们也认为静态方法更快(更多信息见 MSDN 的文章 https://msdn.microsoft.com/en-us/library/ms973852.aspx )。但是,关键是它们不能是任何实例的一部分。这就是为什么我们的Main()方法是静态的。
如果你注意到Main()方法,你可以看到它包含在一个非静态类(程序)中。因此,很明显,非静态类可以包含静态方法。为了详细探究这一点,让我们来看下面的程序,其中有一个包含静态和非静态方法的非静态类。
演示 2
using System;
namespace StaticMethodsEx1
{
class NonStaticClass
{
//a static method
public static void StaticMethod()
{
Console.WriteLine("NonStaticClass.StaticMethod");
}
//a non-static method
public void NonStaticMethod()
{
Console.WriteLine("NonStaticClass.NonStaticMethod");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring static methods
.Example-1***\n");
NonStaticClass anObject = new NonStaticClass();
anObject.NonStaticMethod();//Ok
//anObject.StaticMethod();//Error
NonStaticClass.StaticMethod();
Console.ReadKey();
}
}
}
输出
如果取消对以下行的注释:
//anObject.StaticMethod();
您将收到以下错误:
现在考虑修改后的程序。我在这里引入了一个静态变量和一个实例变量,用静态和实例方法来分析它们。
演示 3
using System;
namespace StaticMethodsEx2
{
class NonStaticClass
{
static int myStaticVariable = 25;//static variable
int myInstanceVariable = 50;//instance variable
//a static method
public static void StaticMethod()
{
Console.WriteLine("NonStaticClass.StaticMethod");
Console.WriteLine("myStaticVariable = {0}", myStaticVariable);//25
//Console.WriteLine("StaticMethod->instance variable = {0}", myInstanceVariable);//error
}
//a non-static method
public void NonStaticMethod()
{
Console.WriteLine("NonStaticClass.NonStaticMethod");
Console.WriteLine("NonStaticMethod->static variable = {0}", myStaticVariable);//25 Ok
//Console.WriteLine("myStaticVariable = {0}", this.myStaticVariable);//Error
Console.WriteLine("myInstanceVariable = {0}", myInstanceVariable);//50
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring static methods
.Example-2***\n");
NonStaticClass anObject = new NonStaticClass();
anObject.NonStaticMethod();//Ok
//anObject.StaticMethod();//Error
NonStaticClass.StaticMethod();
Console.ReadKey();
}
}
}
输出
分析
请注意注释行。它们中的每一个都可能导致编译错误。例如,如果取消对该行的注释:
//Console.WriteLine("myStaticVariable = {0}", this.myStaticVariable);//Error
它会导致以下错误:
因为这里也是实例引用。
老师继续:以后你会学到,在 C# 中,我们有扩展方法。(我们可以用新方法扩展现有类型,而不会影响类型的定义。)这些基本上是静态方法,但是使用实例方法语法调用,因此您可以将静态方法视为实例方法。它们最常用于 LINQ 查询运算符的上下文中。然而,对这些主题的详细讨论超出了本书的范围。
关于静态构造函数的讨论
我们可以使用静态构造函数来初始化任何静态数据,或者执行只需要运行一次的操作。我们不能直接调用静态构造函数(也就是说,我们不能直接控制静态构造函数何时被执行)。但是我们知道它会在以下两种情况下被自动调用:
- 在创建类型的实例之前。
- 当我们在程序中引用一个静态成员时。
考虑下面的程序和输出。
演示 4
using System;
namespace StaticConstructorEx1
{
class A
{
static int StaticCount=0,InstanceCount=0;
static A()
{
StaticCount++;
Console.WriteLine("Static constructor.Count={0}",StaticCount);
}
public A()
{
InstanceCount++;
Console.WriteLine("Instance constructor.Count={0}", InstanceCount);
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring static constructors***\n");
A obA = new A();//StaticCount=1,InstanceCount=1
A obB = new A();//StaticCount=1,InstanceCount=2
A obC = new A();//StaticCount=1,InstanceCount=3
Console.ReadKey();
}
}
}
输出
分析
从程序和输出中,我们看到静态构造函数只执行一次(不是每个实例)
如果您引入此代码:
static A(int A){ }
您将得到一个编译时错误。
如果您引入此代码:
public static A(){...}
您将得到以下编译时错误:
Points to Remember
- 静态构造函数对每种类型只执行一次。我们无法直接控制何时执行静态构造函数。但是我们知道,当我们试图实例化一个类型或者当我们试图访问该类型中的一个静态成员时,会自动调用一个静态构造函数。
- 一个类型只能有一个静态构造函数。它必须是无参数的,并且不接受任何访问修饰符。
- 按照声明顺序,静态字段初始值设定项在静态构造函数之前运行。
- 在没有静态构造函数的情况下,字段初始值设定项就在类型被使用之前执行,或者在运行时突发奇想的任何时候执行。
学生问:
什么时候应该使用静态构造函数?
老师说:写日志会很有用。它们还用于为非托管代码创建包装类。
摘要
本章涵盖了
- 静态类概念
- 静态方法和静态变量概念
- 静态构造函数概念
- 如何在 C# 中实现这些概念以及与之相关的限制
- 何时以及如何使用这些概念
八、C# 中一些关键比较的分析
老师说:在这一章中,我们讨论了 C# 中一些常见的比较。我们开始吧。
隐式转换与显式转换
通过强制转换,我们可以将一种数据类型转换成另一种。有时我们称这种过程为类型转换。基本上,有两种类型的造型:隐式和显式。顾名思义,隐式转换是自动的,我们不需要担心它。但是,我们需要转换操作符来进行显式转换。除此之外,还有两种其他类型的转换:使用帮助器类的转换和用户定义的转换。在这一章中,我们将关注隐式和显式转换。让我们现在过一遍。
在隐式转换中,转换路径遵循从小到大的整数类型,或者从派生类型到基类型。
下面的代码片段将会完美地编译和运行:
int a = 120;
//Implicit casting
double b = a;//ok- no error
对于显式强制转换,考虑相反的情况。如果你写了这样的东西:
int c = b;//Error
编译器会抱怨。
所以,你需要写这样的东西:
//Explicit casting
int c = (int)b;//Ok
Points to Remember
如果一种类型可以转换为另一种类型,则可以应用铸造;也就是说,不能将字符串赋给整数。你总是会在这种尝试中遇到错误;例如,您总是会得到一个错误,即使您尝试对它应用 cast。
int d = ( int)"hello";//error
隐式和显式转换有一些基本的区别。隐式转换是类型安全的(没有数据丢失,因为我们是从一个小容器到一个大容器,我们有足够的空间)。显式转换不是类型安全的(因为在这种情况下,数据从一个大容器移动到一个小容器)。
学生问:
先生,当我们处理引用类型时,如何处理强制转换异常?
老师说:在这些场景中,我们将使用“is”或“as”运算符。我们稍后将讨论它们。
拳击对拳击
老师继续:现在我们来讨论另一个重要的话题:装箱和拆箱。这里我们需要处理值类型和引用类型。
对象(系统。对象)是所有类型的最终基类。因为 Object 是一个类,所以它是一个引用类型。当我们应用强制转换将值类型转换为对象类型(即引用类型)时,该过程称为装箱,相反的过程称为取消装箱。
通过装箱,值类型在堆上分配一个对象实例,然后将复制的值装箱(存储)到该对象中。
这里有一个拳击的例子:
int i = 10;
object o = i;//Boxing
现在考虑相反的情况。如果您尝试编写这样的代码:
object o = i;//Boxing
int j = o;//Error
您将面临编译错误。
为了避免这种情况,我们需要使用拆箱,就像这样:
object o = i;
int j = (int)o; //Unboxing
学生问:
哪种转换是隐式的:装箱还是取消装箱?
老师说:拳击。请注意,我们不需要编写这样的代码:
int i = 10;
object o = (object)i;
//object o=i; is fine since Boxing is implicit.
学生问:
装箱、拆箱和类型转换操作似乎是相似的。这是真的吗?
老师说:有时它可能看起来令人困惑,但如果你专注于基本规则,你可以很容易地避免困惑。通过这些操作(向上转换/向下转换,装箱/取消装箱),我们试图将一件事转换成另一件事。这基本上是他们的相似之处。现在重点说说装箱拆箱的特长。装箱和取消装箱是值类型和对象类型(即引用类型)之间的转换。通过装箱,值类型的副本从堆栈移动到堆中,而取消装箱则执行相反的操作。所以,你基本上可以说,通过装箱,我们将值类型转换为引用类型(显然,取消装箱是这种操作的对应)。
但是从更广泛的意义上来说,使用“投射”这个词,我们的意思是说我们并没有在物体上移动或操作。我们只想转换它们的表面类型。
学生问:
先生,unboxing 和 downcasting(显式强制转换)的共同之处是什么?
两者都可能是不安全的,并且它们可能会引发 InvalidCastException。基本上,显式造型总是很危险的。考虑操作不安全的情况。假设你想把一个 long 转换成一个 int,你已经在下面的演示中写了这样的代码。
演示 1
#region invalid casting
long myLong = 4000000000;
int myInt = int.MaxValue;
Console.WriteLine(" Maximum value of int is {0}", myInt);
//Invalid cast:Greater than maximum value of an integer
myInt = (int) myLong;
Console.WriteLine(" Myint now={0}", myInt);
#endregion
输出
分析
您可以看到,您没有收到任何编译错误,但是整数 myInt 的最终值是不需要的。所以,这种转换是不安全的。
学生问:
"装箱和取消装箱会影响程序的性能."这是真的吗?
老师说:是的。它们会严重影响程序的性能。这里我们介绍了两个程序来分析。演示 2 分析选角的表现,演示 3 分析拳击的表现。请注意,强制转换或装箱操作所花费的时间总是会影响程序的性能,如果我们不断增加 for 循环结构中的迭代次数,这些时间会变得非常重要。
演示 2
using System;
using System.Diagnostics;
namespace CastingPerformanceComparison
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Analysis of casting performance***\n");
#region without casting operations
Stopwatch myStopwatch1 = new Stopwatch();
myStopwatch1.Start();
for (int i = 0; i < 100000; i++)
{
int j = 25;
int myInt = j;
}
myStopwatch1.Stop();
Console.WriteLine("Time taken without casting : {0}", myStopwatch1.Elapsed);
#endregion
#region with casting operations
Stopwatch myStopwatch2 = new Stopwatch();
myStopwatch2.Start();
for ( int i=0;i<100000;i++)
{
double myDouble = 25.5;
int myInt = (int)myDouble;
}
myStopwatch2.Stop();
Console.WriteLine("Time taken with casting: {0}", myStopwatch2.Elapsed);
#endregion
Console.ReadKey();
}
}
}
输出
分析
看到时差了吗?在铸造操作中,时间要长得多。当我们改变迭代次数时,这种差异也就不同了。(在您的机器上,您可以看到类似的差异,但在每次单独运行时,这些值可能会略有不同。)
演示 3
Note
我们在这里使用了泛型编程的简单概念。所以,一旦你理解了泛型的概念,你就可以回到这个程序。
using System;
using System.Collections.Generic;
using System.Diagnostics;//For Stopwatch
namespace PerformanceOfBoxing
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Performance analysis in Boxing ***");
List<int> myInts = new List<int>();
Stopwatch myStopwatch1 = new Stopwatch();
myStopwatch1.Start();
for (int i = 0; i < 1000000; i++)
{
//Adding an integer to a list of Integers. So, there is no need of boxing.(Advantage of Generics)
myInts.Add(i);
}
myStopwatch1.Stop();
Console.WriteLine("Time taken without Boxing: {0}", myStopwatch1.Elapsed);
//Now we are testing :Boxing Performance
List<object> myObjects = new List<object>();
Stopwatch myStopwatch2 = new Stopwatch();
myStopwatch2.Start();
for (int i = 0; i < 1000000; i++)
{
//Adding an integer to a list of Objects. So, there is need of boxing.
myObjects.Add(i);
}
myStopwatch2.Stop();
Console.WriteLine("Time taken with Boxing :{0}", myStopwatch2.Elapsed);
Console.ReadKey();
}
}
}
分析
再次注意时差。在拳击比赛中,时间要长得多。当我们改变迭代(循环)的次数时,这种差异也会改变。
输出
向上转换与向下转换
通过类型转换,我们试图改变对象的外观类型。在一个继承链中,我们可以从下往上走,也可以从上往下走。
通过向上转换,我们从一个子类引用中创建一个基类引用;对于向下转换,我们做相反的事情。
我们已经看到所有的足球运动员(一种特殊类型的运动员)都是运动员,但反过来就不一定了,因为有网球运动员、篮球运动员、曲棍球运动员等等。而且我们也看到了一个父类引用可以指向一个子类对象;也就是说,我们可以这样写
Player myPlayer=new Footballer();
(像往常一样,我们假设球员类是基类,球员类是从基类派生出来的)。这是向上抛的方向。在向上转换中,我们可以有以下注释:
- 它简单而含蓄。
- 当我们从一个子类引用创建一个基类引用时,这个基类引用可以对子对象有一个更严格的视图。
为了清楚地理解这几点,我们来看下面的例子。
演示 4
using System;
namespace UpVsDownCastingEx1
{
class Shape
{
public void ShowMe()
{
Console.WriteLine("Shape.ShowMe");
}
}
class Circle:Shape
{
public void Area()
{
Console.WriteLine("Circle.Area");
}
}
class Rectangle:Shape
{
public void Area()
{
Console.WriteLine("Rectangle.Area");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Upcasting Example***\n");
Circle circleOb = new Circle();
//Shape shapeOb = new Circle();//upcasting
Shape shapeOb = circleOb;//Upcasting
shapeOb.ShowMe();
//shapeOb.Area();//Error
circleOb.Area();//ok
Console.ReadKey();
}
}
}
输出
分析
请注意,我们已经使用以下代码行实现了向上转换:
Shape shapeOb = circleOb;//Upcasting
shapeOb.ShowMe();
您可以看到,尽管 shapeOb 和 circleOb 都指向同一个对象,但是 shapeOb 无法访问 circle 的 Area()方法(即,它对该对象具有限制性视图)。但是 circleOb 可以很容易地访问自己的方法。
学生问:
先生,为什么父参考在这个设计中有限制性的观点?
老师说:当父类被创建时,它不知道它的子类和它将要添加的新方法。因此,父类引用不应该访问专门的子类方法是有意义的。
老师继续说:如果你写了下面这样的东西,你是沮丧的。
Circle circleOb2 = (Circle)shapeOb;//Downcast
因为现在您正在从基类引用创建子类引用。
但是向下转换是显式的,不安全的,这种转换我们会遇到 InvalidCastException。
恶作剧
让我们修改 Main()方法,如下所示。(我们保持其余部分不变;也就是说,所有三个类别——Shape、Circle 和 Rectangle——都与前面的程序相同。现在预测输出。
static void Main(string[] args)
{
Console.WriteLine("***Downcasting is unsafe demo***\n");
Circle circleOb = new Circle();
Rectangle rectOb = new Rectangle();
Shape[] shapes = { circleOb, rectOb };
Circle circleOb2 = (Circle)shapes[1];//Incorrect
//Circle circleOb2 = (Circle)shapes[0];//Correct
circleOb2.Area();
Console.ReadKey();
}
输出
将引发运行时异常。
分析
这是一个我们在运行时会遇到 InvalidCastException()的例子。形状[1]是矩形对象,不是圆形对象。所以,如果你使用向下转换,你需要小心。
是与是
在某些情况下,我们经常需要动态检查一个对象的类型,这两个关键字在这里起着重要的作用。
关键字 is 与给定类型进行比较,如果可以进行强制转换,则返回 true,否则将返回 false。另一方面,as 可以将给定的对象转换为指定的类型,如果它是可转换的;否则,它将返回 null。
因此,我们可以说,使用 as 关键字,我们既可以进行强制转换能力检查,也可以进行转换。
演示 5:使用“is”关键字
这里我们稍微修改了一下程序,用三种不同的形状代替了两种不同的形状:三角形、矩形和圆形。我们将不同的形状存储在一个数组中,然后计算每个类别的总数。
using System;
namespace IsOperatorDemo
{
class Shape
{
public void ShowMe()
{
Console.WriteLine("Shape.ShowMe");
}
}
class Circle : Shape
{
public void Area()
{
Console.WriteLine("Circle.Area");
}
}
class Rectangle : Shape
{
public void Area()
{
Console.WriteLine("Rectangle.Area");
}
}
class Triangle : Shape
{
public void Area()
{
Console.WriteLine("Triangle.Area");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***is operator demo***\n");
//Initialization-all counts are 0 at this point
int noOfCircle = 0, noOfRect = 0, noOfTriangle = 0;
//Creating 2 different circle object
Circle circleOb1 = new Circle();
Circle circleOb2 = new Circle();
//Creating 3 different rectangle object
Rectangle rectOb1 = new Rectangle();
Rectangle rectOb2 = new Rectangle();
Rectangle rectOb3 = new Rectangle();
//Creating 1 Triangle object
Triangle triOb1 = new Triangle();
Shape[] shapes = { circleOb1, rectOb1,circleOb2, rectOb2,triOb1,rectOb3 };
for(int i=0;i<shapes.Length;i++)
{
if( shapes[i] is Circle)
{
noOfCircle++;
}
else if (shapes[i] is Rectangle)
{
noOfRect++;
}
else
{
noOfTriangle++;
}
}
Console.WriteLine("No of Circles in shapes array is {0}", noOfCircle);
Console.WriteLine("No of Rectangles in shapes array is {0}", noOfRect);
Console.WriteLine("No of Triangle in shapes array is {0}", noOfTriangle);
Console.ReadKey();
}
}
}
输出
分析
看看这些代码段:
我们不是盲目地处理形状数组中的对象。一旦我们浏览了每一个,我们就可以控制它们是圆形、矩形还是三角形。如果这不是我们想要的类型,“如果条件”将为假,我们可以避免运行时意外。
因此,您总是可以测试这样一个简单的事实,即所有的圆都是形状,但是下面几行代码的情况正好相反:
Console.WriteLine("*****");
Shape s = new Shape();
Circle c = new Circle();
Console.WriteLine("Any Circle is a Shape?{0}", c is Shape);//True
Console.WriteLine("Any Shape is a Circle? {0}", (s is Circle));//False
输出
演示 6:使用“as”关键字
现在通过一个类似的程序。但是这一次,我们使用了 as 关键字,而不是 is 关键字。
using System;
namespace asOperatorDemo
{
class Program
{
class Shape
{
public void ShowMe()
{
Console.WriteLine("Shape.ShowMe");
}
}
class Circle : Shape
{
public void Area()
{
Console.WriteLine("Circle.Area");
}
}
class Rectangle : Shape
{
public void Area()
{
Console.WriteLine("Rectangle.Area");
}
}
static void Main(string[] args)
{
Console.WriteLine("***as operator demo***\n");
Shape shapeOb = new Shape();
Circle circleOb = new Circle();
Rectangle rectOb = new Rectangle();
circleOb = shapeOb as Circle; //no exception
if( circleOb!=null)
{
circleOb.ShowMe();
}
else
{
Console.WriteLine("'shapeOb as Circle' is prodcuing null ");
}
shapeOb = rectOb as Shape;
if (shapeOb != null)
{
Console.WriteLine("'rectOb as Shape' is NOT prodcuing null ");
shapeOb.ShowMe();
}
else
{
Console.WriteLine(" shapeOb as Circle is prodcuing null ");
}
Console.ReadKey();
}
}
}
输出
分析
如果操作是可强制转换的,则运算符 as 会自动执行转换;否则,它返回 null。
Points to Remember
as 运算符将成功执行向下转换操作,否则将计算为 null(如果向下转换失败)。因此,我们在前面的程序中进行空值检查的方法在 C# 编程中非常常见。
通过值传递值类型与通过引用传递值类型(使用 ref 与 out)
老师继续说:我们已经知道值类型变量直接包含其数据,引用类型变量包含对其数据的引用。
因此,通过值向方法传递值类型变量意味着我们实际上是在向方法传递一个副本。因此,如果该方法对复制的参数进行任何更改,它对原始数据没有影响。如果您希望 caller 方法所做的更改反映回原始数据,您需要用 ref 关键字或 out 关键字通过引用传递它。
让我们看一下这个程序。
演示 7:按值传递值类型
using System;
namespace PassingValueTypeByValue
{
class Program
{
static void Change(int x)
{
x = x * 2;
Console.WriteLine("Inside Change(), myVariable is {0}", x);//50
}
static void Main(string[] args)
{
Console.WriteLine("***Passing Value Type by Value-Demo***");
int myVariable = 25;
Change(myVariable);
Console.WriteLine("Inside Main(), myVariable={0}", myVariable);//25
Console.ReadKey();
}
}
}
输出
分析
这里我们在 change()方法中做了一个改变。但是这个改变的值没有反映在 Change()方法之外,因为在 Main()方法内部,我们看到 myVariable 的值是 25。这是因为实际上所做的更改是在 myVariable 的副本上(或者换句话说,影响只是在局部变量 x 上)。
ref 参数与 out 参数
老师继续说:现在考虑同样的程序,只做了一点小小的修改,如下所示(用箭头突出显示)。
演示 8
输出
分析
这里我们在 change()方法中做了一个改变。并且这个改变的值反映在 Change()方法之外。这里 ref 关键字已经完成了这个任务。对于 ref int x,我们不是指整数参数,而是指对 int(在本例中是 myVariable)的引用。
Points to Remember
我们需要在将 myVariable 传递给 ChangeMe()方法之前对其进行初始化;否则,我们会遇到编译错误。
老师继续说:现在考虑一个非常相似的程序。这次我们将展示 out 参数的用法。
演示 9:使用“out”参数
using System;
namespace PassingValueTypeUsingOut
{
class Program
{
static void Change(out int x)
{
x = 25;
x = x * 2;
Console.WriteLine("Inside Change(), myVariable is {0}", x);//50
}
static void Main(string[] args)
{
Console.WriteLine("***Passing Value Type by Reference using out-Demo***");
//Need to be initialized, if you use 'ref'
int myVariable;
Change(out myVariable);
Console.WriteLine("Inside Main(), myVariable={0}", myVariable);//50
Console.ReadKey();
}
}
输出
分析
这里我们取得了类似的结果(和 ref 一样,变化在 Main()和 ChangeMe()中都有体现)。但是如果你仔细观察,你会发现在这个程序中,我们并没有在将 myVariable 传递给 ChangeMe()方法之前对它进行初始化。对于 out 参数,这种初始化不是强制性的(但是对于 ref,这是必须的)。你还会注意到,我们需要在它从函数中出来之前赋值;对于 out 参数,它是必需的。
Points to Remember
对于 out 参数,这种初始化不是强制性的(但是对于 ref,这是必须的)。另一方面,我们需要在它从函数中出来之前给它赋值。
恶作剧
假设我们修改了 Change()方法,如下所示:
static void Change(out int x)
{
//x = 25;
int y = 10;
x = x * 2;
Console.WriteLine("Inside Change(), myVariable is {0}", x);
}
前面的代码可以编译吗?
回答
不。我们会得到一个编译错误。
分析
正如我们前面提到的,在我们离开方法 Change()之前,我们需要给 x 赋值。在这种情况下,你给另一个变量 y 赋值(10 ),这个变量在这里没有用。
学生问:
主席先生,默认情况下,参数是如何传递的:通过值还是通过引用?
老师说:它们是按价值传递的。
学生问:
先生,我们可以将引用类型作为值传递吗(反之亦然)?
老师说:是的。在 PassingValueTypeUsingRef(演示 8)示例中,我们传递了一个带有 Ref 关键字的值类型。现在考虑一个相反的情况。这里我们将引用类型(字符串)作为值类型传递。
演示 10:将引用类型作为值传递
using System;
namespace PassReferenceTypeUsingValue
{
class Program
{
static void CheckMe(string s)
{
s = "World";
Console.WriteLine("Inside CheckMe(), the string value is {0}", s);//World
}
static void Main(string[] args)
{
string s = "Hello";
Console.WriteLine("Inside Main(), Initially the string value is {0}", s);//Hello
CheckMe(s);
Console.WriteLine("Inside Main(), finally
the string value is {0}", s);//Hello
Console.ReadKey();
}
}
}
输出
我们可以观察到 CheckMe()所做的更改没有反映到 Main()中。
学生问:
先生,这样看来,一旦我们将引用类型作为值传递,我们就不能修改该值了。这种理解正确吗?
老师说:一点也不。这取决于你如何使用它;例如,考虑下面的程序。这里我们使用这种机制来改变数组的前两个元素。
演示 11:数组元素的案例研究
using System;
namespace PassReferenceTypeUsingValueEx2
{
class Program
{
static void CheckMe(int[] arr)
{
arr[0] = 15;
arr[1] = 25;
arr = new int[3] { 100, 200,300};
Console.WriteLine("********");
Console.WriteLine("Inside CheckMe(),arr[0]={0}", arr[0]);//100
Console.WriteLine("Inside CheckMe(),arr[1]={0}", arr[1]);//200
Console.WriteLine("Inside CheckMe(),arr[2]={0}", arr[2]);//300
Console.WriteLine("********");
}
static void Main(string[] args)
{
Console.WriteLine("***Passing reference Type by value.Ex-2***");
int[] myArray= { 1, 2, 3 };
Console.WriteLine("At the beginning,myArray[0]={0}", myArray[0]);//1
Console.WriteLine("At the beginning,myArray[1]={0}", myArray[1]);//2
Console.WriteLine("At the beginning,myArray[2]={0}", myArray[2]);//3
CheckMe(myArray);
Console.WriteLine("At the end,myArray[0]={0}", myArray[0]);//15
Console.WriteLine("At the end,myArray[1]={0}", myArray[1]);//25
Console.WriteLine("At the end,myArray[2]={0}", myArray[2]);//3
Console.ReadKey();
}
}
}
输出
分析
在 CheckMe()方法中,一旦我们创建了一个新数组,引用数组就开始指向一个新数组。因此,在这之后,在 Main()中创建的原始数组没有任何变化。实际上,在那次操作之后,我们处理的是两个不同的数组。
恶作剧
代码会编译吗?
class Program
{
static void ChangeMe( int x)
{
x = 5;
Console.WriteLine("Inside Change() the value is {0}", x);
}
static void ChangeMe(out int x)
{
//out parameter must be assigned before it leaves the function
x = 5;
Console.WriteLine("Inside ChangeMe() the value is {0}", x);
}
static void ChangeMe(ref int x)
{
x = 5;
Console.WriteLine("Inside ChangeMe() the value is {0}", x);
}
static void Main(string[] args)
{
Console.WriteLine("***ref and out Comparison-Demo***");
//for ref, the variable need to be initialized
int myVariable3=25;
Console.WriteLine("Inside Main(),before call, the value is {0}", myVariable3);
ChangeMe( myVariable3);
ChangeMe(ref myVariable3);
ChangeMe(out myVariable3);
Console.WriteLine("Inside Main(),after call, the value is {0}", myVariable3);
}
输出
分析
我们可以使用 ChangeMe(out myVariable3)或 ChangeMe(ref myVariable3)和 ChangeMe(myVariable3)。他们不允许在一起。如果您注释掉 ChangeMe(out myVariable3)及其相关调用,您会收到如下输出:
学生问:
C# 中一个方法(函数)可以返回多个值吗?
老师说:是的,它能。在这种情况下,我们很多人更喜欢 KeyValuePair。但是刚才我们已经学会了 out 的用法,它可以帮助我们实现一个类似的概念。考虑下面的程序。
演示 12:返回多个值的方法
class Program
{
static void RetunMultipleValues(int x, out double area, out double perimeter)
{
area = 3.14 * x * x;
perimeter = 2 * 3.14 * x;
}
static void Main(string[] args)
{
Console.WriteLine("***A method returning multiple values***");
int myVariable3 = 3;
double area=0.0,perimeter=0.0;
RetunMultipleValues(myVariable3, out area, out perimeter);
Console.WriteLine("Area of the circle is {0} sq. unit", area);
Console.WriteLine("Peremeter of the Cicle is {0} unit", perimeter);
}
}
输出
C# 类型的简单比较
老师说:C# 类型可以大致分为
- 值类型
- 参考类型
- 指针类型
- 泛型类型
让我们来探讨这一部分的前三个。第四种类型(即泛型)将在本书的第二部分讨论。所以,让我们从值类型和引用类型开始。
值类型和引用类型
值类型的一些示例是常见的内置数据类型(例如,int、double、char、bool 等)。)、枚举类型和用户定义的结构。(一个例外是 String,它是一种内置数据类型,也是一种引用类型。)
引用类型的一些例子是类(对象)、接口、数组和委托。
内置引用类型包括对象、动态和字符串。
这两种类型的根本区别在于它们在内存中的处理方式。
让我们从它们之间的关键区别开始。
| 值类型 | 参考类型 | | :-- | :-- | | 按照 MSDN 的说法,在自己的内存位置保存数据的数据类型是值类型。 | 另一方面,引用类型包含一个指向另一个实际包含数据的内存位置的指针。(你可以简单地认为它由两部分组成:一个对象和对该对象的引用。) | | 值类型的赋值总是导致实例的复制。 | 引用类型的赋值导致它只复制引用,而不是实际对象。 | | 常见的例子包括除字符串以外的内置数据类型(例如,int、double、char、bool 等)。),枚举类型,用户定义的结构。 | 常见的例子包括:类(对象)、接口、数组、委托和一个特殊的内置数据类型字符串(别名系统。字符串)。 | | 通常,值类型没有空值。 | 引用类型可以指向 null(即,它不指向任何对象)。 |学生问:
先生,在 C# 中如何检查 class 是引用类型,structure 是值类型?
老师说:考虑下面的程序和输出。
演示 13:值类型与引用类型
using System;
namespace ImportantComparison
{
struct MyStruct
{
public int i;
}
class MyClass
{
public int i;
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Test-valueTypes vs Reference Types***\n");
MyStruct struct1, struct2;
struct1=new MyStruct();
struct1.i = 1;
struct2 = struct1;
MyClass class1, class2;
class1= new MyClass();
class1.i = 2;
class2 = class1;
Console.WriteLine("struct1.i={0}", struct1.i);//1
Console.WriteLine("struct2.i={0}", struct2.i);//1
Console.WriteLine("class1.i={0}", class1.i);//2
Console.WriteLine("class2.i={0}", class2.i);//2
Console.WriteLine("***Making changes to strcut1.i(10) and class1.i (20)***");
struct1.i = 10;
class1.i = 20;
Console.WriteLine("***After the changes, values are :***");
Console.WriteLine("struct1.i={0}", struct1.i);//10
Console.WriteLine("struct2.i={0}", struct2.i);//1
Console.WriteLine("class1.i={0}", class1.i);//20
Console.WriteLine("class2.i={0}", class2.i);//20
Console.ReadKey();
}
}
}
输出
分析
我们可以看到,当我们在 class1 中进行更改时,class1 和 class2 这两个类对象都更新了它们的实例变量 I。但对于建筑来说,情况并非如此。struct2.i 保持旧值 1,即使 struct1.i 更改为 10。
When we wrote struct2 = struct1;
struct2 结构成为 struct1 的独立副本,具有自己单独的字段。
When we wrote class2 = class1;
我们正在复制指向同一个对象的引用。
学生问:
先生,什么时候我们应该选择值类型而不是引用类型?
老师说:
一般来说,栈可以比堆更有效地被使用。所以,数据结构的选择很重要。
在引用类型中,当我们的方法完成执行时,不会回收内存。为了回收内存,需要调用垃圾收集机制。它并不总是可靠和简单的。
学生问:
先生,什么时候我们应该选择引用类型而不是值类型?
老师说:
对于值类型,生存期是一个很大的问题。当一个方法完成它的执行时,内存被回收。
这些不适合跨不同类共享数据。
指针类型
在 C# 中,支持指针,但是在不安全的上下文中。我们需要用“不安全”关键字标记代码块。您还需要用/unsafe 选项编译代码。所以基本上,通过使用“不安全”标签,你可以用指针进行 C++风格的编码。目的是一样的:一个指针可以保存变量的地址,可以强制转换成其他指针类型(显然这些操作是不安全的)。
注意
- 最常见的指针运算符是*、&、和-->。
- 我们可以将以下任何类型视为指针类型:byte、sbyte、short、ushort、int、uint、long、ulong、float、double、decimal、bool、char、任何枚举类型、任何指针类型或仅具有非托管类型字段的用户定义的结构类型。
以下是一些基本的指针类型声明:
int *p表示 p 是一个指向整数的指针int **p意味着 p 是一个指向整数的指针char* p表示 p 是一个指向字符的指针void* p表示 p 是一个指向未知类型的指针(虽然它是允许的,但建议使用时要特别小心)
考虑下面的例子。
演示 14:指针类型
using System;
namespace UnsafeCodeEx1
{
class A
{
}
class Program
{
static unsafe void Main(string[] args)
{
int a = 25;
int* p;
p = &a;
Console.WriteLine("***Pointer Type Demo***");
Console.WriteLine("*p is containing:{0}", *p);
A obA = new A();
//Error:Cannot take the address of, get the size of, or declare a pointer to a managed type ('A')
//A* obB = obA;
Console.ReadKey();
}
}
}
输出
分析
在 Visual Studio 2017 中,您需要通过启用如下复选框来允许不安全的代码:
否则,您会遇到以下错误:
学生问:
先生,我们什么时候在 C# 的上下文中使用指针?
老师说:一个基本目的是与 C APIs 的互操作性。除此之外,有时我们可能希望访问托管堆边界之外的内存来处理一些关键问题。正如微软所说:“如果不能访问指针,那么与底层操作系统接口、访问内存映射设备或实现时间关键的算法就不可能或不切实际。”
Points to Remember
-
我们不能在指针类型和对象之间转换。指针不从对象继承。
-
为了在同一个地方声明多个指针,用底层类型写*号,比如
int* a, b, c; //ok,但是如果我们像这样写,我们会遇到一个编译器错误。
int *a,*b,*c;//Error -
稍后我们将学习垃圾收集,它基本上是对引用的操作。垃圾收集器可以在清理过程中收集对象引用,即使某些指针指向它们。这就是指针不能指向引用(或任何包含引用的结构)的原因。
常量与只读
老师继续:C# 支持两个特殊的关键字:const 和 readonly。共同之处在于,它们都试图阻止对一个字段的修改。尽管如此,他们还是有一些不同的特点。我们将通过一些程序段来验证这些。
Points to Remember
我们可以像声明变量一样声明常量,但关键是声明后不能更改。另一方面,我们可以在声明过程中或通过构造函数给 readonly 字段赋值。
要声明一个常量变量,我们需要在声明前加上关键字 const。我们必须记住常量是隐式静态的。
演示 15:使用“const”关键字
using System;
namespace ConstantsEx1
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Quiz : Experiment with a constructor***\n");
const int MYCONST = 100;
//Following line will raise error
MYCONST=90;//error
Console.WriteLine("MYCONST={0}", MYCONST);
Console.ReadKey();
}
}
}
输出
类似地,对于 readonly,我们将得到以下行的错误:
public readonly int myReadOnlyValue=105;
//Following line will raise error
myReadOnlyValue=110;//error
恶作剧
代码会编译吗?
Class ReadOnlyEx
{
public static readonly int staticReadOnlyValue;
static ReadOnlyEx()
{
staticReadOnlyValue = 25;
}
//Some other code e.g. Main Method() etc..
}
回答
是的。
恶作剧
代码会编译吗?
Class ReadOnlyEx
{
public readonly int nonStaticReadOnlyValue;
public ReadOnlyEx(int x)
{
nonStaticReadOnlyValue = x;
}
//Some other code e.g.Main method() etc..
}
回答
是的。
恶作剧
代码会编译吗?
Class ReadOnlyEx
{
public readonly int myReadOnlyValue=105;
public int TrytoIncreaseNonStaticReadOnly()
{
myReadOnlyValue++;
}
//Some other code e.g.Main method() etc..
}
回答
不。在这种情况下,您只能通过构造函数来更改值。(trytoiincreasenonstanticreadonly()不是此处的构造函数)
恶作剧
代码会编译吗?
public static const int MYCONST = 100;
回答
不。常量是隐式静态的。我们不允许在这里提到关键字 static。
恶作剧
输出会是什么?
class TestConstants
{
public const int MYCONST = 100;
}
class Program
{
static void Main(string[] args)
{
TestConstants tc = new TestConstants();
Console.WriteLine(" MYCONST is {0}", tc.MYCONST);
Console.ReadKey();
}
}
回答
我们将遇到编译时错误。我们已经提到常量是隐式静态的。因此,我们不能通过实例引用来访问它们。
我们应该在这里使用类名。因此,下面一行代码可以正常工作:
Console.WriteLine(" MYCONST is {0}", TestConstants.MYCONST);
学生问:
在程序中使用常量有什么好处?
老师说:它们容易阅读和修改。我们可以修改单个位置来反映整个程序的变化。否则,我们可能需要找出变量在程序中的每一次出现。这种方法很容易出错。
学生问:
什么时候我们应该选择 readonly 而不是 const?
老师说:当我们想要一个变量值时,它不应该被改变,但是这个值只有在运行时才知道;例如,我们可能需要在设置初始值之前做一些计算。
我们还会注意到,只读值可以是静态的,也可以是非静态的,而常量总是静态的。因此,一个类的不同实例可以有不同的值。“只读”的一个非常常见的用法是设置一种软件许可证。
Points to Remember
- 只读值可以是静态的,也可以是非静态的;而常数总是静态的。
- readonly 的一个非常常见的用途是设置一种软件许可。
摘要
本章涵盖了
- 隐式和显式转换的比较
- 装箱和取消装箱的比较
- 拳击和铸造的比较
- 上抛和下抛的比较
- 将 is 和用作关键字
- 通过值传递值类型与通过引用传递值类型
- ref 与 out 参数之间的比较
- 我们如何将引用类型作为值传递(反之亦然)?
- C# 中一个方法如何返回多个值?
- 值类型与引用类型
- 如何在 C# 中检查 class 是引用类型,structure 是值类型?
- 什么时候我们应该选择值类型而不是引用类型,反之亦然。
- 指针类型概述。
- const 和 readonly 之间的比较