.NET Framework - C# 7.0 中的新增功能

解構函數

從 C# 1.0 開始,就能調用函數,就是將參數組合起來並封裝到一個類中的構造函數。但是,從來沒有一種簡便的方式可將對象解構回其各個組成部分。例如,假設有一個 PathInfo 類,它採用文件名的每個元素(目錄名、文件名、擴展名),並將它們組合成一個對象,然後支持操作對象的不同元素。現在,假設你需要將該對象提取(解構)回其各個組成部分。

在 C# 7.0 中,通過解構函數完成這項任務將變得輕而易舉,解構函數可返回對象的具體確定組件。注意,不要將解構函數 (deconstructor) 與析構函數 (destructor)(確定性對象解除分配和清除)或終結器 (itl.tc/CSharpFinalizers) 混淆。

我們來看看圖 1 中的 PathInfo 類。

1 具有解構函數的 PathInfo 類及相關測試
public class PathInfo
{
  public string DirectoryName { get; }
  public string FileName { get; }
  public string Extension { get; }
  public string Path
  {
    get
    {
      return System.IO.Path.Combine(
        DirectoryName, FileName, Extension);
    }
  }
  public PathInfo(string path)
  {
    DirectoryName = System.IO.Path.GetDirectoryName(path);
    FileName = System.IO.Path.GetFileNameWithoutExtension(path);
    Extension = System.IO.Path.GetExtension(path);
  }
  public void Deconstruct(
    out string directoryName, out string fileName, out string extension)
  {
    directoryName = DirectoryName;
    fileName = FileName;
    extension = Extension;
  }
  // ...
}

顯然,可以和在 C# 1.0 一樣調用 Deconstruct 方法。但是,C# 7.0 提供了可以顯著簡化調用的語法糖。如果存在解構函數的聲明,則可以使用新的 C# 7.0“類似元組”的語法調用它(參見圖 2)。

2 解構函數調用和賦值
PathInfo pathInfo = new PathInfo(@"\\test\unc\path\to\something.ext");
{
  // Example 1: Deconstructing declaration and assignment.
  (string directoryName, string fileName, string extension) = pathInfo;
  VerifyExpectedValue(directoryName, fileName, extension);
}
{
  string directoryName, fileName, extension = null;
  // Example 2: Deconstructing assignment.
  (directoryName, fileName, extension) = pathInfo;
  VerifyExpectedValue(directoryName, fileName, extension);
}
{
  // Example 3: Deconstructing declaration and assignment with var.
  var (directoryName, fileName, extension) = pathInfo;
  VerifyExpectedValue(directoryName, fileName, extension);
}

請注意,C# 第一次如何允許同時向不同值的多個變量賦值。這與將所有變量都初始化爲同一值 (null) 的空賦值聲明不同:

string directoryName, filename, extension = null;
通過新的類似元組的語法,賦予每個變量一個不同的值,該值與其名稱不對應,但與它出現在聲明和解構語句中的順序相對應。

正如你所期望的,out 參數的類型必須與被分配的變量類型相匹配,並且允許使用 var,因爲此類型可以從 Deconstruct 參數類型中推斷出來。但是,請注意,雖然可以在圓括號外面放置一個 var(如圖 2 中的示例 3 所示),但此時即使所有變量的類型均相同,也不能拉出字符串。

請注意,此時 C# 7.0 類似元組的語法要求圓括號內至少出現兩個變量。例如,即使存在類似如下的解構函數,也不允許使用 (FileInfo path) = pathInfo;:

public void Deconstruct(out FileInfo file)
換句話說,不能對僅有一個 out 參數的 Deconstruct 方法使用 C# 7.0 解構函數。

使用元組

正如我所說過的,前面的每個示例都利用了 C# 7.0 類似元組的語法。此類語法的特點就是用圓括號括住分配的多個變量(或屬性)。我之所以使用術語“類似元組的”,是因爲所有這些解構函數示例實際上在內部均未使用任何元組類型。(實際上,由於已分配的對象是表示封裝的組成部分的實例,因此,不允許通過解構函數語法分配元組,也可以說這樣做不太必要。)

藉助 C# 7.0,現在有了一種特別簡化的語法,可以使用元組,如圖 3 所示。只要允許使用類型說明符,就可以使用這種語法,其中包括聲明、強制轉換運算符和類型參數。

3 聲明、實例化並使用 C# 7.0 元組語法
[TestMethod]
public void Constructor_CreateTuple()
{
  (string DirectoryName, string FileName, string Extension) pathData =
    (DirectoryName: @"\\test\unc\path\to",
    FileName: "something",
    Extension: ".ext");
  Assert.AreEqual<string>(
    @"\\test\unc\path\to", pathData.DirectoryName);
  Assert.AreEqual<string>(
    "something", pathData.FileName);
  Assert.AreEqual<string>(
    ".ext", pathData.Extension);
  Assert.AreEqual<(string DirectoryName, string FileName, string Extension)>(
    (DirectoryName: @"\\test\unc\path\to",
      FileName: "something", Extension: ".ext"),
    (pathData));
  Assert.AreEqual<(string DirectoryName, string FileName, string Extension)>(
    (@"\\test\unc\path\to", "something", ".ext"),
    (pathData));
  Assert.AreEqual<(string, string, string)>(
    (@"\\test\unc\path\to", "something", ".ext"), (pathData));
  Assert.AreEqual<Type>(
    typeof(ValueTuple<string, string, string>), pathData.GetType());
}
[TestMethod]
public void ValueTuple_GivenNamedTuple_ItemXHasSameValuesAsNames()
{
  var normalizedPath =
    (DirectoryName: @"\\test\unc\path\to", FileName: "something",
    Extension: ".ext");
  Assert.AreEqual<string>(normalizedPath.Item1, normalizedPath.DirectoryName);
  Assert.AreEqual<string>(normalizedPath.Item2, normalizedPath.FileName);
  Assert.AreEqual<string>(normalizedPath.Item3, normalizedPath.Extension);
}
static public (string DirectoryName, string FileName, string Extension)
  SplitPath(string path)
{
  // See http://bit.ly/2dmJIMm Normalize method for full implementation.
  return (          
    System.IO.Path.GetDirectoryName(path),
    System.IO.Path.GetFileNameWithoutExtension(path),
    System.IO.Path.GetExtension(path)
    );
}

如果你不太熟悉元組,可以在輕量級語法中將多個類型組合成一個包含類型,然後在對其進行實例化的方法外面使用。之所以說是輕量級,是因爲和定義類/結構不同,元組可通過內聯和動態方式“聲明”。但是,與也支持內聯聲明和實例化的動態類型不同,元組可以從其包含成員的外部訪問,它們實際上可以包含在 API 中。雖然外部 API 支持,但元組沒有兼容版本的擴展(除非類型參數本身正好支持推導),因此,在公共 API 中應謹慎使用。因此,更好的辦法是對公共 API 中的返回內容使用標準類。

在 C# 7.0 之前,該框架已有元組類 System.Tuple<…>(在 Microsoft .NET Framework 4 中引入)。但 C# 7.0 與之前的解決方案不同,因爲它將語義意圖嵌入到聲明中並引入一個元組值類型: System.ValueTuple<…>。

我們現在來看看語義意圖。請注意,在圖 3 中,C# 7.0 元組語法可讓你爲元組包含的每個 ItemX 元素聲明別名。例如,圖 3 中的 pathData 元組實例已定義強類型 DirectoryName: string、FileName: string 和 Extension: string 屬性,因此,可以調用(例如)pathData.DirectoryName。這是一項重大改進,因爲在 C# 7.0 之前,唯一可用的名稱是 ItemX 名稱,其中 X 將針對每個元素增加。

現在,雖然 C# 7.0 元組的元素屬於強類型,但這些名稱本身在類型定義中並未區分。因此,可以分配兩個使用不同別名的元組,你將得到一條警告,通知你將忽略右邊的名稱:

// Warning: The tuple element name 'AltDirectoryName1' is ignored
// because a different name is specified by the target type...
(string DirectoryName, string FileName, string Extension) pathData =
  (AltDirectoryName1: @"\\test\unc\path\to",
  FileName: "something", Extension: ".ext");
同樣,可以將元組分配到尚未定義部分別名元素名稱的其他元組:

// Warning: The tuple element name 'directoryName', 'FileNAme' and 'Extension'
// are ignored because a different name is specified by the target type...
(string, string, string) pathData =
  (DirectoryName: @"\\test\unc\path\to", FileName: "something", Extension: ".ext");

必須確定,每個元素的類型和順序都定義類型兼容性。僅忽略元素名稱。然而,即使在名稱不同時被忽略,它們仍然在 IDE 中提供 IntelliSense。

請注意,無論是否定義元素名稱的別名,所有元組均有 ItemX 名稱,其中 X 對應於元素的數量。ItemX 名稱很重要,因爲它們是元組從 C# 6.0 開始起可用,即使沒有別名元素的名稱也是如此。

需要注意的另一點就是,基礎 C# 7.0 元組類型是 System.ValueTuple。如果正針對其進行編譯的框架中未提供此類型,可以通過 NuGet 包訪問它。

有關元組內部元素的詳細信息,請參閱 intellitect.com/csharp7tupleiinternals。

具有 Is 表達式的模式匹配

有時會存在基類(例如 Storage),以及一系列的派生類、DVD、UsbKey、HardDrive、FloppyDrive 等。要對每個類實施 Eject 方法,請使用以下多個選項:

  1. As 運算符
  2. 檢查結果是否爲 null
  3. 執行 eject 操作
  4. Is 運算符
  5. 使用 Is 運算符檢查類
  6. 執行 eject 操作 Cast 顯式轉換並賦值
  7. 捕獲可能的異常
  8. 執行操作 看起來不怎麼樣啊!

還有第四種、效果更好的方法,即使用你通過虛擬函數分派的多形性。但是,僅在具有 Storage 類的源代碼並且可以添加 Eject 方法時,纔可以使用這種方法。我假設的選項不適用於這個討論,因此需要模式匹配。

上述這些方法存在的問題都是語法相當冗長,總是要求爲需要轉換的每個類提供多個語句。C# 7.0 提供模式匹配,用作一種將測試和賦值合併爲單個操作的方法。因此,圖 4 中的代碼簡化爲如圖 5 中所示的代碼。

4 無模式匹配的類型轉換
// Eject without pattern matching.
public void Eject(Storage storage)
{
  if (storage == null)
  {
    throw new ArgumentNullException();
  }
  if (storage is UsbKey)
  {
    UsbKey usbKey = (UsbKey)storage;
    if (usbKey.IsPluggedIn)
    {
      usbKey.Unload();
      Console.WriteLine("USB Drive Unloaded.");
    }
    else throw new NotImplementedException();    }
  else if(storage is DVD)
  // ...
  else throw new NotImplementedException();
}
圖 5 有模式匹配的類型轉換
// Eject with pattern matching.
public void Eject(Storage storage)
{
  if (storage is null)
  {
    throw new ArgumentNullException();
  }
  if ((storage is UsbKey usbDrive) && usbDrive.IsPluggedIn)
  {
    usbDrive.Unload();
    Console.WriteLine("USB Drive Unloaded.");
  }
  else if (storage is DVD dvd && dvd.IsInserted)
  // ...
  else throw new NotImplementedException();  // Default
}

這兩種轉換方式的區別並不重要,但如果要經常執行(例如,針對每個派生類型),則前一種語法存在一種繁瑣的 C# 特性。C# 7.0 的改進之處是將類型測試、聲明和賦值組合爲一個操作,呈現早期的語法,但不推薦使用。在前一種語法中,檢查類型而不分配標識符會導致失敗而恢復“默認設置”,否則會很麻煩。相比之下,除了類型檢查,分配還考慮到其他條件。

請注意,圖 5 中的代碼開始模式匹配 is 運算符,也支持 null 比較運算符:

if (storage is null) { … }
使用 Switch 語句的模式匹配

雖然支持使用 is 運算符的模式匹配實現了改進,但 switch 語句的模式匹配支持無疑更重要,至少在有多個可轉換的兼容類型時如此。這是因爲 C# 7.0 包括 case 語句和模式匹配,此外,如果滿足 case 語句中的類型模式,就可以在 case 語句中提供、分配和訪問標識符。圖 6 提供了一個示例。

6 Switch 語句中的模式匹配
public void Eject(Storage storage)
{
  switch(storage)
  {
    case UsbKey usbKey when usbKey.IsPluggedIn:
      usbKey.Unload();
      Console.WriteLine("USB Drive Unloaded.");
      break;
    case DVD dvd when dvd.IsInserted:
      dvd.Eject();
      break;
    case HardDrive hardDrive:
      throw new InvalidOperationException();
    case null:
    default:
      throw new ArgumentNullException();
  }
}

在該示例中,請注意如何在 case 語句中自動聲明和分配如 usbKey 和 dvd 的局部變量。正如你所期望的,範圍僅限於 case 語句中。

但也許與變量聲明和賦值一樣重要的是附加條件,可以用一個 when 子句附加到 case 語句。結果是 case 語句完全可以篩選無效的方案,無需在 case 語句內部使用額外的篩選器。這帶來額外的好處是:如果事實上沒有完全滿足前一個 case 語句,也允許計算下一個 case 語句。這也意味着 case 語句不再僅限於常量,此外,switch 表達式可以是任何類型,不再僅限於 bool、char、string、integral 和 enum。

新的 C# 7.0 模式匹配 switch 語句功能引入的另一個重要特徵就是,case 語句順序很重要並在編譯時驗證。(這與該語言的早期版本形成對比,早期版本中沒有模式匹配,case 語句順序也不重要。) 例如,如果我在派生自 Storage 的模式匹配 case 語句之前引入了 Storage 的 case 語句(UsbKey、DVD 和 HardDrive),則 case Storage 會隱藏所有其他的類型模式匹配(派生自 Storage)。如果 case 語句來自隱藏計算結果中的其他派生類型 case 語句的基類,將導致隱藏的 case 語句中出現編譯錯誤。這樣,case 語句順序要求就類似於 catch 語句。

讀者將會記得 null 值中的 is 運算符返回 false。因此,對於值 null 的 switch 表達式,類型模式匹配 case 語句不匹配。爲此,null case 語句的順序無關緊要;此行爲在模式匹配之前與 switch 語句匹配。

此外,爲了支持與 C# 7.0 之前的 switch 語句的兼容性,默認總是最後評估 case,而不考慮它出現在 case 語句順序中的位置。(也就是說,由於 case 總是在最後評估,可讀性通常也會將它放在最後。) 此外,goto case 語句仍僅適用於常量 case 標籤,不適用於模式匹配。

本地函數

雖然已經可以聲明委託併爲其分配一個表達式,但是 C# 7.0 通過允許在另一個成員內部完全聲明本地函數,做出了進一步改進。請考慮圖 7 中的 IsPalindrome 函數。

7 本地函數示例
bool IsPalindrome(string text)
{
  if (string.IsNullOrWhiteSpace(text)) return false;
  bool LocalIsPalindrome(string target)
  {
    target = target.Trim();  // Start by removing any surrounding whitespace.
    if (target.Length <= 1) return true;
    else
    {
      return char.ToLower(target[0]) ==
        char.ToLower(target[target.Length - 1]) &&
        LocalIsPalindrome(
          target.Substring(1, target.Length - 2));
    }
  }
  return LocalIsPalindrome(text);
}

在該實現中,我先檢查傳遞到 IsPalindrome 的參數不是 null 或僅爲空格。(我已使用模式匹配與 “text is null” 進行 null 檢查。) 接下來,我聲明函數 LocalIsPalindrome,其中,我以遞歸方式將第一個和最後一個字符進行比較。這種方法的好處是,我不在可能會錯誤調用的類範圍內聲明 LocalIsPalindrome,進而繞過 IsNullOrWhiteSpace 檢查。換句話說,本地函數提供其他的範圍限制,但僅在周圍函數內部。

圖 7 中的參數驗證方案是一種通用的本地函數用例。我經常遇到的另一個方案發生在單元測試內,例如在測試 IsPalindrome 函數時(參見圖 8)。

8 單元測試通常使用本地函數
[TestMethod]
public void IsPalindrome_GivenPalindrome_ReturnsTrue()
{
  void AssertIsPalindrome(string text)
  {
    Assert.IsTrue(IsPalindrome(text),
      $"'{text}' was not a Palindrome.");
  }
  AssertIsPalindrome("7");
  AssertIsPalindrome("4X4");
  AssertIsPalindrome("   tnt");
  AssertIsPalindrome("Was it a car or a cat I saw");
  AssertIsPalindrome("Never odd or even");
}

返回 IEnumerable 的 Iterator 函數以及 yield 返回元素是另一種通用的本地函數用例。

作爲對該主題的總結,以下列出了大家需要注意的有關本地函數的幾個要點:

本地函數不允許使用可訪問性修飾符(public、private、protected)。
本地函數不支持重載。即使簽名未重疊,也不能在名稱相同的同一種方法中使用兩個本地函數。
編譯器將針對永不調用的本地函數發出警告。
本地函數可以訪問封閉範圍內的所有變量,包括局部變量。此行爲與本地定義的 lambda 表達式相同,除了本地函數不分配表示結束的對象外,其他方面都與本地定義的 lambda 表達式相同。
本地函數存在於整個方法的範圍內,而不考慮是在聲明之前還是之後調用它們。
通過引用返回

從 C# 1.0 開始,可以通過引用 (ref) 將參數傳遞給函數。結果就是對參數本身的任何改變都將傳回給調用方。請考慮以下 Swap 功能:

static void Swap(ref string x, ref string y)
在這種情況下,被調用方法可以用新值更新原始調用方的變量,從而交換第一和第二參數中存儲的內容。

從 C# 7.0 開始,除了 ref 參數,還可以通過函數返回傳回一個引用。例如,考慮返回圖像中與紅眼相關聯的第一像素的函數,如圖 9 所示。

圖 9 Ref 返回和 Ref 局部聲明

public ref byte FindFirstRedEyePixel(byte[] image)
{
  //// Do fancy image detection perhaps with machine learning.
  for (int counter = 0; counter < image.Length; counter++)
  {
    if(image[counter] == (byte)ConsoleColor.Red)
    {
      return ref image[counter];
    }
  }
  throw new InvalidOperationException("No pixels are red.");
}
[TestMethod]
public void FindFirstRedEyePixel_GivenRedPixels_ReturnFirst()
{
  byte[] image;
  // Load image.
  // ...
    // Obtain a reference to the first red pixel.
  ref byte redPixel = ref FindFirstRedEyePixel(image);
  // Update it to be Black.
  redPixel = (byte)ConsoleColor.Black;
  Assert.AreEqual<byte>((byte)ConsoleColor.Black, image[redItems[0]]);
}

通過返回圖像引用,調用方然後能夠將像素更新爲不同的顏色。通過數組檢查更新時發現,該值現在爲 black。使用 by reference 參數的替代方法如下所示,有人可能會說這種方法不太明顯、可讀性較低:

public bool FindFirstRedEyePixel(ref byte pixel);

通過引用返回有兩個重要的限制,並且這兩個限制都由對象生命週期造成。對象引用不應被視爲垃圾收集,因爲對象仍然被引用,當它們不再有任何引用時,不應消耗內存。首先,只能返回以下內容的引用:字段、其他引用返回屬性或函數,或作爲參數傳遞到引用返回函數的對象。例如,FindFirst­RedEyePixel 返回對圖像數組中項目的引用,它是函數的參數。同樣,如果圖像存儲爲類中的字段,則可以通過引用返回該字段:

byte[] _Image;
public ref byte[] Image { get { return ref _Image; } }
其次,ref 局部變量初始化爲內存中的某個存儲位置,且不能修改爲指向不同的位置。(不能具有指向一個引用的指針和修改引用 - 對於那些有 C++ 背景的人,是指向指針的指針。)

以下是需要了解的幾個按引用返回特徵:

如果你要返回一個引用,則顯然必須返回。因此,這意味着在圖 9 的示例中,即使沒有紅眼像素存在,仍需要返回 ref 字節。唯一的解決方法是引發一個異常。相比之下,by reference 參數方法可讓你保持參數不變,並返回一個指示成功的布爾值。在許多情況下,這種方法可能更可取。
聲明一個引用局部變量時,需要初始化。這涉及到爲它分配從函數或引用返回到變量的 ref:

ref string text;  // Error
雖然可以在 C# 7.0 中聲明引用局部變量,但不允許聲明 ref 類型的字段:
class Thing { ref string _Text;  /* Error */ }
不能爲自動實施的屬性聲明 by reference 類型:
class Thing { ref string Text { get;set; }  /* Error */ }
允許使用返回引用的屬性:
class Thing { string _Text = "Inigo Montoya"; 
  ref string Text { get { return ref _Text; } } }
不能使用值(如 null 或常量)初始化引用局部變量。它必須通過返回成員或局部變量/字段的 by reference 分配: 
ref int number = null; ref int number = 42;  // ERROR
輸出變量

從 C# 的第一個版本開始,調用包含輸出參數的方法時,始終要求在調用方法之前預先聲明輸出參數標識符。但 C# 7.0 刪除了這個特性,並且允許以內聯方式聲明輸出參數以及方法調用。圖 10 顯示了一個例子。

10 輸出參數的內聯聲明
public long DivideWithRemainder(
  long numerator, long denominator, out long remainder)
{
  remainder = numerator % denominator;
  return (numerator / denominator);
}
[TestMethod]
public void DivideTest()
{
  Assert.AreEqual<long>(21,
    DivideWithRemainder(42, 2, out long remainder));
  Assert.AreEqual<long>(0, remainder);
}

請注意,在 DivideTest 方法中,從測試中對 DivideWithRemainder 的調用如何在 out 修飾符之後包含一個類型說明符。此外,瞭解剩餘部分如何自動繼續包含在方法的範圍內,如第二個 Assert.AreEqual 調用證明。很好!

文本改進

與以前的版本不同,C# 7.0 包含數字二進制文本格式,如下例所示:

long LargestSquareNumberUsingAllDigits =
  0b0010_0100_1000_1111_0110_1101_1100_0010_0100;  // 9,814,072,356
long MaxInt64 { get; } =
  9_223_372_036_854_775_807;  // Equivalent to long.MaxValue
還要注意對下劃線 “_” 用作數字分隔符的支持。它只是用來提高可讀性,可以放在數字位數(二進制、十進制或十六進制數字)之間的任何位置。

通用的異步返回類型

有時在實施異步方法時,能夠同步返回結果,縮短一個長時間運行的操作,因爲結果幾乎是瞬時的,甚至是已知的。例如,考慮一個異步方法,用於確定目錄 (bit.ly/2dExeDG) 中文件的總大小。事實上,如果該目錄中沒有文件,則該方法可以立即返回,而不執行長時間運行的操作。直到 C# 7.0,異步語法的要求規定此類方法的返回結果應當是 Task,因此,即使不需要這樣的 Task 實例,也要實例化 Task。(要實現這一點,通用模式是從 Task.FromResult 返回結果。)

在 C# 7.0 中,編譯器不再限制異步方法返回到 void、Task 或 Task。現在可以定義自定義類型,例如 .NET Core Framework 提供的 System.Threading.Tasks.ValueTask struct,它們與異步方法返回值兼容。有關更多信息,請參閱 itl.tc/GeneralizedAsyncReturnTypes。

更多的 Expression-Bodied 成員

C# 6.0 引入了函數和屬性的 expression-bodied 成員,從而簡化了實現瑣碎的方法和屬性的語法。在 C# 7.0 中,將 expression-bodied 實現添加到了構造函數、訪問器(get 和 set 屬性實現),甚至終結器中(請參見圖 11)。

11 在訪問器和構造函數中使用 Expression-Bodied 成員
class TemporaryFile  // Full IDisposible implementation
                     // left off for elucidation.
{
  public TemporaryFile(string fileName) =>
    File = new FileInfo(fileName);
  ~TemporaryFile() => Dispose();
  Fileinfo _File;
  public FileInfo File
  {
    get => _File;
    private set => _File = value;
  }
  void Dispose() => File?.Delete();
}

我希望使用 expression-bodied 成員,這對於終結器特別常見,因爲最常見的實現是調用 Dispose 方法,如上圖所示。

我很高興地在此說明,對 expression-bodied 成員的額外支持是由 C# 社區實施的,而不是 Microsoft C# 團隊。而且還是開源,耶!

警告: 此功能在 Visual Studio 2017 RC 中尚未實現。

Throw 表達式:

圖 11 中的臨時類可以得到增強,在 expression-bodied 成員內包括參數驗證;因此,我可以將構造函數更新爲:

public TemporaryFile(string fileName) =>
  File = new FileInfo(filename ?? throw new ArgumentNullException());

如果沒有 throw 表達式,C# 對 expression-bodied 成員的支持就不能進行任何參數驗證。但是,通過 C# 7.0 支持 throw 作爲一個表達式,而不僅僅是一個語句,因此,可以在更大的包含表達式中報告錯誤內聯。

警告: 此功能在 Visual Studio 2017 RC 中尚未實現。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章