2012-08-03 13 views
24

Trình biên dịch .NET C# (.NET 4.0) biên dịch câu lệnh fixed theo cách khá đặc biệt.Tại sao MSFT C# biên dịch một mảng "cố định thành phân rã" và "địa chỉ của phần tử đầu tiên" khác nhau?

Đây là một chương trình ngắn nhưng đầy đủ để cho bạn thấy những gì tôi đang nói đến.

using System; 

public static class FixedExample { 

    public static void Main() { 
     byte [] nonempty = new byte[1] {42}; 
     byte [] empty = new byte[0]; 

     Good(nonempty); 
     Bad(nonempty); 

     try { 
      Good(empty); 
     } catch (Exception e){ 
      Console.WriteLine(e.ToString()); 
      /* continue with next example */ 
     } 
     Console.WriteLine(); 
     try { 
      Bad(empty); 
     } catch (Exception e){ 
      Console.WriteLine(e.ToString()); 
      /* continue with next example */ 
     } 
    } 

    public static void Good(byte[] buffer) { 
     unsafe { 
      fixed (byte * p = &buffer[0]) { 
       Console.WriteLine(*p); 
      } 
     } 
    } 

    public static void Bad(byte[] buffer) { 
     unsafe { 
      fixed (byte * p = buffer) { 
       Console.WriteLine(*p); 
      } 
     } 
    } 
} 

Biên dịch bằng "csc.exe FixedExample.cs/unsafe/o +" nếu bạn muốn theo dõi.

Đây là IL tạo ra cho phương pháp Good:

Tốt()

.maxstack 2 
    .locals init (uint8& pinned V_0) 
    IL_0000: ldarg.0 
    IL_0001: ldc.i4.0 
    IL_0002: ldelema [mscorlib]System.Byte 
    IL_0007: stloc.0 
    IL_0008: ldloc.0 
    IL_0009: conv.i 
    IL_000a: ldind.u1 
    IL_000b: call  void [mscorlib]System.Console::WriteLine(int32) 
    IL_0010: ldc.i4.0 
    IL_0011: conv.u 
    IL_0012: stloc.0 
    IL_0013: ret 

Đây là IL tạo ra cho phương pháp Bad:

Bad()

.locals init (uint8& pinned V_0, uint8[] V_1) 
    IL_0000: ldarg.0 
    IL_0001: dup 
    IL_0002: stloc.1 
    IL_0003: brfalse.s IL_000a 
    IL_0005: ldloc.1 
    IL_0006: ldlen 
    IL_0007: conv.i4 
    IL_0008: brtrue.s IL_000f 
    IL_000a: ldc.i4.0 
    IL_000b: conv.u 
    IL_000c: stloc.0 
    IL_000d: br.s  IL_0017 
    IL_000f: ldloc.1 
    IL_0010: ldc.i4.0 
    IL_0011: ldelema [mscorlib]System.Byte 
    IL_0016: stloc.0 
    IL_0017: ldloc.0 
    IL_0018: conv.i 
    IL_0019: ldind.u1 
    IL_001a: call  void [mscorlib]System.Console::WriteLine(int32) 
    IL_001f: ldc.i4.0 
    IL_0020: conv.u 
    IL_0021: stloc.0 
    IL_0022: ret 

Đây là những gì Good làm:

  1. Lấy địa chỉ của bộ đệm [0].
  2. Tham khảo địa chỉ đó.
  3. Gọi WriteLine với giá trị không tham chiếu đó.

Đây là những gì 'Bad` làm:

  1. Nếu đệm là null, GOTO 3.
  2. Nếu buffer.Length = 0, GOTO 5.
  3. Store giá trị 0 trong địa phương! khe 0,
  4. GOTO 6.
  5. Lấy địa chỉ của bộ đệm [0].
  6. Tùy chọn địa chỉ đó (trong vùng địa phương 0, có thể là 0 hoặc bộ đệm ngay bây giờ).
  7. Gọi WriteLine với giá trị không tham chiếu đó.

Khi buffer không vừa trống và không trống, hai chức năng này cũng thực hiện tương tự. Lưu ý rằng Bad chỉ cần nhảy qua một vài vòng trước khi thực hiện cuộc gọi hàm WriteLine.

Khi buffer là null, Good ném một NullReferenceException trong cố định con trỏ declarator (byte * p = &buffer[0]). Có lẽ đây là hành vi mong muốn để sửa một mảng được quản lý, bởi vì nói chung bất kỳ thao tác nào bên trong câu lệnh cố định cố định sẽ phụ thuộc vào tính hợp lệ của đối tượng đang được sửa. Nếu không thì tại sao mã đó nằm bên trong khối fixed? Khi Good được thông qua một tham chiếu null, nó không thành công ngay khi bắt đầu khối fixed, cung cấp một dấu vết ngăn xếp có liên quan và thông tin. Các nhà phát triển sẽ thấy điều này và nhận ra rằng ông phải xác nhận buffer trước khi sử dụng nó, hoặc có lẽ logic của mình không chính xác được giao null đến buffer.Dù bằng cách nào, việc nhập rõ ràng khối fixed với một mảng được quản lý null là không mong muốn.

Bad xử lý trường hợp này khác nhau, thậm chí không mong muốn. Bạn có thể thấy rằng Bad không thực sự ném ngoại lệ cho đến khi p bị hủy đăng ký. Nó làm như vậy theo cách vòng tròn gán null vào cùng một vị trí cục bộ giữ p, sau đó ném ngoại lệ khi câu lệnh chặn fixed dereference p.

Xử lý null cách này có lợi thế là giữ mô hình đối tượng trong C# nhất quán. Tức là, bên trong khối fixed, p vẫn được coi là ngữ nghĩa như là một loại "con trỏ tới một mảng được quản lý" sẽ không, khi null, gây ra các vấn đề cho đến (hoặc trừ khi) nó bị bỏ qua. Nhất quán là tất cả tốt và tốt, nhưng vấn đề là p không phải là một con trỏ đến một mảng được quản lý. Nó là một con trỏ đến phần tử đầu tiên của buffer, và bất kỳ ai đã viết mã này (Bad) sẽ hiểu ý nghĩa ngữ nghĩa của nó như vậy. Bạn không thể nhận được kích thước của buffer từ p và bạn không thể gọi số p.ToString(), vậy tại sao lại coi nó như thể đó là một đối tượng? Trong trường hợp buffer là null, rõ ràng là lỗi mã hóa và tôi tin rằng nó sẽ hữu ích hơn rất nhiều nếu Bad sẽ ném ngoại lệ tại khai báo con trỏ cố định, thay vì bên trong phương thức.

Vì vậy, có vẻ như là Good xử lý null tốt hơn Bad. Điều gì về bộ đệm trống?

Khi buffer có độ dài 0, Good ném IndexOutOfRangeException tại trình khai báo con trỏ cố định. Điều đó có vẻ như một cách hoàn toàn hợp lý để xử lý truy cập mảng giới hạn. Xét cho cùng, mã số &buffer[0] phải được xử lý giống như &(buffer[0]), rõ ràng là nên ném IndexOutOfRangeException.

Bad xử lý trường hợp này khác và một lần nữa không mong muốn. Cũng giống như trường hợp nếu buffernull, khi buffer.Length == 0, Bad không ném ngoại lệ cho đến khi p bị hủy đăng ký và tại thời điểm đó, nó ném NullReferenceException, không phải IndexOutOfRangeException! Nếu p không bao giờ bị hủy đăng ký, thì mã đó thậm chí không ném ngoại lệ. Một lần nữa, có vẻ như ý tưởng ở đây là đưa ra p ý nghĩa ngữ nghĩa của "con trỏ tới một mảng được quản lý". Nhưng một lần nữa, tôi không nghĩ rằng bất cứ ai viết mã này sẽ nghĩ về p theo cách đó. Mã sẽ hữu ích hơn nhiều nếu nó đã ném IndexOutOfRangeException vào khai báo con trỏ cố định, do đó thông báo cho nhà phát triển rằng mảng được truyền vào là trống và không phải là null.

Có vẻ như fixed(byte * p = buffer) phải được biên dịch thành cùng một mã như là fixed (byte * p = &buffer[0]). Cũng lưu ý rằng mặc dù buffer có thể có bất kỳ biểu thức tùy ý nào, kiểu của nó (byte[]) được biết tại thời gian biên dịch và do đó mã trong Good sẽ hoạt động cho bất kỳ biểu thức tùy ý nào.

Sửa

Trong thực tế, nhận thấy rằng việc thực hiện Bad thực hiện kiểm tra lỗi trên buffer[0]hai lần. Nó thực hiện một cách rõ ràng ở đầu phương thức, và sau đó thực hiện nó một cách rõ ràng tại lệnh ldelema.


Vì vậy, chúng ta thấy rằng GoodBad ngữ nghĩa khác nhau. Bad dài hơn, có thể chậm hơn, và chắc chắn không cung cấp cho chúng tôi ngoại lệ mong muốn khi chúng tôi có lỗi trong mã của chúng tôi, và thậm chí không thành công nhiều hơn so với trong một số trường hợp.

Đối với những người tò mò, phần 18.6 của spec (C# 4.0) nói rằng hành vi là "Thực hiện định nghĩa" trong cả hai trường hợp thất bại:

Một cố định con trỏ-initializer có thể là một trong những như sau:

• Mã thông báo “&” tiếp theo là biến tham chiếu (§5.3.3) đến biến di chuyển (§18.3) của loại không được quản lý T, với điều kiện loại T * được chuyển đổi hoàn toàn thành con trỏ loại được đưa ra trong tuyên bố cố định. Trong trường hợp này, trình khởi tạo tính toán địa chỉ của biến đã cho và biến được đảm bảo duy trì ở một địa chỉ cố định trong khoảng thời gian của câu lệnh cố định.

• Biểu thức của một kiểu mảng với các phần tử của kiểu không được quản lý T, với điều kiện kiểu T * được chuyển đổi hoàn toàn thành kiểu con trỏ được đưa ra trong câu lệnh cố định. Trong trường hợp này, bộ khởi tạo tính toán địa chỉ của phần tử đầu tiên trong mảng và toàn bộ mảng được đảm bảo duy trì ở một địa chỉ cố định trong khoảng thời gian của câu lệnh cố định. Hành vi của câu lệnh cố định được xác định thực hiện nếu biểu thức mảng là null hoặc nếu mảng có phần tử không.

... trường hợp khác ...

điểm ngoái, MSDN documentation gợi ý rằng hai là "tương đương":

// Hai bài tập sau đây là tương đương ...

cố định (double * p = arr) {/ ... /}

cố định (double * p = & arr [0]) {/ ... /}

Nếu hai có nghĩa vụ phải là "tương đương", thì tại sao sử dụng ngữ nghĩa xử lý lỗi khác nhau đối với các tuyên bố trước đây ?

Nó cũng xuất hiện rằng nỗ lực bổ sung được đưa vào viết đường dẫn mã được tạo trong Bad. Mã được biên dịch trong Good hoạt động tốt cho tất cả các trường hợp lỗi và giống như mã trong Bad trong trường hợp không phải là lỗi. Tại sao triển khai các đường dẫn mã mới thay vì chỉ sử dụng mã đơn giản được tạo cho Good?

Tại sao nó được triển khai theo cách này?

+0

@pst tôi loại bỏ 'SoWhat' và giải thích của nó cho ngắn gọn. Đã sửa. –

+1

Bạn đã kiểm tra những gì C# spec nói về 'arr [0]'? Tôi khá chắc chắn 'arr [0]' ném một ngoại lệ khi 'arr' là null hoặc rỗng trong bất kỳ ngữ cảnh nào, bất kể câu lệnh' fixed' nào bao quanh nó. – hvd

+0

@hvd Có đó là lý do tại sao 'cố định (byte * p = buffer)' chỉ nên được coi là 'cố định (byte * p = & buffer [0])'. Sau này sẽ chăm sóc kiểm tra lỗi sạch hơn. Tôi sẽ thêm điều này vào câu hỏi. –

Trả lời

9

Bạn có thể nhận thấy rằng mã IL bạn đã bao gồm thực hiện thông số kỹ thuật gần như từng dòng. Điều đó bao gồm việc triển khai rõ ràng hai trường hợp ngoại lệ được liệt kê trong thông số trong trường hợp chúng có liên quan và không bao gồm mã trong trường hợp chúng không có. Vì vậy, lý do đơn giản nhất tại sao trình biên dịch hoạt động theo cách nó là "bởi vì spec nói như vậy".

Tất nhiên, đó chỉ dẫn đến hai câu hỏi thêm rằng chúng ta có thể đặt câu hỏi:

  • Tại sao C# nhóm ngôn ngữ chọn để viết spec theo cách này?
  • Tại sao nhóm biên dịch đã chọn rằng hành vi cụ thể cụ thể được xác định?

Thiếu người từ các nhóm thích hợp hiển thị, chúng tôi thực sự không thể trả lời một trong những câu hỏi đó hoàn toàn. Tuy nhiên, chúng ta có thể có một đâm vào trả lời thứ hai bằng cách cố gắng làm theo lý luận của họ.

Nhớ lại rằng spec nói, trong trường hợp cung cấp một mảng đến một cố định con trỏ-initializer, mà

Hành vi của báo cáo kết quả cố định là thực hiện xác định nếu biểu thức mảng là null hoặc nếu mảng có 0 phần tử.

Vì việc triển khai tự do chọn bất kỳ điều gì trong trường hợp này, chúng tôi có thể cho rằng hành vi hợp lý là dễ nhất và rẻ nhất cho nhóm biên dịch.

Trong trường hợp này, nhóm biên dịch đã chọn làm gì là "ném ngoại lệ vào thời điểm mã của bạn làm điều gì đó sai". Hãy xem xét mã sẽ hoạt động như thế nào nếu nó không nằm trong một mã khởi tạo con trỏ cố định và suy nghĩ về những gì khác đang xảy ra. Trong ví dụ "Tốt" của bạn, bạn đang cố gắng lấy địa chỉ của một đối tượng không tồn tại: phần tử đầu tiên trong một mảng rỗng/trống. Đó không phải là một cái gì đó bạn thực sự có thể làm, vì vậy nó sẽ tạo ra một ngoại lệ. Trong ví dụ "Bad" của bạn, bạn chỉ đơn thuần gán địa chỉ của tham số cho biến con trỏ; byte * p = null là một tuyên bố hoàn toàn hợp pháp. Chỉ khi bạn cố gắng WriteLine(*p) thì có lỗi xảy ra. Kể từ khi cố định con trỏ-initializer được phép làm bất cứ điều gì nó muốn trong trường hợp ngoại lệ này, điều đơn giản nhất để làm là chỉ cho phép chuyển nhượng xảy ra, như vô nghĩa như nó được.

Rõ ràng, hai câu lệnh là không tương đương chính xác. Chúng ta có thể nói điều này bởi thực tế là tiêu chuẩn đối xử với họ khác nhau:

  • &arr[0] là: "Các dấu hiệu‘&’theo sau là một biến tham chiếu", và do đó trình biên dịch sẽ tính toán địa chỉ của arr [0]
  • arr là: "Biểu thức kiểu mảng", do đó trình biên dịch tính toán địa chỉ của phần tử đầu tiên của mảng, với báo trước rằng một mảng rỗng hoặc 0 tạo ra hành vi được thực hiện mà bạn đang thấy.

Hai sản phẩm tương đương kết quả, miễn là có phần tử trong mảng, đó là điểm mà tài liệu MSDN đang cố gắng vượt qua. Đặt câu hỏi về lý do tại sao hành vi không xác định rõ ràng hoặc hành vi được xác định thực hiện theo cách nó không thực sự giúp bạn giải quyết bất kỳ vấn đề cụ thể nào, bởi vì bạn không thể dựa vào nó là đúng trong tương lai. (Có nói rằng, tôi tất nhiên là tò mò muốn biết những gì quá trình suy nghĩ, vì bạn rõ ràng không thể "sửa chữa" một giá trị null trong bộ nhớ ...)

+0

Theo hành vi xác định chỉ định chỉ được phép trong trường hợp thứ hai, nhưng tốt thực hiện trường hợp đầu tiên. '& mảng [0]' là * không * biểu thức mảng. – usr

+1

"chúng tôi sẽ phải hy vọng rằng một người nào đó trong nhóm biên dịch C# xảy ra cùng" Đó là hy vọng của tôi :) –

+0

@usr đúng, tôi đã cố gắng để có được điểm đó trong bài viết nhưng tôi sẽ rõ ràng hơn về nó :) –

1

Vì vậy, chúng tôi thấy rằng Tốt và Xấu khác nhau về mặt ngữ nghĩa. Tại sao?

Bởi vì tốt là trường hợp 1 và xấu là trường hợp 2.

Tốt không gán "Một biểu hiện của một mảng kiểu". Nó gán "Mã thông báo" & "theo sau là một tham chiếu biến" vì vậy nó là trường hợp 1. Xấu gán "Một biểu thức của một mảng kiểu" làm cho nó trường hợp 2. Nếu điều này đúng MSDN tài liệu là sai.

Trong mọi trường hợp, điều này giải thích tại sao trình biên dịch C# tạo hai mẫu mã khác nhau (và trong trường hợp thứ hai chuyên biệt).

Tại sao trường hợp 1 tạo mã đơn giản như vậy? Tôi đang suy đoán ở đây: Lấy địa chỉ của một phần tử mảng có thể được biên dịch theo cùng một cách như sử dụng array[index] trong một biểu hiện ref. Ở cấp CLR, các tham số và biểu thức ref chỉ là các con trỏ được quản lý. Vì vậy, là biểu thức &array[index]: Nó được biên dịch cho một con trỏ được quản lý mà không được ghim nhưng "nội thất" (thuật ngữ này xuất phát từ Managed C++ tôi nghĩ). GC tự động sửa lỗi. Nó hoạt động giống như một đối tượng bình thường.

Vì vậy, trường hợp 1 nhận được điều trị con trỏ được quản lý thông thường trong khi trường hợp 2 nhận được hành vi đặc biệt, được thực hiện được xác định (không được xác định).

Điều này không trả lời tất cả các câu hỏi của bạn nhưng ít nhất nó cung cấp một số lý do cho các quan sát của bạn. Tôi hy vọng Eric Lippert sẽ thêm câu trả lời của anh ấy như một người trong cuộc.

+2

"Nếu điều này đúng thì tài liệu MSDN sai." Không thực sự, chỉ hơi gây hiểu nhầm, như tôi đã hiểu. Hai hình thức tương đương với những bộ đệm mà cả hai hình thức đã xác định hành vi. – hvd

+0

@hvd, có cho những trường hợp đó. Nhưng không phải cho tất cả. Các tài liệu không đủ điều kiện tuyên bố của họ theo cách bạn vừa làm. – usr

+0

@hvd Tôi nên mong đợi rằng "Hành vi được xác định thực hiện" được xác định trong tài liệu MSDN. Rõ ràng nó không phải là trong trường hợp này. –