Mengapa metode ekstensi string ini tidak memunculkan pengecualian?

119

Saya punya metode ekstensi string C # yang harus mengembalikan IEnumerable<int>semua indeks substring di dalam string. Ini berfungsi dengan sempurna untuk tujuan yang dimaksudkan dan hasil yang diharapkan dikembalikan (sebagaimana dibuktikan oleh salah satu pengujian saya, meskipun bukan yang di bawah), tetapi pengujian unit lain telah menemukan masalah dengannya: tidak dapat menangani argumen nol.

Inilah metode ekstensi yang saya uji:

public static IEnumerable<int> AllIndexesOf(this string str, string searchText)
{
    if (searchText == null)
    {
        throw new ArgumentNullException("searchText");
    }
    for (int index = 0; ; index += searchText.Length)
    {
        index = str.IndexOf(searchText, index);
        if (index == -1)
            break;
        yield return index;
    }
}

Berikut adalah tes yang menandai masalah tersebut:

[TestMethod]
[ExpectedException(typeof(ArgumentNullException))]
public void Extensions_AllIndexesOf_HandlesNullArguments()
{
    string test = "a.b.c.d.e";
    test.AllIndexesOf(null);
}

Ketika pengujian dijalankan terhadap metode ekstensi saya, itu gagal, dengan pesan kesalahan standar bahwa metode "tidak melempar pengecualian".

Ini membingungkan: Saya telah dengan jelas meneruskan nullke fungsinya, namun untuk beberapa alasan perbandingannya null == nullkembali false. Oleh karena itu, tidak ada pengecualian yang dilempar dan kode berlanjut.

Saya telah mengonfirmasi bahwa ini bukan bug dengan pengujian: ketika menjalankan metode di proyek utama saya dengan panggilan ke Console.WriteLinedi ifblok perbandingan-null , tidak ada yang ditampilkan di konsol dan tidak ada pengecualian yang tertangkap oleh catchblok apa pun yang saya tambahkan. Selain itu, menggunakan string.IsNullOrEmptybukannya == nullmemiliki masalah yang sama.

Mengapa perbandingan yang seharusnya sederhana ini gagal?

ArtOfCode
sumber
5
Sudahkah Anda mencoba menelusuri kode? Itu mungkin akan menyelesaikannya dengan cukup cepat.
Matthew Haugen
1
Apa yang terjadi? (Apakah itu membuang sebuah pengecualian, jika demikian, yang satu dan apa baris?)
user2864740
@ user2864740 Saya telah menjelaskan semua yang terjadi. Tidak ada pengecualian, hanya pengujian yang gagal dan metode jalankan.
ArtOfCode
7
Iterator tidak akan dieksekusi sampai iterasi
BlueRaja - Danny Pflughoeft
2
Sama-sama. Yang ini juga membuat daftar "gotcha terburuk" Jon: stackoverflow.com/a/241180/88656 . Ini masalah yang cukup umum.
Eric Lippert

Jawaban:

158

Anda sedang menggunakan yield return. Saat melakukannya, kompilator akan menulis ulang metode Anda menjadi fungsi yang mengembalikan kelas yang dihasilkan yang mengimplementasikan mesin status.

Secara umum, ini menulis ulang penduduk setempat ke bidang kelas itu dan setiap bagian dari algoritme Anda di antara yield returninstruksi menjadi status. Anda dapat memeriksa dengan decompiler akan menjadi apa metode ini setelah kompilasi (pastikan untuk mematikan dekompilasi cerdas yang akan menghasilkan yield return).

Tetapi intinya adalah: kode metode Anda tidak akan dieksekusi sampai Anda mulai mengulang.

Cara biasa untuk memeriksa prasyarat adalah dengan membagi metode Anda menjadi dua:

public static IEnumerable<int> AllIndexesOf(this string str, string searchText)
{
    if (str == null)
        throw new ArgumentNullException("str");
    if (searchText == null)
        throw new ArgumentNullException("searchText");

    return AllIndexesOfCore(str, searchText);
}

private static IEnumerable<int> AllIndexesOfCore(string str, string searchText)
{
    for (int index = 0; ; index += searchText.Length)
    {
        index = str.IndexOf(searchText, index);
        if (index == -1)
            break;
        yield return index;
    }
}

Ini berfungsi karena metode pertama akan berperilaku seperti yang Anda harapkan (eksekusi langsung), dan akan mengembalikan mesin status yang diimplementasikan oleh metode kedua.

Perhatikan bahwa Anda juga harus memeriksa strparameter untuk null, karena metode ekstensi dapat dipanggil pada nullnilai, karena mereka hanya gula sintaksis.


Jika Anda penasaran tentang apa yang dilakukan compiler pada kode Anda, berikut adalah metode Anda, didekompilasi dengan dotPeek menggunakan opsi Show Compiler-generated Code .

public static IEnumerable<int> AllIndexesOf(this string str, string searchText)
{
  Test.<AllIndexesOf>d__0 allIndexesOfD0 = new Test.<AllIndexesOf>d__0(-2);
  allIndexesOfD0.<>3__str = str;
  allIndexesOfD0.<>3__searchText = searchText;
  return (IEnumerable<int>) allIndexesOfD0;
}

[CompilerGenerated]
private sealed class <AllIndexesOf>d__0 : IEnumerable<int>, IEnumerable, IEnumerator<int>, IEnumerator, IDisposable
{
  private int <>2__current;
  private int <>1__state;
  private int <>l__initialThreadId;
  public string str;
  public string <>3__str;
  public string searchText;
  public string <>3__searchText;
  public int <index>5__1;

  int IEnumerator<int>.Current
  {
    [DebuggerHidden] get
    {
      return this.<>2__current;
    }
  }

  object IEnumerator.Current
  {
    [DebuggerHidden] get
    {
      return (object) this.<>2__current;
    }
  }

  [DebuggerHidden]
  public <AllIndexesOf>d__0(int <>1__state)
  {
    base..ctor();
    this.<>1__state = param0;
    this.<>l__initialThreadId = Environment.CurrentManagedThreadId;
  }

  [DebuggerHidden]
  IEnumerator<int> IEnumerable<int>.GetEnumerator()
  {
    Test.<AllIndexesOf>d__0 allIndexesOfD0;
    if (Environment.CurrentManagedThreadId == this.<>l__initialThreadId && this.<>1__state == -2)
    {
      this.<>1__state = 0;
      allIndexesOfD0 = this;
    }
    else
      allIndexesOfD0 = new Test.<AllIndexesOf>d__0(0);
    allIndexesOfD0.str = this.<>3__str;
    allIndexesOfD0.searchText = this.<>3__searchText;
    return (IEnumerator<int>) allIndexesOfD0;
  }

  [DebuggerHidden]
  IEnumerator IEnumerable.GetEnumerator()
  {
    return (IEnumerator) this.System.Collections.Generic.IEnumerable<System.Int32>.GetEnumerator();
  }

  bool IEnumerator.MoveNext()
  {
    switch (this.<>1__state)
    {
      case 0:
        this.<>1__state = -1;
        if (this.searchText == null)
          throw new ArgumentNullException("searchText");
        this.<index>5__1 = 0;
        break;
      case 1:
        this.<>1__state = -1;
        this.<index>5__1 += this.searchText.Length;
        break;
      default:
        return false;
    }
    this.<index>5__1 = this.str.IndexOf(this.searchText, this.<index>5__1);
    if (this.<index>5__1 != -1)
    {
      this.<>2__current = this.<index>5__1;
      this.<>1__state = 1;
      return true;
    }
    goto default;
  }

  [DebuggerHidden]
  void IEnumerator.Reset()
  {
    throw new NotSupportedException();
  }

  void IDisposable.Dispose()
  {
  }
}

Ini adalah kode C # yang tidak valid, karena kompilator diizinkan untuk melakukan hal-hal yang tidak diizinkan oleh bahasa, tetapi legal di IL - misalnya menamai variabel dengan cara yang tidak dapat Anda lakukan untuk menghindari benturan nama.

Tapi seperti yang Anda lihat, AllIndexesOfsatu - satunya membangun dan mengembalikan sebuah objek, yang konstruktornya hanya menginisialisasi beberapa keadaan. GetEnumeratorhanya menyalin objek. Pekerjaan sebenarnya dilakukan ketika Anda mulai menghitung (dengan memanggil MoveNextmetode).

Lucas Trzesniewski
sumber
9
BTW, saya menambahkan poin penting berikut ke jawabannya: Perhatikan bahwa Anda juga harus memeriksa strparameternya null, karena metode ekstensi dapat dipanggil pada nullnilai, karena mereka hanya gula sintaksis.
Lucas Trzesniewski
2
yield returnpada prinsipnya adalah ide yang bagus, tetapi memiliki banyak hal aneh. Terima kasih telah mengungkap yang satu ini!
nateirvin
Jadi, pada dasarnya kesalahan akan terjadi jika enumarator dijalankan, seperti di foreach?
MVCDS
1
@VCD Persis. MoveNextdisebut di bawah tenda oleh foreachkonstruksi. Saya menulis penjelasan tentang apa yang foreachada dalam jawaban saya menjelaskan semantik koleksi jika Anda ingin melihat pola yang tepat.
Lucas Trzesniewski
34

Anda memiliki blok iterator. Tidak ada kode dalam metode itu yang pernah dijalankan di luar panggilan ke MoveNextpada iterator yang dikembalikan. Memanggil metode tidak akan mencatat tetapi membuat mesin status, dan itu tidak akan pernah gagal (di luar yang ekstrem seperti kesalahan kehabisan memori, stack overflows, atau pengecualian pembatalan thread).

Saat Anda benar-benar mencoba mengulang urutan, Anda akan mendapatkan pengecualian.

Inilah sebabnya mengapa metode LINQ sebenarnya membutuhkan dua metode untuk memiliki semantik penanganan kesalahan yang mereka inginkan. Mereka memiliki metode pribadi yang merupakan blok iterator, dan kemudian metode blok non-iterator yang tidak melakukan apa-apa selain melakukan validasi argumen (sehingga dapat dilakukan dengan penuh semangat, daripada ditunda) sambil tetap menunda semua fungsionalitas lainnya.

Jadi ini pola umumnya:

public static IEnumerable<T> Foo<T>(
    this IEnumerable<T> souce, Func<T, bool> anotherArgument)
{
    //note, not an iterator block
    if(anotherArgument == null)
    {
        //TODO make a fuss
    }
    return FooImpl(source, anotherArgument);
}

private static IEnumerable<T> FooImpl<T>(
    IEnumerable<T> souce, Func<T, bool> anotherArgument)
{
    //TODO actual implementation as an iterator block
    yield break;
}
Pelayanan
sumber
0

Pencacah, seperti yang dikatakan orang lain, tidak dievaluasi sampai mereka mulai melakukan pencacahan (yaitu, IEnumerable.GetNextmetode ini dipanggil). Jadi ini

List<int> indexes = "a.b.c.d.e".AllIndexesOf(null).ToList<int>();

tidak dievaluasi sampai Anda mulai menghitung, yaitu

foreach(int index in indexes)
{
    // ArgumentNullException
}
Jenna
sumber