in
修飾符也是從 C# 7.2 開始引入的,它與我們上一篇中討論的 《C# 中的只讀結構體(readonly struct)》[1] 是緊密相關的。
in 修飾符
in
修飾符通過引用傳遞引數。 它讓形參成為實參的別名,即對形參執行的任何操作都是對實參執行的。 它類似於 ref
或 out
關鍵字,不同之處在於 in
引數無法通過呼叫的方法進行修改。
ref
修飾符,指定引數由引用傳遞,可以由呼叫方法讀取或寫入。out
修飾符,指定引數由引用傳遞,必須由呼叫方法寫入。in
修飾符,指定引數由引用傳遞,可以由呼叫方法讀取,但不可以寫入。
舉個簡單的例子:
struct Product
{
public int ProductId { get; set; }
public string ProductName { get; set; }
}
public static void Modify(in Product product)
{
//product = new Product(); // 錯誤 CS8331 無法分配到 變數 'in Product',因為它是隻讀變數
//product.ProductName = "測試商品"; // 錯誤 CS8332 不能分配到 變數 'in Product' 的成員,因為它是隻讀變數
Console.WriteLine($"Id: {product.ProductId}, Name: {product.ProductName}"); // OK
}
引入 in 引數的原因
我們知道,結構體例項的記憶體在棧(stack)上進行分配,所佔用的記憶體隨宣告它的型別或方法一起回收,所以通常在記憶體分配上它是比引用型別佔有優勢的。[2]
但是對於有些很大(比如有很多欄位或屬性)的結構體,將其作為方法引數,在緊湊的迴圈或關鍵程式碼路徑中呼叫方法時,複製這些結構的成本就會很高。當所呼叫的方法不修改該引數的狀態,使用新的修飾符 in
宣告引數以指定此引數可以按引用安全傳遞,可以避免(可能產生的)高昂的複製成本,從而提高程式碼執行的效能。
in 引數對效能的提升
為了測試 in
修飾符對效能的提升,我定義了兩個較大的結構體,一個是可變的結構體 NormalStruct
,一個是隻讀的結構體 ReadOnlyStruct
,都定義了 30 個屬性,然後定義三個測試方法:
DoNormalLoop
方法,引數不加修飾符,傳入一般結構體,這是以前比較常見的做法。DoNormalLoopByIn
方法,引數加in
修飾符,傳入一般結構體。DoReadOnlyLoopByIn
方法,引數加in
修飾符,傳入只讀結構體。
程式碼如下所示:
public struct NormalStruct
{
public decimal Number1 { get; set; }
public decimal Number2 { get; set; }
//...
public decimal Number30 { get; set; }
}
public readonly struct ReadOnlyStruct
{
// 自動屬性上的 readonly 關鍵字是可以省略的,這裡加上是為了便於理解
public readonly decimal Number1 { get; }
public readonly decimal Number2 { get; }
//...
public readonly decimal Number30 { get; }
}
public class BenchmarkClass
{
const int loops = 50000000;
NormalStruct normalInstance = new NormalStruct();
ReadOnlyStruct readOnlyInstance = new ReadOnlyStruct();
[Benchmark(Baseline = true)]
public decimal DoNormalLoop()
{
decimal result = 0M;
for (int i = 0; i < loops; i++)
{
result = Compute(normalInstance);
}
return result;
}
[Benchmark]
public decimal DoNormalLoopByIn()
{
decimal result = 0M;
for (int i = 0; i < loops; i++)
{
result = ComputeIn(in normalInstance);
}
return result;
}
[Benchmark]
public decimal DoReadOnlyLoopByIn()
{
decimal result = 0M;
for (int i = 0; i < loops; i++)
{
result = ComputeIn(in readOnlyInstance);
}
return result;
}
public decimal Compute(NormalStruct s)
{
//業務邏輯...
return 0M;
}
public decimal ComputeIn(in NormalStruct s)
{
//業務邏輯...
return 0M;
}
public decimal ComputeIn(in ReadOnlyStruct s)
{
//業務邏輯...
return 0M;
}
}
在沒有使用 in
引數的方法中,意味著每次呼叫傳入的是變數的一個新副本; 而在使用 in
修飾符的方法中,每次不是傳遞變數的新副本,而是傳遞同一副本的只讀引用。
使用 BenchmarkDotNet 工具測試三個方法的執行時間,結果如下:
Method | Mean | Error | StdDev | Median | Ratio | RatioSD |
---|---|---|---|---|---|---|
DoNormalLoop | 1,536.3 ms | 65.07 ms | 191.86 ms | 1,425.7 ms | 1.00 | 0.00 |
DoNormalLoopByIn | 480.9 ms | 27.05 ms | 79.32 ms | 446.3 ms | 0.32 | 0.07 |
DoReadOnlyLoopByIn | 581.9 ms | 35.71 ms | 105.30 ms | 594.1 ms | 0.39 | 0.10 |
從這個結果可以看出,如果使用 in
引數,不管是一般的結構體還是隻讀結構體,相對於不用 in
修飾符的引數,效能都有較大的提升。這個效能差異在不同的機器上執行可能會有所不同,但是毫無疑問,使用 in
引數會得到更好的效能。
在 Parallel.For 中使用
在上面簡單的 for
迴圈中,我們看到 in
引數有助於效能的提升,那麼在並行運算中呢?我們把上面的 for
迴圈改成使用 Parallel.For
來實現,程式碼如下:
[Benchmark(Baseline = true)]
public decimal DoNormalLoop()
{
decimal result = 0M;
Parallel.For(0, loops, i => Compute(normalInstance));
return result;
}
[Benchmark]
public decimal DoNormalLoopByIn()
{
decimal result = 0M;
Parallel.For(0, loops, i => ComputeIn(in normalInstance));
return result;
}
[Benchmark]
public decimal DoReadOnlyLoopByIn()
{
decimal result = 0M;
Parallel.For(0, loops, i => ComputeIn(in readOnlyInstance));
return result;
}
事實上,道理是一樣的,在沒有使用 in
引數的方法中,每次呼叫傳入的是變數的一個新副本; 在使用 in
修飾符的方法中,每次傳遞的是同一副本的只讀引用。
使用 BenchmarkDotNet 工具測試三個方法的執行時間,結果如下:
Method | Mean | Error | StdDev | Ratio |
---|---|---|---|---|
DoNormalLoop | 793.4 ms | 13.02 ms | 11.54 ms | 1.00 |
DoNormalLoopByIn | 352.4 ms | 6.99 ms | 17.27 ms | 0.42 |
DoReadOnlyLoopByIn | 341.1 ms | 6.69 ms | 10.02 ms | 0.43 |
同樣表明,使用 in
引數會得到更好的效能。
使用 in 引數需要注意的地方
我們來看一個例子,定義一個一般的結構體,包含一個屬性 Value
和 一個修改該屬性的方法 UpdateValue
。 然後在別的地方也定義一個方法 UpdateMyNormalStruct
來修改該結構體的屬性 Value
。
程式碼如下:
struct MyNormalStruct
{
public int Value { get; set; }
public void UpdateValue(int value)
{
Value = value;
}
}
class Program
{
static void UpdateMyNormalStruct(MyNormalStruct myStruct)
{
myStruct.UpdateValue(8);
}
static void Main(string[] args)
{
MyNormalStruct myStruct = new MyNormalStruct();
myStruct.UpdateValue(2);
UpdateMyNormalStruct(myStruct);
Console.WriteLine(myStruct.Value);
}
}
您可以猜想一下它的執行結果是什麼呢? 2 還是 8?
我們來理一下,在 Main
中先呼叫了結構體自身的方法 UpdateValue
將 Value
修改為 2, 再呼叫 Program
中的方法 UpdateMyNormalStruct
, 而該方法中又呼叫了 MyNormalStruct
結構體自身的方法 UpdateValue
,那麼輸出是不是應該是 8 呢? 如果您這麼想就錯了。
它的正確輸出結果是 2,這是為什麼呢?
這是因為,結構體和許多內建的簡單型別(sbyte、byte、short、ushort、int、uint、long、ulong、char、float、double、decimal、bool 和 enum 型別)一樣,都是值型別,在傳遞引數的時候以值的方式傳遞。因此呼叫方法 UpdateMyNormalStruct
時傳遞的是 myStruct
變數的新副本,在此方法中,其實是此副本呼叫了 UpdateValue
方法,所以原變數 myStruct
的 Value
不會發生變化。
說到這裡,有聰明的朋友可能會想,我們給 UpdateMyNormalStruct
方法的引數加上 in
修飾符,是不是輸出結果就變為 8 了,in
引數不就是引用傳遞嗎?
我們可以試一下,把程式碼改成:
static void UpdateMyNormalStruct(in MyNormalStruct myStruct)
{
myStruct.UpdateValue(8);
}
static void Main(string[] args)
{
MyNormalStruct myStruct = new MyNormalStruct();
myStruct.UpdateValue(2);
UpdateMyNormalStruct(in myStruct);
Console.WriteLine(myStruct.Value);
}
執行一下,您會發現,結果依然為 2 !這……就讓人大跌眼鏡了……
用工具檢視一下 UpdateMyNormalStruct
方法的中間語言:
.method private hidebysig static
void UpdateMyNormalStruct (
[in] valuetype ConsoleApp4InTest.MyNormalStruct& myStruct
) cil managed
{
.param [1]
.custom instance void [System.Runtime]System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = (
01 00 00 00
)
// Method begins at RVA 0x2164
// Code size 18 (0x12)
.maxstack 2
.locals init (
[0] valuetype ConsoleApp4InTest.MyNormalStruct
)
IL_0000: nop
IL_0001: ldarg.0
IL_0002: ldobj ConsoleApp4InTest.MyNormalStruct
IL_0007: stloc.0
IL_0008: ldloca.s 0
IL_000a: ldc.i4.8
IL_000b: call instance void ConsoleApp4InTest.MyNormalStruct::UpdateValue(int32)
IL_0010: nop
IL_0011: ret
} // end of method Program::UpdateMyNormalStruct
您會發現,在 IL_0002
、IL_0007
和 IL_0008
這幾行,仍然建立了一個 MyNormalStruct
結構體的防禦性副本(defensive copy
)。雖然在呼叫方法 UpdateMyNormalStruct
時以引用的方式傳遞引數,但在方法體中呼叫結構體自身的 UpdateValue
前,卻建立了一個該結構體的防禦性副本,改變的是該副本的 Value
。這就有點奇怪了,不是嗎?
我們使用 in
引數的目的就是想減少結構體的複製從而提升效能,但這裡並沒有起到作用。甚至,假如 UpdateMyNormalStruct
方法中多次呼叫該結構體的非只讀方法,編譯器也會多次建立該結構體的防禦性副本,這就對效能產生了負面影響。
Google 了一些資料是這麼解釋的:C# 無法知道當它呼叫一個結構體上的方法(或getter)時,是否也會修改它的值/狀態。於是,它所做的就是建立所謂的“防禦性副本”。當在結構體上執行方法(或getter)時,它會建立傳入的結構體的副本,並在副本上執行方法。這意味著原始副本與傳入時完全相同,呼叫者傳入的值並沒有被修改。
有沒有辦法讓方法 UpdateMyNormalStruct
呼叫後輸出 8 呢?您將引數改成 ref
修飾符試試看 ? ? ?
綜上所述,最好不要把 in
修飾符和一般(非只讀)結構體一起使用,以免產生晦澀難懂的行為,而且可能對效能產生負面影響。
in 引數的限制
不能將 in
、ref
和 out
關鍵字用於以下幾種方法:
- 非同步方法,通過使用
async
修飾符定義。 - 迭代器方法,包括
yield return
或yield break
語句。 - 擴充套件方法的第一個引數不能有
in
修飾符,除非該引數是結構體。 - 擴充套件方法的第一個引數,其中該引數是泛型型別(即使該型別被約束為結構體。)
總結
- 使用
in
引數,有助於明確表明此引數不可修改的意圖。 - 當只讀結構體(
readonly struct
)的大小大於IntPtr.Size
[3] 時,出於效能原因,應將其作為in
引數傳遞。 - 不要將一般(非只讀)結構體作為
in
引數,因為結構體是可變的,反而有可能對效能產生負面影響,並且可能產生晦澀難懂的行為。
作者 : 技術譯民
出品 : 技術譯站
https://www.cnblogs.com/ittranslator/p/13876180.html C# 中的只讀結構體 ↩︎
https://www.cnblogs.com/ittranslator/p/13664383.html C# 中 Struct 和 Class 的區別總結 ↩︎
https://docs.microsoft.com/zh-cn/dotnet/api/system.intptr.size#System_IntPtr_Size IntPtr.Size ↩︎