基元类型、引用类型和值类型
5.1编程语言的基元类型
FCL(Framework Class Library) Framework 类库,FCL是 .net Framework 包含的一组DLL程序集的统称,FCL包含了提供了很多功能,它是生成.NET Framework 应用程序、组件和控件的基础。FCL由命名空间组成。每个命名空间都包含可在程序中应用的类、结构、委托和接口等。常见的命名空间有System、System.Windows等。
什么是基元类型
编译器直接支持的数据类型称为基元类型(primitive type)。基元类型直接映射到Framework类库(FCL)中存在的类型。比如以下4行代码都是正确的,生成的IL代码也是相同的。
int a = 0;
System.Int32 a =0;
int a = new int();
System.Int32 a = new System.Inte32();
下表列出了FCL类型在C#中对应的基元类型:
C#基元类型 | FCL类型 | 符合CLS | 说明 |
---|---|---|---|
sbyte | System.SByte | NO | 有符号8位值 |
byte | System.Byte | YES | 无符号8位值 |
short | System.Int16 | YES | 有符号16位值 |
ushort | System.UInt16 | NO | 无符号16位值 |
int | System.Int32 | YES | 有符号32位值 |
uint | System.UInt32 | NO | 无符号32位值 |
long | System.Int64 | YES | 有符号64位值 |
ulong | System.UInt64 | NO | 无符号64位值 |
char | System.Char | YES | 16位Unicode字符 |
float | System.Single | YES | IEEE32位浮点值 |
double | System.Double | YES | IEEE64位浮点值 |
bool | System.Boolean | YES | 一个true/false值 |
decimal | System.Decimal | YES | 一个128位高精度浮点值,常用于不容许舍入误差的金融计算 |
string | System.String | YES | 一个字符数组 |
object | System.Object | YES | 所有类型的基类型 |
dynamic | System.Object | YES | 对于CLR,dynamic和object完全一致。然而,C#编译器允许使用一个简单的语法,让dynamic变量参与动态调度 |
可以假定C#编译器自动假定在所有的源代码文件中添加了以下using指令:
using sbyte = System.Sbyte;
using byte = System.Byte;
using int = System.Int32;
using uint = System.UInt32;
......
表达式由字面量构成,编译器在编译的时候就能完成表达式求值
Boolean found=false;//生成的代码将found设为0
Int32 x=100+20+3;//x设为123
String a="a"+"bc";//s设为“abc”
checked和unchecked基元类型操作
此指令就用来检查溢出和不检查溢出,而默认是unchecked,不过这个可以改。检查溢出就报异常,不检查溢出就回滚。
对基元类型执行许多算数运算都可能造成溢出。不同语言处理溢出也是不同的。C和C++不将溢出视为错误,并允许值回滚;应用程序"若无其事"的运行着。相反,Microsoft Visual Basic总是将溢出视为错误,并会抛出异常。
CLR提供了一些特殊的IL指令,允许编译器选择它认为最恰当的行为。CLR有一个add指令,将两值相加但不检查溢出。还有一个add.ovf指令,作用是两值相加,溢出时抛出异常。类似的还有sub/sub.ovf等。
C#允许开发人员自己决定如何处理溢出。溢出检查默认是关闭的。开发人员可以使用C#编译器控制溢出的一个办法是使用/checked+编译器开关。
C#通过提供checked和unchecked操作符来实现局部是否检查发生溢出。
unchecked:
UInt32 invalid = unchecked((UInt32)(-1)); //OK
checked:
Byte b = 100;
b = checked((Byte)(n+200)); //抛出溢出异常
C#还提供checked和unchecked语句
checked{
Byte b = 100;
b = checked((Byte)(n+200));
}
在Visaul Studio的"高级生成设置"对话框中可以指定编译器是否检查溢出。
System.Decimal类型是一个非常特殊的类型。虽然C#将Decimal视为一个基元类型,但CLR则不然,也就是说CLR没有相应的IL指令来决定如何处理Decimal值。Decimal值得处理速度是要慢于其他CLR基元类型的值得处理速度。还有对Decimal来说,checked和uncheked操作符、语句和编译器都是无效的,Decimal溢出时是一定会抛出异常的。
5.2引用类型和值类型
CLR支持两种类型:引用类型和值类型。
值类型
虽然FCL中大多数都是引用类型,但开发人员用的最多的还是值类型。引用类型总是在托管堆上分配的,C#的new操作符会返回对象的内存地址——也就是指向对象数据的内存地址。
使用引用类型必须注意到一些性能问题,首先考虑一下事实:
1)内存必须从托管堆上分配。
2)堆上分配的每个对象都有一些额外的成员(比如前面提到过得"类型对象指针"和"同步块索引"),这些成员必须初始化。
3)对象中的其他字节(为字段而设)总是设为零。
4)从托管堆上分配一个对象时,可能强制执行一次垃圾回收操作。
如果所有类型都是引用类型,应用程序的性能会显著下降。为了提升简单的、常用的类型的性能,CLR提供了名为**“值类型”**的轻量型类型。
值类型的实例一般在线程栈上分配的(虽然也可作为字段嵌入一个引用类型的对象中)。在代表值类型的实例的一个变量中,并不包含一个指向实例的指针。相反,变量中包含了实例本身的字段。
由于变量已经包含了实例的字段,所以为了操作实例中的字段,不再需要提供一个指针。值类型的实例不受垃圾回收器的控制。因此,值类型的使用缓解了托管堆中的压力,并减少了一个应用程序在其生存期内需要进行的垃圾回收次数。
.NET Framework SDK文档明确指出,在查看一个类型时,任何称为"类"的类型都是引用类型。如System.Exception类、System.Random类等引用类型。文档将所有值类型都称为结构或枚举。如System.Int32结构、System.Boolean结构等值类型。
所有值类型都必须从System.ValueType派生。所有枚举类型都从System.Enum抽象类派生,而System.Enum又是从System.ValueType派生的。CLR和所有编程语言都给予枚举特殊待遇,以后会提到。
所有值类型都是隐式密封的(sealed),目的是防止将一个值类型用于其他任何引用类型或值类型的基类型。
在托管代码中,要由定义类型的开发人员决定在什么地方分配类型的实例,使用该类型的人对此并无控制权。
//引用类型
class SomeRef
{
public Int32 x;
}
//值类型
struct SomeVal
{
public Int32 x;
}
static void Main(string[] args)
{
SomeRef r1 = new SomeRef(); //在堆上分配
SomeVal v1 = new SomeVal(); //在栈上分配
r1.x = 5;
v1.x = 5;
Console.WriteLine(r1.x); //5
Console.WriteLine(v1.x); //5
SomeRef r2 = r1;
SomeVal v2 = v1;
r1.x = 8;
v1.x = 9;
Console.WriteLine(r1.x); //8
Console.WriteLine(r2.x); //8
Console.WriteLine(v1.x); //9
Console.WriteLine(v2.x); //5
}
建议条件
除非以下条件都能满足,否则不应该将一个类型声明成值类型:
1)类型具有基元类型的行为。
2)类型不需要从其他任何类型继承
3)类型也不会派生出其他类型。
类型实例的大小应该在考虑之列,因为默认情况下,实参是以传值方式传递的,这会造成对值类型实例中的字段进行复制,从而影响性性能。同样的,被定义为返回一个值类型的一个方法在返回时,实例中的字段会赋值到调用者分配的内存中,从而影响性能。
所以,选用值类型还应满足:
1)类型的实例较小(约16字节或者更小)
2)类型的实例较大(大于16字节),但不作为方法的实参传递,也不从方法返回。
值类型的主要优势在于它们不作为对象在托管堆上分配。
值类型和引用类型的区别:
1)值类型对象有两种表示形式:未装箱(unboxed)和已装箱(boxed)。引用类型总是处于已装箱形式。
2)值类型是从System.ValueType派生的。该类型提供了与System.Object定义的相同的方法。然而,System.ValueType重写了Equals方法和GetHashCode方法。由于这个默认实现存在性能问题,所以定义自己的值类型时,应该重写Equals和GetHashCode方法,并提供它们的显示实现。
3)值类型的所有方法都不能是抽象的,而且所有方法都是隐式密封(sealed)方法。
4)引用类型的变量包含的是堆上的一个对象的地址。默认情况,在创建一个引用类型的变量时,它被初始化为null,表明引用类型的变量当前不指向一个有效对象。相反,值类型初始化是,所有的成员都会初始化为0。由于值类型的变量不是指针,所以在访问一个值类型时,不会抛出NullReferenceException异常。CLR确实提供了一个特殊的特性,能为值类型 添加"可空"标识。如"int?"
5) 将一个值类型的变量赋给另一个值类型变量,会执行一次逐字段复制。将引用类型赋给另一个引用类型时,只复制内存地址。
6)由于未装箱的值类型不再堆上分配,所以一旦定义了该类型的一个实例的方法不再处于活动状态,为他们分配的内存就会被释放。这意味着值类型的实例在其内存被回收时,不会通过Finalize方法接收到一个通知。
引用类型和值类型的区别:
引用类型 | 值类型 |
---|---|
从托管堆中分配 | 从线程的堆栈中分配 |
对象考虑垃圾回收机制 | 不考虑垃圾回收机制 |
所有类都是引用 | 结构或枚举都是值类型 |
System.Object | 继承自System.ValueType |
只有装箱形式 | 有两种形式:装箱和未装箱 |
可以继承和派生 | 不能作为基类,不能有虚方法 |
引用类型变量初始化时默认为null | 初始化时默认为0值 |
复制时只拷贝内存地址 | 复制时“字段对字段”的拷贝 |
何时使用struct, 何时使用class?
同时满足以下三个条件:
**1)**类型中没有成员会会修改类型的实例字段;
**2)**类型不需要从其它任何类型继承;
3)类型不会派生出其他任何类型;
并满足以下两个条件中的一个:
**1)**类型的实例小于16字节;
**2)**类型的实例大于16字节,但不作为方法的实参传递,也不作为方法的返回值。此时,可以把这个类型定义为struct, 否则定义为class.
定义一个值类型应该注意什么?
1)由于System.ValueType重写了Equals方法和GetHashCode方法,在定义自己的值类型时,也要重写这两个方法并提供它们的显式实现;
**2)**所有方法都不能是虚方法。
SomeVal为一个struct,包含一个实例字段x, 以下代码有何不同?
SomeVal v = new SomeVal();
SomeVal v;
两行代码都会在线程栈上分配内存空间,唯一的不同在于C#会认为new操作符对v进行了初始化。如果用第一行代码定义v, 打印v.x时会打印出默认值0, 如果使用第二行代码定义v, 打印v.x时会出错。
5.3值类型的装箱和拆箱
装箱过程
在CLR中为了将一个值类型转换成一个引用类型,要使用一个名为装箱的机制。
将值类型转换为引用类型。当我们把值类型参数传递给需要引用类型参数的方法时,会自动进行装箱操作。过程如下:
- 从托管堆为要生成的引用类型分配大小。大小为:值类型实例本身的大小+额外空间(类型对象指针和同步块索引SyncBlockIndex)。
- 值类型的字段复制到新的分配的堆内存
- 返回托管堆中新分配内存的地址。也就是指向对象的引用。
拆箱过程
拆箱不是直接将装箱过程倒过来。拆箱的代价比装箱低得多。拆箱其实就是一个获取一个指针的过程,该指针指向包含在一个对象中的原始值类型(数据字段)。事实上,指针指向的是已装箱实例中的未装箱部分。所以,和装箱不同,拆箱不要求在内存中复制字节。还有一个重点就是,拆箱之后,往往会紧接着发生一次字段的复制操作。
获取指向对象中包含的值类型部分的指针。一般拆箱之后会进行字段拷贝操作,两个操作加起来才是真正与装箱互反的操作。过程如下:
- 如果引用为Null,则抛出NullReferenceException异常。
- 如果引用对象不是一个期望值类型的已装箱对象,会抛出InvalidCastException异常。
- 返回一个指向包含在已装箱对象中值类型部分的指针。
上面第二条意味着一下代码不会如你预期的那样工作:
public static void Main(){
Int32 x = 5;
Object o = x;
Int16 y = (Int16) o;//抛出InvalidCastException异常
}
在对一个对象进行拆箱的时候,只能将其转型为原始未装箱时的值类型——Int32,下面是正确的写法:
public static void Main(){
Int32 x = 5;
Object o = x; //对x进行装箱,o引用已装箱的对象
Int16 y = (Int16) (Int32) o; //先拆箱为正确的类型,在进行装箱
}
前面说过,在进行一次拆箱后,经常会紧接着一次字段的复制。以下演示了拆箱和复制操作:
public static void Main() {
Point p = new Point(); //栈变量
p.x = p.y = 1;
object o = p; //对p进行装箱,o引用已装箱的实例
p = (Point) o; //对o进行拆装,将字段从已装箱的实例复制到栈变量
}
在最后一行,C#编译器会生成一条IL指令对o进行拆箱,并生成另一条IL指令将这些字段从堆复制到基于栈的变量p中。
再看看一下代码:
public static void Main() {
Point p = new Point(); // 栈变量
p.x = p.y = 1;
object o = p; // 对p进行装箱,o引用已装箱的实例
// 将Point的x字段变成2
p = (Point) o; // 对o进行拆装,将字段从已装箱的实例复制到栈变量
p.x = 2; // 更改变量的状态
o = p; // 对p进行装箱,o引用已装箱的实例
}
最后三行代码唯一的目的就是将Point的x字段从1变成2.为此,首先要执行一次拆箱,在执行一次字段复制,在更改字段(在栈上),最后执行一次装箱(从而在托管堆上创建一个全新的已装箱实例)。希望你能体会到装箱和拆箱/复制操作对应用程序性能的影响。
在看个演示装箱和拆箱的例子:
private static void Main(string[] args)
{
Int32 v = 5; // 创建一个伪装箱的值类型变量
Object o = v; // o引用一个已装箱的、包含值5的Int32
v = 123; // 将未装箱的值修改成为123
Console.WriteLine(v + "," + (Int32)o); //显示"123,5"
}
你可以看出上述代码进行了几次装箱操作?是3次
主要原因是在Console.WriteLine方法上。
Console.WriteLine方法要求获取一个String对象,为了创建一个String对象,C#编译器生成的代码来调用String对象的静态方法Concate。该方法有几个重载的版本,唯一区别就是参数数量,在本例中需要连接三个数据项来创建一个字符串,所以编译器会选择以下Concat方法来调用:
public static String Concat(Objetc arg0, Object arg1, Onject arg2);
所以,如果像下面写对WriteLine的调用,生成的IL代码将具有更高的执行效率:
Console.WriteLine(v + "," + o); //显示"123,5"
这只是移除了变量o之前的(Int32)强制转换。就避免了一次拆箱和一次装箱。
我们还可以这样调用WriteLine,进一步提升上述代码的性能:
Console.WriteLine(v.ToString() + "," + o); //显示"123,5"
现在,会为未装箱的值类型实例v调用ToString方法,它返回一个String。String类型已经是引用类型,所以能直接传给Concat方法,不需要任何装箱操作。
装箱与拆箱简单总结
装箱就是把本来在栈中的值类型,在堆中新开辟一个内存空间,把值类型的数据复制进去,并增加引用类型都有的类型指针和同步块索引,然后返回这个内存空间引用地址。
拆箱就是反过来,先获取装箱对象中各个字段的地址,再将这些字段包含的值从堆复制到栈。
由上面看出装箱拆箱其实很影响效率,所以写代码的时候应该避免。
另外装箱的值类型,调用自身的函数修改自己的字段的时候并不会修改堆里的数据,只会先转换为值类型,再修改栈里的数据。
如果要修改堆里的数据,只能定义一个接口,让值类型里的函数去实现这个接口,然后想修改堆里的数据,那么就把对象转换为这个接口,那么去修改的话,就会修改堆里的数据。
如果你看不懂上面的话,那么请慎用值类型,作者推荐不要定义任何会修改实例字段的属性或者方法,甚至可以将值类型的所有字段都加上readonly。
其实结构体什么的用类就好了,大的结构体传参啊什么的,搞不好还会引起栈溢出。毕竟每个线程也就1MB的栈空间。
一个值类型调用System.Object类定义的方法会不会发生装箱?
如果值类型重写了System.Object定义的虚方法(Equals, GetHashCode, ToString),调用时不会发生装箱,如果重写的方法中调用了基类的实现,则需要进行装箱;如果值类型调用了非虚方法(GetType, MemberwiseClone),则会发生装箱。
对象相等性和同一性
System.Object类型提供了一个名为Equals的虚方法,它的作用是在两个对象包含相同的值得前提下返回true。如:
public class Object{
publick virtual Boolean Equals(Object obj) {
//如果两个引用指向同一个对象,它们肯定包含相同的值
if ( this == obj ) return true;
//假定对象不包含相同的值
return false;
}
}
对于Object的Equals方法的默认实现来说,它实现的实际是同一性,而非相等性。
下面展示了如何在内部正确实现一个Equals方法。
1)如果obj实参为null,就返回false,因为在调用非静态的Equals方法时,this所标识的当前对象显然不为null.
2)如果this和obj实参引用同一个对象,就返回true。在比较包含大量字段的对象时,这一步有助性能提升。
3)如果this和obj实参引用不同类型的对象,就返回false。一个String对象显然不等于一个FileStream对象。
4)针对类型定义的每个实例字段,将this对象中的值与obj对象中的值进行比较。任何字段不相等,就返回false。
5)调用基类的Equals方法,以便比较它定义的任何字段。如果基类的Equals方法返回false,就返回false;否则返回true;
例如:
public class Object{
public virtual Boolean Equals(Object obj) {
//要比较的对象不能为null
if (obj == null ) return false;
//如果对象类型不同,则肯定不相等
if (this.GetType() != obj.GetType()) return false;
//如果对象属于相同的类型,那么在它们所有字段都匹配的前提下返回true
//由于System.Object没有定义任何字段,所以字段是匹配的
return true;
}
}
由于,一个类型能重写Object的Equals方法,所以不能再调用这个Equals方法来测试同一性。为了修正这一问题,Object提供了一个静态方法ReferenEquals,其原型如下:
public class Object{
public static Boolean ReferenceEquals(Object objA , Object objB) {
retuen ( onjA == objB );
}
}
如果想检查同一性,务必调用ReferenceEquals,而不应该使用C#的== 操作符,因为 == 操作符可能被重载。
System.ValueType(所有值类型的基类)重写了Object的Equals方法,并进行了正确的实现来执行值得相等性检查。
对象的相等性和同一性简单总结
之前我们讲过System.Object提供了名为Equals的虚方法,也就是说所有的对象都是有的,作用实在两个对象包含相同值的前提下返回true。
然而这个方法只是比较了同一性,而不是相等性。
实际上就是对应的同一性就是指两个对象的引用相同,也就是说它们指向同一个对象。
相等性如字面意思可知。也就说如果具备同一性,那么一定具备相等性。
由于Equals是个虚方法,可以重写,所以并不一定就是这个用法。
(System.ValueType就重写了,Equals实现的是相等性而不是统一性。但是这个Equals里的实现步骤用到了反射,而反射这个东西又是比较慢的,所以定义自己的值类型时可以考虑重写,从而提高性能。)
而==这个操作符也是可以重载的,除非你在比较之前,将两个对象的类型都转换为object。
于是Object又提供了一个静态方法,Object.ReferenceEquals,效果就如上所述,比较同一性。
注意,如果自己去定义一个值类型,然后重写Equals方法去实现相等性。那么应该注意让类型实现IEquatable接口的Equals方法(通常实现的Equals方法除了调用自己的类型参数,还应该有一个重载函数调用object参数,以便在内部调用类型安全的Equals方法。这个定义接收object对象的重写函数么就是对IEquatable的Equals的实现),还有重写==和!=操作符方法。
考虑到排序,所以可能还需要实现IComparable的CompareTo方法,和IComparable的类型安全的CompareTo方法。实现了这些方法,那么<,<=,>,>=在内部调用类型安全的CompareTo方法也OK了。
(如果你觉得上面自己定义值类型的实现还有什么地方觉得遗漏,最好的方法其实就是去看int类型的定义就ok了)
5.4对象哈希码
FCL的设计者认为,如果能将任何对象的任何实例放到一个哈希表集合中,会带来很多好处。为此,System.Object提供了虚方法GetHashCode,它能获取任意对象的Int32哈希值。
如果你重写了Equals方法,那么还应重写GetHashCode方法。因为在System.Collection.Hashtable类型、System.Collections.Generic.Dictionary类型以及其他一些集合实现中,要求两个对象为了相等,必须具有相同的哈希码,所以重写了Equals,那么还应该重写GetHashCode,确保相等性算法和对象哈希码算法是一致的。
System.ValueType实现的GetHashCode采用了反射机制(它的速度较慢),并对类型的实例字段执行的XOR运算。建议自己实现GetHashCode,这样才能确切的掌握它所做的事,而且你的实现会比ValueType的实现快一些。
在自己实现哈希表集合时,或调用GetHashCode,千万不要对哈希码进行持久化,因为哈希码很容易改变。
小结
System.Object提供了虚方法GetHashCode,它能获取任意对象的Int32哈希码。
另外重写了Equals方法,那么最好重写GetHashCode方法。
因为在System.Collections.Hashtable类型和System.Collections.Generic.Dictionary类型以及其它的一些集合的实现中,要求两个对象必须要有相同的Hash码才被视为相等。
所以重写Equals方法实现相等性后,最好也重写GetHashCode方法,以确保相等型算法和对象哈希码算法一致。
另外重写时可以调用基类的GetHashCode方法,但是不要调用Object或者ValueType的GetHashCode方法,因为两者的实现性能不高。
包含相同值的两个不同对象应返回相同的哈希码。(作者建议不要对哈希码进行持久化,因为哈希码的算法可能会改变)
5.5dynamic基元类型
为了方便开发人员使用反射或者与基本组件通信,C#编译器允许将一个表达式的类型标记为dynamic.还可将一个表达式的结果放在一个变量中,并将变量的类型标记为dynamic,然后,可以用这个dynamic表达式/变量调用一个成员,比如字段、属性/索引器、方法、委托等。
代码使用dynamic表达式/变量调用一个成员时,编译器会生成特殊的IL代码来描述所需要的操作。这种特殊的代码称为payload(有效载荷)。在运行时,payload代码根据当前有dynamic表达式/变量引用的对象的实际类型来决定具体执行的操作。
不要混淆dynamic和var。用var声明的局部变量只是一种简化语法,它要求编译器根据一个表达式推断具体的数据类型。var关键字只能用于声明方法内部的局部变量,而dynamic关键字可用于局部变量,字段和参数。表达式不能转型为var,但可以转型为dynamic。必须实现初始化化var声明的变量,但无需初始化用dynamic声明的变量。
dynamic表达式其实与System.Object一样的类型。编译器假定你在表达式上进行任何操作都是合法的,所以不会生成任何警告和错误。但是试图在运行时执行无效操作,就会抛出异常。
不能定义对dynamic进行扩展的扩展方法,但可以定义对Object进行扩展的扩展方法。
不能将Lambda表达式或者匿名方法作为实参传给dynamic方法调用,因为编译器不能推断出要使用的类型。
C#内建的动态求值功能所产生的额外开销是不容忽视的。虽然能用动态功能简化语法,但也要看是否值得。
小结
dynamic基元类型是为了方便开发人员使用反射或者与其它非.net组件通信.
代码使用dynamic表达式/变量时,编译器生成特殊的IL代码来描述这种操作。这种特殊的代码被称为payload(有效载荷)。在运行时,payload根据dynamic表达式/变量引用的对象的实际类型来决定具体执行的操作。
dynamic类型在编译后实际上是作为System.object,然而它在元数据中被应用了System.Runtime.CompilerServices.DynamicAttribute的实例。局部变量除外,因为Attribute显然不能在方法内部使用。
另外使用的泛型的dynamic的代码时,泛型代码已经变异好了,将类型视为Object,编译器不在泛型代码中生成payload,所以也不会执行动态调度。
且编译器允许使用隐式转型语法,将表达式从dynamic转型为其它类型。
dynamic a=123;
Int32 b=a;
另外dynamic表达式的求值结果也是一个dynamic类型。
不能定义对dynamic进行扩展的扩展方法,不能将lambda表达式或匿名方法作为实参传给dynamic使用。
为COM对象生成可由“运行时”调用的包装时。Com组件的方法中使用任何Variant实际都转化为dynamic,这称为动态化。显著简化了与COM对象的操作。
当然用dynamic会有额外的性能开销,因为会引用一些必须的dll,然后执行一些动态绑定啊什么的。如果只是一两处用这个东西,还是用传统方法好一点。(一般会引用Microsoft.CSharp.dll,与com组件操作还会用到System.Dynamic.dll)
dynamic和var的区别是什么?
dynamic是在运行时检测实际类型,var是在编译时编译器已经能够确定实际类型。例如定义了一个var str = “abc”; 这完全等同于定义了string str = “abc”; 编译器可以判断出str只能是string类型,用str调用string类型的方法和属性时,智能提示可以显示所有string类型的方法和属性,但当定义dynamic str = “abc”; 时,编译器并不知道str的实际类型,只会在运行时做判断,即使我们用str调用一个根本不存在的方法,同样可以通过变异,但运行时会报错。