第 32 讲:面向对象编程(四):索引器
上回我们说完了属性的基本用法,它把字段包装起来,避免外部调用的时候出现赋值的错误。今天我们来讲一下第二类属性:索引器(Indexer)。
索引器也称为有参属性(Parameterful Property),因为它除了和属性的基本用法差不多以外,还可以带有一些自定义的额外参数信息。索引这个词语在 C 语言里就已经存在了,不过它不能自定义。C 语言里,我们取一个数组或者指针对应位置的数据的时候,会使用索引运算符,就是那个中括号语法。
在 C# 里,为了灵活使用语法,C# 贴心地为我们提供了自定义索引器的机制,并把索引器当成了面向对象的一种成员,可见地位还是很高的。
Part 1 引例
假设我们设计了两个数据结构,一个是链表节点(Node),一个则是叫链表(List)。假设我们暂时不考虑那些增删改查的处理代码,而只关心基本的过程的话:
这里啰嗦一下前面好像没有说过的语法点。因为它们经常用到,但因为这些东西比起别的知识点来说不是那么重要,所以我们不必单独拿一节内容给大家讲,干脆就在这里说了。
首先是这里的
List
类下的Start
属性和Node
类下的Next
属性。这两个属性是没有set
方法的。在 C# 里,这个语法是允许的,这种属性被称为只读属性(Read-only Property)。这里的“只读”和字段的“只读”略有不同。字段里,“只读”指的是字段的数据无法修改而标记的readonly
get
和set
方法)的整合形式的成员,因此我们是无法对方法标记readonly
修饰符的;而另一方面,因为属性只有get
方法,因此它仅用来取值,所以用户就无法使用属性 = 数值
的赋值语法对后台的这个字段作赋值了。因此从这个角度来说,它是“只读”的:只用来读取数据的。从另外一个角度来说,我们无法使用属性 = 数值
的赋值语法,也就无从通过别的方式对这个后台字段赋值了。后台字段此时就是只读的了,因此这个字段可以标记readonly
修饰符。第二。我们可以看到
Node
类型里,有一个Node
自己这个数据类型的字段。这个语法称为递归类型成员(Recursed Member)。按道理讲,如果这个A
类型下有A
类型的字段的话,那么数据存储就可能无休止地使得存储空间膨胀起来直到撑爆内存。但实际上是这样的吗?C# 其实并不会导致这种情况的发生。它采用了指针的概念。Node
类型是用类写成的,每一个 C# 的类里,所有用到的类类型的成员,都会被认为一个特殊的指针。换句话说,这个成员实际上是一个指针变量,它指向的就是另外一块内存区域,存储的就是这个成员的数据内容了。而就原本的这个对象来说,假设它只有一个int
类型的数据,和一个A
这个类类型的数据的话,那么整体这个对象的存储空间只占据int
的大小,和一个指针类型的存储大小的总和。因此,数据本身并不会像我们想的那样:放在一起导致内存被撑爆。最后,因为类类型的东西在存储到别的对象里作为一个成员而存在的时候,它是一个指针。因此,类类型的所有数据,它们的默认数值是
null
。这个null
在字符串里讲过,它表示“没有内存分配”。是的,引用类型一般都较大,而且大小不固定,所以用null
专门表达这些类型的对象“还没有内存分配”。那么,如果我们没有针对字段赋初始数值的话,所有字段会被赋值为“它这个类型的默认数值”。比如说int
类型的默认数值是 0,而Node
类型的默认数值就是null
了。如果不写,就等价于在字段最后追加= null;
。
大概就这个样子。下面我们针对于这里的 List
类型来思考取元素的问题。
我们认为 List
是一个链表,因为它带有一个起头节点(Start
属性)。那么,我们通过移动“指针”,来达到遍历链表的过程。
比如这段代码。我们假设初始化的时候,给 temp
这个临时变量赋值 Start
。然后,让 temp
不断执行 temp = temp.Next
这样的过程。temp
是 Node
类型的,而 Node
类型里包含 Next
属性,这个 Next
属性的意思是“当前链表节点的下一个节点;如果链表没有下一个节点的话,这个属性的数值就是 null
”。那么不断 temp = temp.Next
就是在不断让 temp
的“指针”移动到下一个节点上去的过程。直到中间的条件 temp != null
不成立的时候,循环退出。
在循环体里,我们不断执行 count++
操作,这表示每移动一次 temp
的“指针”,count
就会自动增加一个单位。对,没有错,这个 count
就是在记录整根链表的总节点个数。
== null
和!= null
的语法判别对象是不是null
这个数值的。但是,这个等号和不等号后面是不能写别的东西的,比如== 3
。因为Node
类型怎么可能可以和一个int
作比较呢?要比较也只能比较两个Node
类型才对啊。C# 的
==
和!=
在值类型(就是前面说的那些个系统自带类型,string
除外)里,是判别数值是不是相同;但在类里,==
和!=
默认是判断指针是否一致,即两个对象是否指向的是同一块内存空间。显然,我们可以构造出两个数值上完全一样的Node
类型的变量,但我们使用==
和!=
是无法判别里面存储的数值是否一致的,因此一定要注意,==
和!=
在类里的比较行为。
所以,我们可以使用属性 Length
,并把这段代码抄进去,来计算链表的总长度。
是的,代码就这么写就完事了。也不是很难,对吧。
Part 2 索引器的语法
索引器之所以还有个名字叫做有参属性,是因为它的格式和属性的语法基本一样,也是使用 get
和 set
来表达传入的信息。不过因为它还会带有额外参数,因此还是有不一样的语法。
可以从代码里看到,get
是我们要实现的,set
方法实现起来太复杂了(牵扯到链表里上下两个节点的指针的变动),因此我们暂时不考虑,也没有必要这里讲这么难的东西,毕竟这不是数据结构的课程。
我们只是改良了一下 属性的 get
方法的代码。我们使用 p
表示和记录当前走到第几个节点了。如果 p
一直往下移动,走到和 index
的数值一样的时候,我们就认为,我们走到了这个节点上,于是就把 temp
的数据(之前说过 temp
是类类型的变量,因此它表达的是一个指针,相当于把指针作为数值)返回出去。另外一方面,如果我们传入的 index
不正常的话(比如链表还没有那么长,但你传入的 index
超过了这个总长度,显然就没有意义),于是我们返回 null
默认表达一个隐式信息:index
不正常。当然,这里你抛异常也行。
这里我们要说的是两个点。一个是语法上 public Node this[in index]
里的 Node
,另一个是 this[int index]
。
第一,Node
表达的意思和属性里表达的意思是一样的。写在索引器声明之前,如果是 get
方法,就表示这个属性的返回值类型;如果是 set
方法,就表示赋值的这个隐式变量 value
是什么类型的。索引器也是一样,get
方法里,表示这个返回值的类型是 Node
;在 set
方法里则表示 value
这个隐式变量是 Node
类型的(从外部传进来的数值)。虽然我们这里 set
方法没有实现,但你肯定知道它的用法,对吧。
第二,this[int index]
。这个语法看起来有点怪,我们说一下意思。this
是一个 C# 的关键字,它大概有四个用法:
索引器声明里(
this[int index]
里这个this
);构造器串联调用(
: this(参数)
里这个this
,这个还没讲);this
引用(this.成员
里这个this
,这个也没讲);扩展方法的参数(
this 类型 参数名
,这个是 C# 3 才有的特性,所以还没有讲)。
当然了,这些语法我们之后慢慢都会接触到,不过我们这里需要了解的是其中的第一个用法。这里的 this
你可以理解成“一个万能替换变量”。当 Node
类型的变量出来之后,这个变量名就可以直接配合索引器运算符 []
来表示取值。数组里,我们使用 arr[3]
来取 arr
这个数组的第 4 个元素的数值,而这里的 arr
就是一个数值类型的变量,它的写法格式就和这里的 this[参数]
是一样的,而具体碰到什么变量了,就把这个变量替换掉这里的 this
来表达索引器,比如说 list[3]
来取链表的第 4 个节点。这就是为什么索引器的声明语法这么奇怪的原因。
另外,在索引器参数里,我们是写成类似方法的参数一样的形式,先写类型,然后再写参数名。这个参数名是可以在 get
和 set
方法里用的。另外因为方法的特殊性,set
方法里你还可以用 value
这个隐式变量。
那么,语法我们就说到这里。下面我们来说一下索引器的使用。
Part 3 索引器的用法
下面我们说一下索引器的用法。其实也没啥好说的,和属性还有字段的方式完全一样,放在等号左侧就表示赋值(自动调用 set
方法);放在别的地方作为表达式计算的时候,就用的是 get
方法)。比如说:
这表示什么呢?这显然就表示我们要取出 list[2]
这个节点,然后取 Value
属性作为结果输出。显然 list[2]
就是 a
了,因此我们可以看到输出的结果是 1。
Part 4 其它的问题
最后说两个无关紧要的问题,可能你会有这样的疑惑。
4-1 参数个数是否无限制
索引器的参数(比如前面这个 int index
)可以无限制往后追加吗?
实际上,是可以的。索引器参数并没有规定非得只能有一个。你看,C# 里,矩形数组不就是可以后面用逗号分隔每一个数值吗?索引器在声明出来的时候,你完全可以写很多参数进去,比如这样:
这样是可以的。只是你在调用索引器的时候,你也得写这么多参数进去:variable[0, 1, 2, 3.0, 4F, "hello"]
。
4-2 索引器是否可以重载
是的,索引器也能重载,毕竟它也是有参的存在。方法、构造器之所以可以重载,是因为它们都是有参数的成员。索引器是可以重载的,因为它也可以自定义参数和参数类型。只是我们从 C 语言里学习过来,可能有一种经验主义思维,以为 C# 索引器好像只能用 int
一样。实际上,并不是。因此,只要满足前面那些个重载规则,索引器也是可以重载的。