可以调用Null的实例方法吗?

前几天有个网友问我一个问题:调用实例方法的时候为什么目标对象不能为Null。看似一个简单的问题,还真不是一句话就能说清楚的。而且这个结论也不对,当我们调用定义在某个类型的实例方法时,目标对象其实可以为Null。

一、从ECMA-335 Spec说起
二、Call V.S Callvirt
三、直接调用(C#)
四、静态方法
五、值类型实例方法
六、?.操作符
七、扩展方法

一、从ECMA-335 Spec说起

A method that is associated with an instance of the type is either an instance method or a virtual method (see §I.8.4.4). When they are invoked, instance and virtual methods are passed the instance on which this invocation is to operate (known as this or a this pointer).
The fundamental difference between an instance method and a virtual method is in how the implementation is located. An instance method is invoked by specifying a class and the instance method within that class. Except in the case of instance methods of generic types, the object passed as this can be null (a special value indicating that no instance is being specified) or an instance of any type that inherits (see §I.8.9.8) from the class that defines the method. A virtual
method can also be called in this manner. This occurs, for example, when an implementation of a virtual method wishes to call the implementation supplied by its base class. The CTS allows this to be null inside the body of a virtual method.

A virtual or instance method can also be called by a different mechanism, a virtual call. Any type that inherits from a type that defines a virtual method can provide its own implementation of that method (this is known as overriding, see §I.8.10.4). It is the exact type of the object (determined at runtime) that is used to decide which of the implementations to invoke.

上面这段文字节选自Common Language Infrastructure (CLI),我来简单总结一下:

  • 与某个类型实例关联的方法,也就是被我们统称为实例方法,其实进一步划分为Instance Method和Virtual Method。我觉得将它们称为非虚实例方法(Non-Virtual Instance Method)和虚实例方法(Virtual Instance Method)更清楚;
  • 从IL指令来看,方法有Call和Callvirt两种调用方式。两种实例方法类型+两种调用方式,所以一共就有四种调用场景;
  • Call指令直接调用声明类型的方法,实在编译时决定的;Callvirt指令调用的是目标对象真实类型的方法,只能在运行时确定。从原理上讲,Call指令避免了目标方法的动态分发,所以性能更好;
  • 以Call不要求目标对象为Null,因为目标方法在运行时就已经确定了,但以Callvirt指令需要根据指定的对象确定目标方法所在的类型,所以要求目标对象不能为Null。

我个人在补充几点:

  • 在CLR眼中其实并没有静态方法和实例方法的区别,这两种方法都会自动添加一个前置的参数,其类型就是方法所在的类型。当我们调用静态方法时,第一个参数总是Null(对于值类型就是default),调用实例方法时则将目标对象作为第一个参数;
  • 除了Call和Callvirt指令,方法调用还有Calli指令,它可以更具提供的方法指针和参数列表来调用目标方法;关于三种方法调用指令,可以参考我的文章《方法的三种调用形式》;

二、Call V.S. Callvirt

我们来回答开篇提出的问题:不论是不是虚方法,只要以Call指令调用,就不要求目标对象不为null;但我们不能使用Callvirt指令调用Null的实例方法,不论它们是否为虚方法。我们使用下面这个例子要验证这一结论。

using System.Reflection.Emit;

Invoke(CreateInvoker(OpCodes.Call, "Foo"));
Invoke(CreateInvoker(OpCodes.Call, "Bar"));
Invoke(CreateInvoker(OpCodes.Callvirt, "Foo"));
Invoke(CreateInvoker(OpCodes.Callvirt, "Bar"));

static void Invoke(Action<Foobar?> invoker)
{
    try
    {
        invoker(null);
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.Message);
    }
}

static Action<Foobar?> CreateInvoker(OpCode opcode, string methodName)
{
    DynamicMethod foo = new DynamicMethod(
        name: "Invoke",
        returnType: typeof(void),
        parameterTypes: [typeof(Foobar)]);
    var il = foo.GetILGenerator();
    il.Emit(OpCodes.Ldarg_0);
    il.Emit(opcode,typeof(Foobar).GetMethod(methodName)!);
    il.Emit(OpCodes.Ret);
    return (Action<Foobar?>)foo.CreateDelegate(typeof(Action<Foobar?>));
}

public class Foobar
{
    public void Foo() => Console.WriteLine(this is null);
    public virtual void Bar() => Console.WriteLine(this is null);
}

如上面的代码片段所示,Foobar类中定义了Foo和Bar两个实例方法,前者为常规方法,后者为虚方法。CreateInvoker方法根据指定的方法调用指令和方法名创建了一个动态方法(DynamicMethod ),进而创建出调用指定方法的Action<Foobar> 委托。Invoke方法会在Try/Catch中执行指定Action<Foobar>委托,以确定方法调用是否成功完成。演示程序先后四次调用Invoke方法,分别演示了以Call/Callvirt指令调用常规方法/虚方法,如下所示的输出结果证实了我们的结论。

image

三、直接调用(C#)

那么在C#中调用常规方法和虚方法又会如何呢?为此我定义了如下两个静态方法Foo和Bar,然后根据它们创建了对应的Action<Foobar>委托作为参数调用Invoke方法。

using System.Reflection.Emit;

Invoke(Foo);
Invoke(Bar);
static void Foo(Foobar? foobar) => foobar!.Foo();
static void Bar(Foobar? foobar) => foobar!.Bar();

static void Invoke(Action<Foobar?> invoker)
{
    try
    {
        invoker(null);
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.Message);
    }
}

public class Foobar
{
    public void Foo() => Console.WriteLine(this is null);
    public virtual void Bar() => Console.WriteLine(this is null);
}

从如下的输出结果可以看出,不管调用的方法是否为虚方法,都要求目标对象不为Null。

image

根据我们上面的结论,既然方法调用作了“空引用验证”,使用的方法调用指令就不可能是Call。如下所是的是静态方法Foo和Bar的IL代码,可以看出它们调用Foobar对象的Foo和Bar方法采用的指令都是Callvirt。

.method assembly hidebysig static
	void '<<Main>$>g__Foo|0_0' (
		class Foobar foobar
	) cil managed
{
	.custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
		01 00 02 00 00
	)
	.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
		01 00 00 00
	)
	// Method begins at RVA 0x20b2
	// Header size: 1
	// Code size: 8 (0x8)
	.maxstack 8

	// foobar!.Foo();
	IL_0000: ldarg.0
	IL_0001: callvirt instance void Foobar::Foo()
	// }
	IL_0006: nop
	IL_0007: ret
} // end of method Program::'<<Main>$>g__Foo|0_0'
.method assembly hidebysig static
	void '<<Main>$>g__Bar|0_1' (
		class Foobar foobar
	) cil managed
{
	.custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
		01 00 02 00 00
	)
	.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
		01 00 00 00
	)
	// Method begins at RVA 0x20bb
	// Header size: 1
	// Code size: 8 (0x8)
	.maxstack 8

	// foobar!.Bar();
	IL_0000: ldarg.0
	IL_0001: callvirt instance void Foobar::Bar()
	// }
	IL_0006: nop
	IL_0007: ret
} // end of method Program::'<<Main>$>g__Bar|0_1'

在我的记忆中(也可能是我记错了),针对常规非虚方法的调用指令,原来的编译器会使用Call指令,不知道从哪个版本开始统一是Callvirt指令了。其实也好理解,如果方法不涉及目标对象,我们就应该将其定义成静态方法,针对实例方法执行空引用验证其实是有必要的。

四、静态方法

我们在上面说过,静态方法和实例方法并没有什么不同,但是调用静态方法时指定的第一个参数总是Null,所以针对它们的调用就不可能使用Callvirt指令,而只能使用Call指定。如下所示的是静态方法Invoke的IL代码,可以参数针对Console.WriteLine方法的调用使用的指令就是Call。

.method assembly hidebysig static
	void '<<Main>$>g__Invoke|0_2' (
		class [System.Runtime]System.Action`1<class Foobar> invoker
	) cil managed
{
	.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
		01 00 00 00
	)
	.param [1]
		.custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8[]) = (
			01 00 02 00 00 00 01 02 00 00
		)
	// Method begins at RVA 0x20c4
	// Header size: 12
	// Code size: 31 (0x1f)
	.maxstack 2
	.locals init (
		[0] class [System.Runtime]System.Exception ex
	)

	// {
	IL_0000: nop
	.try
	{
		// {
		IL_0001: nop
		// invoker(null);
		IL_0002: ldarg.0
		IL_0003: ldnull
		IL_0004: callvirt instance void class [System.Runtime]System.Action`1<class Foobar>::Invoke(!0)
		// (no C# code)
		IL_0009: nop
		// }
		IL_000a: nop
		IL_000b: leave.s IL_001e
	} // end .try
	catch [System.Runtime]System.Exception
	{
		// catch (Exception ex)
		IL_000d: stloc.0
		// {
		IL_000e: nop
		// Console.WriteLine(ex.Message);
		IL_000f: ldloc.0
		IL_0010: callvirt instance string [System.Runtime]System.Exception::get_Message()
		IL_0015: call void [System.Console]System.Console::WriteLine(string)
		// (no C# code)
		IL_001a: nop
		// }
		IL_001b: nop
		IL_001c: leave.s IL_001e
	} // end handler

	IL_001e: ret
} // end of method Program::'<<Main>$>g__Invoke|0_2'

五、值类型实例方法

对于值类型实例方法的调用,由于目标对象不可能是Null,而且值类型也没有虚方法一说,所以使用的指令也应该是Call。

static void Do(Foobar foobar) => foobar.Do();
public struct Foobar
{
    public void Do() { }
}

上面定义的静态方法Do具有如下的IL代码,可以看出它调用结构体Foobar的同名方法使用的指令就是Call。

.method assembly hidebysig static
	void '<<Main>$>g__Do|0_0' (
		valuetype Foobar foobar
	) cil managed
{
	.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
		01 00 00 00
	)
	// Method begins at RVA 0x2064
	// Header size: 1
	// Code size: 9 (0x9)
	.maxstack 8

	// foobar.Do();
	IL_0000: ldarga.s foobar
	IL_0002: call instance void Foobar::Do()
	// }
	IL_0007: nop
	IL_0008: ret
} // end of method Program::'<<Main>$>g__Do|0_0'

六、?.操作符

在进行方法调用时,如果不确定目标对象是否为Null,按照如下的形式使用?.操作符就很有必要。

static string ToString(object? instance) => instance?.ToString() ?? "N/A";

?.操作符仅仅是一个语法糖而已,编译器会将上述代码翻译成如下的形式:

static string ToString(object? instance) => ((instance != null) ? instance.ToString() : null) ?? "N/A";

七、扩展方法

扩展方法是个静态方法,所以针对它们的调用时不会进行空引用验证的。但是扩展方法又是以实例方法形式进行调用的,所以我推荐在定义扩展方法的时候最好对传入的第一个参数进行空引用验证。

public static class FoobarExtesnions
{
    public static void ExtendedMethod(this Foobar foobar)
    {
        ArgumentNullException.ThrowIfNull(foobar, nameof(foobar));
        ...
    }
}
posted @ 2024-08-19 09:43  Artech  阅读(2545)  评论(7编辑  收藏  举报