in 参数(C# 7.2)
C# 7.2为方法参数引入了新修饰符in
。该修饰符的使用方式与ref
、out
相同,但目的不同。一个带有in
修饰符的参数,可以通过引用传递从而避免复制,同时可以保证参数值不被修改。在方法内部,in
参数的行为类似于ref readonly
局部变量。该变量依然是由调用方传入的一个内存地址,因此要保证方法不会修改该值,否则修改结果会影响调用方,这样就违背了in
参数的意义。
in
参数与ref
和out
参数之间存在一个巨大的差异:调用方无须为调用实参添加in
修饰符。如果调用时没有指定in
修饰符,而实参是某个变量,那么编译器将按引用传递该实参,但是还要创建一个隐藏的局部变量,并将参数值赋给该变量。如果调用方显式指定了in
修饰符,那么只有实参可以直接按引用传递时调用才合法。代码清单13-12列出了所有可能的情况。
代码清单13-12
in
参数的合法传递实参与非法传递实参
static void PrintDateTime(in DateTime value) <------ 使用in参数声明方法
{
string text = value.ToString(
"yyyy-MM-dd'T'HH:mm:ss",
CultureInfo.InvariantCulture);
Console.WriteLine(text);
}
static void Main()
{
DateTime start = DateTime.UtcNow;
PrintDateTime(start); <------ 变量隐式地通过引用传递
PrintDateTime(in start); <------ 变量显式地通过引用传递(由于in修饰符)
PrintDateTime(start.AddMinutes(1)); <------ 复制结果给隐藏的局部变量(通过引用方式)
PrintDateTime(in start.AddMinutes(1)); <------ 编译错误:实参不能引用传递
}
在生成的IL代码中,形参等同于使用[IsReadOnlyAttribute]
修饰的ref
参数。位于System.Runtime.CompilerServices
命名空间下的[IsReadOnlyAttribute]
比InAttribute
引入得晚,存在于.NET 4.7.1中,但.NET Standard 2.0中不存在。如果要为此而添加依赖或者自行声明该attribute,就会比较烦琐。因此,如果没有其他attribute可用,编译器会自动在程序集中生成该attribute。
该attribute在IL中没有modreq
修饰符。任何不能解析IsReadOnlyAttribute
的编译器都将它视为常规ref
参数(CLR也不需要知道该attribute)。更高版本的编译器编译出来的调用代码会突然编译失败,因为它们现在要求in
修饰符而不是ref
修饰符了,这就引出了一个更为庞大的关于向后兼容的问题。
一、 兼容性考量
in
修饰符被设计成调用时可选,这造成了一个有趣的向后兼容问题。将一个方法形参从值参数(默认的不带修饰符的参数)修改为in
参数,这样的改动总属于源码兼容(无须修改调用代码便可以通过编译),但不属于二进制兼容(任何已编译完成的程序集调用该方法时会在执行期失败)。具体含义视具体使用场景而定。假设现在要将一个已经发布的程序集中的方法形参改为in
参数。
- 如果发生改动的方法在调用时在我们的控制范围之外(例如通过NuGet发布的库),这就属于破坏性改动,应该按照一般破坏性改动的应对方式对待。
- 如果调用方在调用方法前可以重新编译代码(即便不能改动调用代码),对于调用方来说也不是破坏性改动。
- 如果该方法只用于程序集内部,则无须关心二进制兼容问题,因为所有调用代码都将重新编译。
还有一种比较少见的情况:对于一个带有ref
参数(只为避免复制)的方法(不在方法中修改参数值),将ref
改成in
总是二进制兼容的,但源码不兼容。这一点和把值参数改成in
参数刚好相反。
以上内容都有一个共同的前提:使用in
参数不破坏方法本身的语义,但这个前提并不总是成立的,原因如下。
二、 in
参数惊人的不可变性:外部修改
到目前为止,各种迹象似乎表明,只要不在方法中修改参数,就可以安全地把它设为in
参数。然而事实并非如此,这种想法不可取。编译器会防止方法内部修改参数值,但无法阻止其他代码修改。必须记住,in
参数只是某个内存地址的别名,其他代码可以修改它。先看一个简单的例子。
代码清单13-13
in
参数和值参数在副作用上的差异
static void InParameter(in int p, Action action)
{
Console.WriteLine("Start of InParameter method");
Console.WriteLine($"p = {p}");
action();
Console.WriteLine($"p = {p}");
}
static void ValueParameter(int p, Action action)
{
Console.WriteLine("Start of ValueParameter method");
Console.WriteLine($"p = {p}");
action();
Console.WriteLine($"p = {p}");
}
static void Main()
{
int x = 10;
InParameter(x, () => x++);
int y = 10;
ValueParameter(y, () => y++);
}
前两个方法除了参数属性和打印信息不同,其他内容都相同。在Main
方法中,调用两个方法的方式也相同,把一个初始值为10的变量作为实参进行传递,然后由action
来为该变量执行自增操作。下面的执行结果展示了两个方法在语义上的差别:
Start of InParameter method
p = 10
p = 11
Start of ValueParameter method
p = 10
p = 10
可见InParameter
方法能够体现出参数由于action()
调用而发生的变化,而ValueParameter
不能。这并不意外,因为in
参数的目的就是共享同一个内存位置,而值参数只是执行一次值复制。
问题在于,在这个特定的简单例子中,问题显而易见,但实际情况并不总是如此。假如in
参数刚好是同一个类型中某个字段的别名,这时对该字段的任何修改,无论是直接在方法中修改,还是由方法调用的其他代码来修改,都会反映到参数中,那么对于调用代码或方法本身,都不是显而易见的。如果牵涉多线程,就更难预测代码行为了。
虽然有些“危言耸听”,但意在强调这是一个很实际的问题。我们已经习惯了使用ref
修饰形参和实参来强调此类行为的可能。此外,ref
修饰符给人的感觉是,使用它就要关注参数的变化是否可见,in
修饰符则强调参数的不可变性。13.3.4节还会给出关于in
参数的使用指导,目前只需要知道in
参数可能会发生意外的更改即可。
三、 使用in
参数进行方法重载
至此,还有一个问题未讨论:如果有两个方法同名且参数类型相同,其中一个的参数使用了in
修饰符,而另一个没有,会发生什么?
请记住,CLR只知道这是一个ref
参数。因此无法通过只改变ref
、out
以及in
修饰符来重载方法。对于CLR来说它们是相同的,但我们可以通过添加in
修饰符来重载一个普通值参数的方法:
void Method(int x) { ... }
void Method(in int x) { ... }
在进行重载决议时,带有值参数的方法比不带有in
参数的方法优先级高:
int x = 5;
Method(5); <------ 调用第1个方法
Method(x); <------ 调用第1个方法
Method(in x); <------ 由于in修饰符的存在,会调用第2个方法
有了这些规则,为现有值参数的方法添加in
参数的重载方法时,不用太过担心兼容性问题。
四、 in
参数的使用指导
提示:我还不曾在实际代码中使用过in
参数,以下指导意见都是基于推测的。
需要注意的第一点是:in
参数的设计初衷是提升效率。一条普遍性原则是,在对代码做有效、反复的性能评估,并且设定好性能目标之前,不要为了提升性能而贸然更改代码。如果更改不够慎重,就会以提升性能之名把代码复杂化,结果发现即便某几个方法的性能大幅提升了,这几个方法却不在应用的关键路径上。具体的性能目标和正在编写代码的类型相关(游戏、Web应用、库、物联网应用等),并且需要慎之又慎。我推荐将BenchmarkDorNet项目作为小型性能评测工具。
in
参数的优势在于能有效避免数据复制。如果只是使用引用类型或者小型结构体,可能根本不会有什么性能提升。从逻辑上讲,哪怕内存地址中的值不发生复制,内存地址本身也需要传递给方法。因为JIT编译和优化机制对于我们来说是个黑盒,所以这里不做深究。不经测试的性能提升都是空谈,因为性能问题牵扯的因素太多了,任何推理都可能只是有限的理性推测。不过随着结构体规模的不断增大,使用in
参数的优势也会逐渐提升。
我对于in
参数的主要担心是,它会使代码变得难以理解。如13.3.2节所述,即便方法中并没有修改参数的值,但是两次对同一个参数值的读取得到了不同的结果。这样不仅不利于正确编写代码,而且可能会编写出似是而非的代码。
不过,有一种方式可以做到既利用in
参数的优势,又能够避免上述问题:减少或者移除任何可能修改参数值的代码。假设有一个公共API,该API通过一系列深层嵌套的私有方法调用实现,那么对于该API本身应当使用值参数,而对那些私有方法使用in
参数。代码清单13-14虽然实际价值不大,却是一个很好的示例。
代码清单13-14 安全地使用
in
参数
public static double PublicMethod( (本行及以下2行) 使用值参数的公共方法
LargeStruct first,
LargeStruct second)
{
double firstResult = PrivateMethod(in first);
double secondResult = PrivateMethod(in second);
return firstResult + secondResult;
}
private static double PrivateMethod( (本行及以下1行) 使用in参数的私有方法
in LargeStruct input)
{
double scale = GetScale(in input);
return (input.X + input.Y + input.Z) * scale;
}
private static double GetScale(in LargeStruct input) => <------ 另一个使用in参数的方法
input.Weight * input.Score;
采用这种方式可以防止参数被意外修改,因为所有方法都是私有的,我们可以检查所有调用方,确定它们不会传递那些在方法执行时可能被修改的参数。在PublicMethod
方法调用时,每个结构体只会被复制一次,但这些复制品之后在私有方法调用时都是别名。这样就把自己的代码和其他线程中调用方的任何修改,或者其他方法的副作用隔离开来了。有时可能需要允许修改参数,但是需要写好文档并且谨慎控制。
也可以把相同的逻辑应用于内部调用,但是需要更多限制,因为会有更多代码能够调用当前方法。我个人习惯在调用时和方法声明时,都给参数加上in
修饰符,这样在阅读代码时能准确理解代码意图。
上述内容可总结为以下建议。
- 只有确定性能提升可观时,才使用
in
参数,例如使用大型结构体时。 - 在公共API中尽量避免使用
in
参数,除非即便参数值发生变化,方法也能正确执行。 - 可以考虑通过公共方法作为防止参数被修改的外部屏障,然后在内部私有方法中使用
in
参数来减少复制。 - 对于采用
in
参数的方法,在调用时考虑显式给出in
修饰符(除非有意利用编译器来通过引用传递隐藏的局部变量)。
使用Roslyn分析器应该很容易检查这些指导性策略。目前还没有这样一款分析器,但将来很有可能出现在NuGet包管理器中。
说明 如果读者发现这样一款分析器,请告知我,我会在本书的网站上备注。
以上所说的性能提升都需要考量减少的复制量,这听起来并不很直白。下面详细介绍编译器会在何时静默完成复制工作,以及如何避免复制。
(完)
相关阅读:
C# 回顾:ref 知多少
C# ref 局部变量和 ref return
in 参数(C# 7.2)
将结构体声明为只读(C# 7.2)
使用 ref 参数或者in参数的扩展方法(C# 7.2)
类 ref 结构体(C# 7.2)
评论
发表评论