科技行者

行者学院 转型私董会 科技行者专题报道 网红大战科技行者

知识库

知识库 安全导航

至顶网软件频道基础软件C#锐利体验域与属性

C#锐利体验域与属性

  • 扫一扫
    分享文章到微信

  • 扫一扫
    关注官方公众号
    至顶头条

C#锐利体验域与属性

作者:李建忠 来源:MSDN 2007年10月29日

关键字: 域与属性 C# Linux

  • 评论
  • 分享微博
  • 分享邮件

第七讲 域与属性

域(Field)又称成员变量(Member Variable),它表示存储位置,是C#中类不可缺少的一部分。域的类型可以是C#中任何数据类型。但对于除去string类型的其他引用类型由于在初始化时涉及到一些类的构造器的操作,我们这里将不提及,我们把这一部分内容作为“类的嵌套”放在“接口 继承与多态”一讲内来阐述。

域分为实例域和静态域。实例域属于具体的对象,为特定的对象所专有。静态域属于类,为所有对象所共用。C#严格规定实例域只能通过对象来获取,静态域只能通过类来获取。例如我们有一个类型为MyClass的对象MyObject,MyClass内的实例域instanceField(存取限制为public)只能这样获取:MyObject. instanceField。而MyClass的静态域staticField(存取限制为public)只能这样获取:MyClass.staticField。注意静态域不能像传统C++那样通过对象获取,也就是说MyObject.staticField的用法是错误的,不能通过编译器编译。

域的存取限制集中体现了面向对象编程的封装原则。如前所述,C#中的存取限制修饰符有5种,这5种对域都适用。C#只是用internal扩展了C++原来的friend修饰符。在有必要使两个类的某些域互相可见时,我们将这些类的域声明为internal,然后将它们放在一个组合体内编译即可。如果需要对它们的继承子类也可见的话,声明为protected internal即可。实际上这也是组合体的本来意思--将逻辑相关的类组合封装在一起。

C#引入了readonly修饰符来表示只读域,const来表示不变常量。顾名思义对只读域不能进行写操作,不变常量不能被修改,这两者到底有什么区别呢?只读域只能在初始化--声明初始化或构造器初始化--的过程中赋值,其他地方不能进行对只读域的赋值操作,否则编译器会报错。只读域可以是实例域也可以是静态域。只读域的类型可以是C#语言的任何类型。但const修饰的常量必须在声明的同时赋值,而且要求编译器能够在编译时期计算出这个确定的值。const修饰的常量为静态变量,不能够为对象所获取。const修饰的值的类型也有限制,它只能为下列类型之一(或能够转换为下列类型的):sbyte, byte, short, ushort, int, uint, long, ulong, char, float, double, decimal, bool, string, enum类型, 或引用类型。值得注意的是这里的引用类型,由于除去string类型外,所有的类型出去null值以外在编译时期都不能由编译器计算出他们的确切的值,所以我们能够声明为const的引用类型只能为string或值为null的其他引用类型。显然当我们声明一个null的常量时,我们已经失去了声明的意义--这也可以说是C#设计的尴尬之处!

这就是说,当我们需要一个const的常量时,但它的类型又限制了它不能在编译时期被计算出确定的值来,我们可采取将之声明为static readonly来解决。但两者之间还是有一点细微的差别的。看下面的两个不同的文件:

//file1.cs
//csc /t:library file1.cs
using System;
namespace MyNamespace1
{
public class MyClass1
{
public static readonly int myField = 10;
}
}
//file2.cs
//csc /r:file1.dll file2.cs
using System;
namespace MyNamespace2
{
public class MyClass1
{
public static void Main()
{
Console.WriteLine(MyNamespace1.MyClass1.myField);
}
}
}

我们的两个类分属于两个文件file1.cs 和file2.cs,并分开编译。在文件file1.cs内的域myField声明为static readonly时,如果我们由于某种需要改变了myField的值为20,我们只需重新编译文件file1.cs为file1.dll,在执行file2.exe时我们会得到20。但如果我们将static readonly改变为const后,再改变myField的初始化值时,我们必须重新编译所有引用到file1.dll的文件,否则我们引用的MyNamespace1.MyClass1.myField将不会如我们所愿而改变。这在大的系统开发过程中尤其需要注意。实际上,如果我们能够理解const修饰的常量是在编译时便被计算出确定的值,并代换到引用该常量的每一个地方,而readonly时在运行时才确定的量--只是在初始化后我们不希望它的值再改变,我们便能理解C#设计者们的良苦用心,我们才能彻底把握const和readonly的行为!

域的初始化是面向对象编程中一个需要特别注意的问题。C#编译器缺省将每一个域初始化为它的默认值。简单的说,数值类型(枚举类型)的默认值为0或0.0。字符类型的默认值为'\x0000'。布尔类型的默认值为false。引用类型的默认值为null。结构类型的默认值为其内的所有类型都取其相应的默认值。虽然C#编译器为每个类型都设置了默认类型,但作为面向对象的设计原则,我们还是需要对变量进行正确的初始化。实际上这也是C#推荐的做法,没有对域进行初始化会导致编译器发出警告信息。C#中对域进行初始化有两个地方--声明的同时进行初始化和在构造器内进行初始化。如前所述,域的声明初始化实际上被编译器作为赋值语句放在了构造器的内部的最开始处执行。实例变量初始化会被放在实例构造器内,静态变量初始化会被放在静态构造器内。如果我们声明了一个静态的变量并同时对之进行了初始化,那么编译器将为我们构造出一个静态构造器来把这个初始化语句变成赋值语句放在里面。而作为const修饰的常量域,从严格意义上讲不能算作初始化语句,我们可以将它看作类似于C++中的宏代换。

属性

属性可以说是C#语言的一个创新。当然你也可以说不是。不是的原因是它背后的实现实际上还是两个函数--一个赋值函数(get),一个取值函数(set),这从它生成的中间语言代码可以清晰地看到。是的原因是它的的确确在语言层面实现了面向对象编程一直以来对“属性”这一OO风格的类的特殊接口的诉求。理解属性的设计初衷是我们用好属性这一工具的根本。C#不提倡将域的保护级别设为public而使用户在类外任意操作--那样太不OO,或者具体点说太不安全!对所有有必要在类外可见的域,C#推荐采用属性来表达。属性不表示存储位置,这是属性和域的根本性的区别。下面是一个典型的属性设计:

using System;
class MyClass
{
int integer;
public int Integer
{
get {return integer;}
set {integer=value;}
}
}
class Test
{
public static void Main()
{
MyClass MyObject=new MyClass();
Console.Write(MyObject.Integer);
MyObject.Integer++;
Console.Write(MyObject.Integer);
}
}

一如我们期待的那样,程序输出0 1。我们可以看到属性通过对方法的包装向程序员提供了一个友好的域成员的存取界面。这里的value是C#的关键字,是我们进行属性操作时的set的隐含参数,也就是我们在执行属性写操作时的右值。

属性提供了只读(get),只写(set),读写(get和 set)三种接口操作。对域的这三种操作,我们必须在同一个属性名下声明,而不可以将它们分离,看下面的实现:

class MyClass
{
private string name;
public string Name 
{           
get { return name; }
}
public string Name 
{          
set { name = value; }
}
}

上面这种分离Name属性实现的方法是错误的!我们应该像前面的例子一样将他们放在一起。值得注意的是三种属性(只读,只写,读写)被C#认为是同一个属性名,看下面的例子:

class MyClass
{
protected int num=0;
public int Num
{
set 
{
num=value;
}
}
}
class MyClassDerived: MyClass
{
new public int Num
{
get 
{
return num;
}
}
}
class Test
{
public static void Main()
{
MyClassDerived MyObject = new MyClassDerived();
//MyObject.Num= 1;  //错误 ! 
((MyClass)MyObject).Num = 1;   
}
}

我们可以看到MyClassDerived中的属性Num-get{}屏蔽了MyClass中属性Num-set{}的定义。

当然属性远远不止仅仅限于域的接口操作,属性的本质还是方法,我们可以根据程序逻辑在属性的提取或赋值时进行某些检查,警告等额外操作,看下面的例子:

class MyClass
{
private string name;
public string Name 
{           
get { return name; }
set 
{
if (value==null)
name="Microsoft";
else
name=value;
}
}
}

由于属性的方法的本质,属性当然也有方法的种种修饰。属性也有5种存取修饰符,但属性的存取修饰往往为public,否则我们也就失去了属性作为类的公共接口的意义。除了方法的多参数带来的方法重载等特性属性不具备外, virtual, sealed, override, abstract等修饰符对属性与方法同样的行为,但由于属性在本质上被实现为两个方法,它的某些行为需要我们注意。看下面的例子:

abstract class A
{
int y;
public virtual int X 
{
get { return 0; }
}
public virtual int Y 
{
get { return y; }
set { y = value; }
}
public abstract int Z { get; set; }
}
class B: A
{
int z;
public override int X 
{
get { return base.X + 1; }
}
public override int Y 
{
set { base.Y = value < 0? 0: value; }
}
public override int Z 
{
get { return z; }
set { z = value; }
}
}

这个例子集中地展示了属性在继承上下文中的某些典型行为。这里,类A由于抽象属性Z的存在而必须声明为abstract。子类B中通过base关键字来引用父类A的属性。类B中可以只通过Y-set便覆盖了类A中的虚属性。

静态属性和静态方法一样只能存取类的静态域变量。我们也可以像做外部方法那样,声明外部属性。

第八讲 索引器与操作符重载

索引器

索引器(Indexer)是C#引入的一个新型的类成员,它使得对象可以像数组那样被方便,直观的引用。索引器非常类似于我们前面讲到的属性,但索引器可以有参数列表,且只能作用在实例对象上,而不能在类上直接作用。下面是典型的索引器的设计,我们在这里忽略了具体的实现。

class MyClass
{
    public object this [int index]
    {
        get
        {
            // 取数据
        }
        set 
        {
            // 存数据
        }
    }
}

索引器没有像属性和方法那样的名字,关键字this清楚地表达了索引器引用对象的特征。和属性一样,value关键字在set后的语句块里有参数传递意义。实际上从编译后的IL中间语言代码来看,上面这个索引器被实现为:

class MyClass
{
    public object get_Item(int index)
    {
          // 取数据
    }
    public void set_Item(int index, object value)
 {
//存数据
    }
}

由于我们的索引器在背后被编译成get_Item(int index)和set_Item(int index, object value)两个方法,我们甚至不能再在声明实现索引器的类里面声明实现这两个方法,编译器会对这样的行为报错。这样隐含实现的方法同样可以被我们进行调用,继承等操作,和我们自己实现的方法别无二致。通晓C#语言底层的编译实现为我们下面理解C#索引器的行为提供了一个很好的基础。

和方法一样,索引器有5种存取保护级别,和4种继承行为修饰,以及外部索引器。这些行为同方法没有任何差别,这里不再赘述。唯一不同的是索引器不能为静态(static),这在对象引用的语义下很容易理解。值得注意的是在覆盖(override)实现索引器时,应该用base[E]来存取父类的索引器。

和属性的实现一样,索引器的数据类型同时为get语句块的返回类型和set语句块中value关键字的类型。

索引器的参数列表也是值得注意的地方。“索引”的特征使得索引器必须具备至少一个参数,该参数位于this关键字之后的中括号内。索引器的参数也只能是传值类型,不可以有ref(引用)和out(输出)修饰。参数的数据类型可以是C#中的任何数据类型。C#根据不同的参数签名来进行索引器的多态辨析。中括号内的所有参数在get和set下都可以引用,而value关键字只能在set下作为传递参数。

下面是一个索引器的具体的应用例子,它对我们理解索引器的设计和应用很有帮助。

using System;
class BitArray
{
int[] bits;
int length;
public BitArray(int length) 
{
if (length < 0) 
throw new ArgumentException();
bits = new int[((length - 1) >> 5) + 1];
this.length = length;
}
public int Length 
{
get { return length; }
}
public bool this[int index] 
{
get 
{
if (index < 0 || index >= length) 
throw new IndexOutOfRangeException();
else
return (bits[index >> 5] & 1 << index) != 0;
}
set
{
if (index < 0 || index >= length)
throw new IndexOutOfRangeException();
else if(value) 
bits[index >> 5] |= 1 << index;
else
bits[index >> 5] &= ~(1 << index);
}
}
}
class Test
{
static void Main() 
{
BitArray Bits=new BitArray(10);
for(int i=0;i<10;i++)
Bits[i]=(i%2)==0;
                  
      Console.Write(Bits[i]+"  ");
}
}

编译并运行程序可以得到下面的输出:

True False True False True False True False True False 

上面的程序通过索引器的使用为用户提供了一个界面友好的bool数组,同时又大大降低了程序的存储空间代价。索引器通常用于对象容器中为其内的对象提供友好的存取界面--这也是为什么C#将方法包装成索引器的原因所在。实际上,我们可以看到索引器在.NET Framework类库中有大量的应用。

操作符重载

操作符是C#中用于定义类的实例对象间表达式操作的一种成员。和索引器类似,操作符仍然是对方法实现的一种逻辑界面抽象,也就是说在编译成的IL中间语言代码中,操作符仍然是以方法的形式调用的。在类内定义操作符成员又叫操作符重载。C#中的重载操作符共有三种:一元操作符,二元操作符和转换操作符。并不是所有的操作符都可以重载,三种操作符都有相应的可重载操作符集,列于下表:

一元操作符 + - ! ~ ++ -- true false 
二元操作符 + - * / % & | ^ << >> == != > < >= <= 
转换操作符 隐式转换()和显式转换() 

重载操作符必须是public和static 修饰的,否则会引起编译错误,这在操作符的逻辑语义下是不言而喻的。父类的重载操作符会被子类继承,但这种继承没有覆盖,隐藏,抽象等行为,不能对重载操作符进行virtual sealed override abstract修饰。操作符的参数必须为传值参数。我们下面来看一个具体的例子:

using System;
class Complex
{
double  r, v;  //r+ v i
public Complex(double r, double v)
{
this.r=r;
this.v=v;
}
public static Complex operator +(Complex a, Complex b) 
{
return new Complex(a.r+b.r, a.v+b.v);
}
public static Complex operator -(Complex a)
{
return new Complex(-a.r,-a.v);
}
public static Complex operator ++(Complex a) 
{
  double r=a.r+1;
  double v=a.v+1;
return new Complex(r, v);
}
public void Print()
{
Console.Write(r+" + "+v+"i");
}
}
class Test
{
public static void Main()
{
Complex a=new Complex(3,4);
Complex b=new Complex(5,6);

Complex c=-a;
c.Print();
Complex d=a+b;
d.Print();

a.Print();
Complex e=a++;
a.Print();
e.Print();
Complex f=++a;
a.Print();
f.Print();

}
}

编译程序并运行可得到下面的输出:

-3 + -4i 8 + 10i 3 + 4i 4 + 5i 3 + 4i 5 + 6i 5 + 6i 

我们这里实现了一个“+”号二元操作符,一个“-”号一元操作符(取负值),和一个“++”一元操作符。注意这里,我们都没有对传进来的参数作任何改变--这在参数是引用类型的变量是尤其重要,虽然重载操作符的参数只能是传值方式。而我们在返回值时,往往需要“new”一个新的变量--除了true和false操作符。这在重载“++”和“--” 操作符时尤其显得重要。也就是说我们做在a++时,我们将丢弃原来的a值,而取代的是新的new出来的值给a! 值得注意的是e=a++或f=++a中e的值或f的值根本与我们重载的操作符返回值没有一点联系!它们的值仅仅是在前置和后置的情况下获得a的旧值或新值而已!前置和后置的行为不难理解。

操作符重载对返回值和参数类型有着相当严格的要求。一元操作符中只有一个参数。操作符“++”和“--”返回值类型和参数类型必须和声明该操作符的类型一样。操作符“+ - ! ~”的参数类型必须和声明该操作符的类型一样,返回值类型可以任意。true和false操作符的参数类型必须和声明该操作符的类型一样,而返回值类型必须为bool,而且必须配对出现--也就是说只声明其中一个是不对的,会引起编译错误。参数类型的不同会导致同名的操作符的重载--实际上这是方法重载的表现。

二元操作符参数必须为两个,而且两个必须至少有一个的参数类型为声明该操作符的类型。返回值类型可以任意。有三对操作符也需要必须配对声明出现,它们是“==”和“!=”,“>”和“<”,“>=”和“<=”。需要注意的是两个参数的类型不同,虽然类型相同但顺序不同都会导致同名的操作符的重载。

转换操作符为不同类型之间提供隐式转换和显式转换,主要用于方法调用,转型表达和赋值操作。转换操作符对其参数类型(被转换类型)和返回值类型(转换类型)也有严格的要求。参数类型和返回值类型不能相同,且两者之间必须至少有一个和定义操作符的类型相同。转换操作符必须定义在被转换类型或转换类型任何其中一个里面。不能对系统定义过的转换操作进行重新定义。两个类型也都不能是object或接口类型,两者之间不能有直接或间接的继承关系--这三种情况系统已经默认转换。我们来看一个例子:

using System;
public struct Digit
{
byte value;
public Digit(byte value) 
{
if (value < 0 || value > 9) 
throw new ArgumentException();
this.value = value;
}
public static implicit operator byte(Digit d) 
{
return d.value;
}
public static explicit operator Digit(byte b) 
{
return new Digit(b);
}
}

上面的例子提供了Digit类型和byte类型之间的隐式转换和显式转换。从Digit到byte的转换为隐式转换,转换过程不会因为丢失任何信息而抛出异常。从byte到Digit的转换为显式转换,转换过程有可能因丢失信息而抛出异常。实际上这也为我们揭示了什么时候声明隐式转换,什么时候声明显示转换的设计原则。不能对同一参数类型同时声明隐式转换和显式转换。隐式转换和显式转换无需配对使用--虽然C#推荐这样做。

实际上可以看到,对于属性,索引器和操作符这些C#提供给我们的界面操作,都是方法的某种形式的逻辑抽象包装,它旨在为我们定义的类型的用户提供一个友好易用的界面--我们完全可以通过方法来实现它们实现的功能。理解了这样的设计初衷,我们才会恰当,正确地用好这些操作,而不致导致滥用和错用。


查看本文来源
    • 评论
    • 分享微博
    • 分享邮件
    邮件订阅

    如果您非常迫切的想了解IT领域最新产品与技术信息,那么订阅至顶网技术邮件将是您的最佳途径之一。

    重磅专题
    往期文章
    最新文章