Tại sao một lệnh gọi hàm tạo đệ quy thực hiện biên dịch mã C # không hợp lệ?


82

Sau khi xem hội thảo trên web Jon Skeet Kiểm tra ReSharper , tôi bắt đầu chơi một chút với các lệnh gọi hàm tạo đệ quy và nhận thấy rằng đoạn mã sau là mã C # hợp lệ (hợp lệ, tôi có nghĩa là nó biên dịch).

class Foo
{
    int a = null;
    int b = AppDomain.CurrentDomain;
    int c = "string to int";
    int d = NonExistingMethod();
    int e = Invalid<Method>Name<<Indeeed();

    Foo()       :this(0)  { }
    Foo(int v)  :this()   { }
}

Như chúng ta có thể đã biết, khởi tạo trường được trình biên dịch chuyển vào hàm khởi tạo. Vì vậy, nếu bạn có một trường như thế int a = 42;, bạn sẽ có a = 42trong tất cả các hàm tạo. Nhưng nếu bạn có một hàm tạo gọi một hàm tạo khác, bạn sẽ có mã khởi tạo chỉ trong một hàm được gọi.

Ví dụ: nếu bạn có hàm tạo với các tham số gọi hàm tạo mặc định, bạn sẽ chỉ có quyền gán a = 42trong hàm tạo mặc định.

Để minh họa trường hợp thứ hai, mã tiếp theo:

class Foo
{
    int a = 42;

    Foo() :this(60)  { }
    Foo(int v)       { }
}

Biên dịch thành:

internal class Foo
{
    private int a;

    private Foo()
    {
        this.ctor(60);
    }

    private Foo(int v)
    {
        this.a = 42;
        base.ctor();
    }
}

Vì vậy, vấn đề chính, là mã của tôi, được đưa ra ở đầu câu hỏi này, được biên dịch thành:

internal class Foo
{
    private int a;
    private int b;
    private int c;
    private int d;
    private int e;

    private Foo()
    {
        this.ctor(0);
    }

    private Foo(int v)
    {
        this.ctor();
    }
}

Như bạn có thể thấy, trình biên dịch không thể quyết định nơi đặt khởi tạo trường và do đó, không đặt nó ở đâu. Cũng lưu ý, không có baselệnh gọi hàm tạo. Tất nhiên, không có đối tượng nào có thể được tạo, và bạn sẽ luôn kết thúc StackOverflowExceptionnếu bạn cố gắng tạo một phiên bản của Foo.

Tôi có hai câu hỏi:

Tại sao trình biên dịch lại cho phép gọi hàm tạo đệ quy?

Tại sao chúng ta quan sát hành vi như vậy của trình biên dịch cho các trường, được khởi tạo trong lớp như vậy?


Một số lưu ý: ReSharper cảnh báo bạn với Possible cyclic constructor calls. Hơn nữa, trong Java các lệnh gọi hàm tạo như vậy sẽ không biên dịch sự kiện, vì vậy trình biên dịch Java bị hạn chế hơn trong trường hợp này (Jon đã đề cập thông tin này tại hội thảo trên web).

Điều này làm cho những câu hỏi này trở nên thú vị hơn, bởi vì đối với cộng đồng Java, trình biên dịch C # ít nhất là hiện đại hơn.

Điều này được biên dịch bằng cách sử dụng trình biên dịch C # 4.0 và C # 5.0 và được dịch ngược bằng dotPeek .


3
Làm thế nào mà anh ấy ** tôi đã bỏ lỡ video này ???
— Royi Namir

7
Câu hỏi hay.
— Dennis

2
Các trình khởi tạo trường đẹp ở đó: int a = null; int b = AppDomain.CurrentDomain; int c = "string to int"; int d = NonExistingMethod(); int e = Invalid<Method>Name<<Indeeed(); Người ta nên đặt câu hỏi: "Các khai báo trường này OK trong tình huống nào?" (Có một cảnh báo về các lĩnh vực không được sử dụng, nhưng bạn có thể thoát khỏi cảnh báo rằng bằng cách đọc từng lĩnh vực bên trong cơ thể của một trong những nhà xây dựng intance (hoặc ở nơi khác).)
— Jeppe Stig Nielsen

4
Tôi tin rằng điều này được cho phép vì lý do tương tự .
— GSerg

4
Khởi tạo trường được đưa vào tất cả các hàm tạo gọi một hàm tạo cơ sở. Do đó, nếu không có hàm tạo nào gọi hàm tạo cơ sở, thì việc khởi tạo trường sẽ không được đưa vào bất kỳ đâu. Ít nhất thì phần đó có ý nghĩa hoàn hảo đối với tôi. Không phải là trình biên dịch không thể tìm ra nơi để đặt nó, mà là bởi vì trình biên dịch nhận thấy rằng nó không cần phải đặt nó ở đâu.

Câu trả lời:


11

Tìm thấy thú vị.

Có vẻ như thực sự chỉ có hai loại hàm tạo cá thể:

  1. Một phương thức tạo thể hiện xâu chuỗi một phương thức khởi tạo thể hiện khác cùng kiểu , với : this( ...)cú pháp.
  2. Một phương thức khởi tạo thể hiện chuỗi một phương thức khởi tạo thể hiện của lớp cơ sở . Điều này bao gồm các hàm tạo cá thể mà không có chainig nào được chỉ định, vì : base()nó là mặc định.

(Tôi đã bỏ qua hàm tạo phiên bản của System.Object đó là một trường hợp đặc biệt. System.ObjectKhông có lớp cơ sở! Nhưng System.Objectcũng không có trường nào.)

Các bộ khởi tạo trường cá thể có thể có trong lớp, cần được sao chép vào phần đầu của phần thân của tất cả các hàm khởi tạo cá thể kiểu 2. ở trên, trong khi không có bộ khởi tạo cá thể nào thuộc loại 1. cần mã gán trường.

Vì vậy, rõ ràng là không cần trình biên dịch C # thực hiện phân tích các hàm tạo kiểu 1. để xem có chu trình hay không.

Bây giờ, ví dụ của bạn đưa ra một tình huống trong đó tất cả các hàm tạo phiên bản đều thuộc loại 1 .. Trong tình huống đó, mã initaializer trường không cần phải đặt ở bất kỳ đâu. Vì vậy, nó dường như không được phân tích sâu cho lắm.

Nó chỉ ra rằng khi tất cả các hàm tạo thể hiện thuộc loại 1. , bạn thậm chí có thể dẫn xuất từ ​​một lớp cơ sở không có hàm tạo nào có thể truy cập được. Tuy nhiên, lớp cơ sở phải không được niêm phong. Ví dụ: nếu bạn viết một lớp chỉ có các hàm tạo privatecá thể, mọi người vẫn có thể dẫn xuất từ ​​lớp của bạn nếu họ đặt tất cả các hàm tạo cá thể trong lớp dẫn xuất là loại 1. ở trên. Tuy nhiên, một biểu thức tạo đối tượng mới tất nhiên sẽ không bao giờ kết thúc. Để tạo các thể hiện của lớp dẫn xuất, người ta sẽ phải "ăn gian" và sử dụng những thứ như System.Runtime.Serialization.FormatterServices.GetUninitializedObjectphương thức.

Một ví dụ khác: System.Globalization.TextInfo Lớp chỉ có một phương thức internalkhởi tạo thể hiện. Nhưng bạn vẫn có thể lấy từ lớp này trong một hợp ngữ khác mscorlib.dllvới kỹ thuật này.

Cuối cùng, liên quan đến

Invalid<Method>Name<<Indeeed()

cú pháp. Theo các quy tắc C #, điều này được đọc là

(Invalid < Method) > (Name << Indeeed())

vì toán tử dịch trái <<có quyền ưu tiên cao hơn cả toán tử nhỏ hơn <và toán tử lớn hơn >. Hai toán hạng sau có cùng mức độ ưu tiên và do đó được đánh giá bằng quy tắc kết hợp trái. Nếu các loại

MySpecialType Invalid;
int Method;
int Name;
int Indeed() { ... }

và nếu được MySpecialTypegiới thiệu là (MySpecialType, int)quá tải của operator <, thì biểu thức

Invalid < Method > Name << Indeeed()

sẽ hợp pháp và có ý nghĩa.


Theo tôi, sẽ tốt hơn nếu trình biên dịch đưa ra cảnh báo trong trường hợp này. Ví dụ, nó có thể nói unreachable code detectedvà trỏ đến số dòng và cột của bộ khởi tạo trường không bao giờ được dịch sang IL.


1
Tôi không hiểu ... không trình tạo trường được gọi trước ctor?
— Royi Namir

2
@RoyiNamir Có. Nhưng nếu bạn nhìn vào IL, nó hoạt động giống như người hỏi viết: "Như chúng ta có thể đã biết, khởi tạo trường được trình biên dịch chuyển vào hàm khởi tạo." Nghĩa là gì nghĩa là, giả sử bạn viết lớp này trong C #: class Example { int field = 42; internal Example() { /* some code here */ field = 100; } }thì IL được tạo ra bởi puts rằng 42phân công vào constructor dụ, trước khi mọi thứ khác, đúng như nếu bạn đã viết:class Example { int field; internal Example() { field = 42; /* some code here */ field = 100; } }
— Jeppe Stig Nielsen

5

Tôi nghĩ vì đặc điểm ngôn ngữ chỉ quy định trực tiếp gọi ra cùng một hàm tạo đang được định nghĩa.

Từ ngày 10.11.1:

Tất cả các hàm tạo thể hiện (trừ những đối tượng dành cho lớp object) ngầm bao gồm một lệnh gọi của một hàm tạo thể hiện khác ngay trước thân hàm dựng. Hàm tạo để gọi ngầm được xác định bởi hàm khởi tạo-khởi tạo

...

  • Một trình khởi tạo phương thức khởi tạo thể hiện của biểu mẫu khiến một phương thức khởi tạo cá thể từ chính lớp đó được gọi ... Nếu một khai báo phương thức khởi tạo thể hiện bao gồm một trình khởi tạo phương thức khởi tạo gọi chính phương thức khởi tạo đó, lỗi thời gian biên dịch sẽ xảy rathis(argument-listopt)

Câu cuối cùng đó dường như chỉ loại trừ việc gọi trực tiếp chính nó là tạo ra lỗi thời gian biên dịch, ví dụ:

Foo() : this() {}

Là bất hợp pháp.


Tôi thừa nhận mặc dù - tôi không thể thấy lý do cụ thể để cho phép nó. Tất nhiên, ở mức IL các cấu trúc như vậy được phép vì các cấu trúc phiên bản khác nhau có thể được chọn trong thời gian chạy, tôi tin rằng - vì vậy bạn có thể có đệ quy miễn là nó kết thúc.


Tôi nghĩ lý do khác mà nó không gắn cờ hoặc cảnh báo về điều này là vì nó không cần phải phát hiện tình huống này. Hãy tưởng tượng theo dõi qua hàng trăm hàm tạo khác nhau, chỉ để xem liệu một chu trình có tồn tại hay không - khi bất kỳ nỗ lực sử dụng nào sẽ nhanh chóng (như chúng ta biết) bị nổ tung trong thời gian chạy, đối với một trường hợp khá phức tạp.

Khi nó thực hiện tạo mã cho mỗi hàm tạo, tất cả những gì nó xem xét là constructor-initializer, bộ khởi tạo trường và phần thân của hàm tạo - nó không xem xét bất kỳ mã nào khác:

  • Nếu constructor-initializerlà một phương thức khởi tạo thể hiện cho chính lớp, nó không phát ra các bộ khởi tạo trường - nó phát ra constructor-initializerlời gọi và sau đó là phần thân.

  • Nếu constructor-initializerlà một phương thức khởi tạo thể hiện cho lớp cơ sở trực tiếp, nó sẽ phát ra các trình khởi tạo trường, sau đó là constructor-initializerlệnh gọi và sau đó là nội dung.

Trong cả hai trường hợp, nó không cần phải đi tìm nơi khác - vì vậy nó không phải là trường hợp "không thể" quyết định nơi đặt các bộ khởi tạo trường - nó chỉ tuân theo một số quy tắc đơn giản chỉ xem xét hàm tạo hiện tại.


2
Nhưng những gì về thực tế là nó cho phép dòng như biên dịch này: int e = Invalid<Method>Name<<Indeeed();. Tôi nói đó là một lỗi của trình biên dịch.
— Matthew Watson

@MatthewWatson Nó có thể được hiểu là int e = Invalid < Method > Name << Indeed();với các toán tử nhị phân "nhỏ hơn", "lớn hơn" và "dịch trái". Về mặt cú pháp thì OK, nhưng sẽ là một số quá tải thực sự điên rồ của các toán tử để làm cho nó OK với việc gõ mạnh.
— Jeppe Stig Nielsen

1
@JeppeStigNielsen Được, nhưng nó sẽ không biên dịch nếu bạn để nguyên mã khác với việc xóa mã phương thức khởi tạo đệ quy. Đó là lý do tại sao tôi nghĩ đó là một lỗi.
— Matthew Watson

4
@MatthewWatson Không thể phát hiện lỗi tại thời điểm phân tích cú pháp vì lớp chưa hoàn thành. (Có thể lớp của bạn sẽ xác định các thành viên được gọi là Invalidvv sẽ làm cho nó hợp lệ.) Lỗi thường được phát hiện khi tạo mã, nhưng bạn đã tìm thấy một cách để viết mã không bao giờ được tạo. Bạn đã tìm thấy một lỗ hổng lén lút trong trình biên dịch (một cách để viết mã sẽ không bao giờ được biên dịch), nhưng không phải là một lỗ hổng nghiêm trọng vì dù sao thì mã vi phạm cũng không thể truy cập được.
— Raymond Chen

2

Ví dụ của bạn

class Foo
{
    int a = 42;

    Foo() :this(60)  { }
    Foo(int v)       { }
}

sẽ hoạt động tốt, theo nghĩa là bạn có thể khởi tạo đối tượng Foo đó mà không gặp vấn đề gì. Tuy nhiên, đoạn mã sau sẽ giống mã mà bạn đang hỏi hơn

class Foo
{
    int a = 42;

    Foo() :this(60)     { }
    Foo(int v) : this() { }
}

Cả mã đó và mã của bạn sẽ tạo ra một dòng chảy chồng lên (!), Vì phép đệ quy không bao giờ cạn kiệt. Vì vậy, mã của bạn bị bỏ qua bởi vì nó không bao giờ được thực thi.

Nói cách khác, trình biên dịch không thể quyết định nơi đặt mã bị lỗi vì nó có thể nói rằng đệ quy không bao giờ hết. Tôi nghĩ điều này là do nó phải đặt nó ở nơi nó sẽ chỉ được gọi một lần, nhưng bản chất đệ quy của các hàm tạo làm cho điều đó không thể.

Đệ quy theo nghĩa của một hàm tạo tạo ra các thể hiện của chính nó trong phần thân của hàm tạo có ý nghĩa đối với tôi, vì ví dụ, điều đó có thể được sử dụng để khởi tạo cây trong đó mỗi nút trỏ đến các nút khác. Nhưng đệ quy thông qua các tiền khởi tạo của loại được minh họa bởi câu hỏi này không bao giờ có kết quả, vì vậy sẽ có ý nghĩa đối với tôi nếu điều đó không được phép.


1
Vâng, tôi đồng ý, đó là lý do tại sao tôi đã tạo ra câu hỏi này. Tại sao trình biên dịch không thể quyết định nơi đặt logic khởi tạo và do đó, tại sao nó lại cho phép gọi đệ quy? Có một lý do cho điều này?
— Ilya Ivanov

Tôi thấy rõ ràng rằng trình biên dịch không thể quyết định nơi đặt mã bị lỗi bởi vì nó có thể nói rằng đệ quy không bao giờ hết. Tại sao đó là một bí ẩn?
— Stochasnged

Nếu C # không thể quyết định phương thức nào sẽ gọi, nó sẽ báo lỗi ambiguous method call, nó sẽ không bỏ qua lệnh gọi phương thức đó. Nếu tôi là một trình biên dịch, tôi cũng sẽ gặp lỗi trong trường hợp này.
— Ilya Ivanov

1
thật tệ, câu trả lời nhận được rất nhiều phiếu tán thành, tôi không phản đối bất kỳ câu trả lời nào trong số đó (chỉ là trường hợp). Trong trường hợp này, nó cũng không thể quyết định nơi đặt logic khởi tạo. Vì vậy, câu hỏi chính của tôi là tại sao lại cho phép các cuộc gọi đệ quy? Có một lý do đằng sau điều này? Có lẽ tôi đang thiếu một cái gì đó
— Ilya Ivanov

3
@IlyaIvanov - Tôi nghĩ câu hỏi thích hợp hơn là - tại sao phải viết trình dò ​​chu kỳ để phát hiện các lệnh gọi hàm tạo đệ quy trong trình biên dịch?
— Damien_The_Un Believer

0

Tôi nghĩ điều này được cho phép vì bạn vẫn có thể (có thể) bắt được Exception và làm điều gì đó có ý nghĩa với nó.

Quá trình khởi tạo sẽ không bao giờ được chạy và nó gần như chắc chắn sẽ ném ra một StackOverflowException. Nhưng đây vẫn có thể là hành vi bị truy nã và không phải lúc nào cũng có nghĩa là quá trình sẽ sụp đổ.

Như được giải thích ở đây https://stackoverflow.com/a/1599236/869482

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookie và Chính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.