Dlaczego ta metoda rozszerzenia ciągu nie zgłasza wyjątku?

119

Mam metodę rozszerzenia ciągu C #, która powinna zwrócić IEnumerable<int>wszystkie indeksy podciągu w ciągu. Działa idealnie zgodnie z przeznaczeniem, a oczekiwane wyniki są zwracane (co udowodnił jeden z moich testów, choć nie ten poniżej), ale inny test jednostkowy wykrył z nim problem: nie radzi sobie z argumentami zerowymi.

Oto metoda rozszerzenia, którą testuję:

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;
    }
}

Oto test, który zgłosił problem:

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

Gdy test zostanie uruchomiony na mojej metodzie rozszerzającej, kończy się niepowodzeniem i wyświetlany jest standardowy komunikat o błędzie, że metoda „nie zgłosiła wyjątku”.

To jest mylące: wyraźnie przeszedłem nulldo funkcji, ale z jakiegoś powodu porównanie null == nullwraca false. W związku z tym nie jest zgłaszany żaden wyjątek, a kod jest kontynuowany.

Potwierdziłem, że to nie jest błąd testu: podczas uruchamiania metody w moim głównym projekcie z wywołaniem Console.WriteLinew ifbloku porównania null nic nie jest wyświetlane na konsoli i żaden z catchdodanych przeze mnie bloków nie wychwytuje żadnego wyjątku . Co więcej, używanie string.IsNullOrEmptyzamiast == nullma ten sam problem.

Dlaczego to rzekomo proste porównanie zawodzi?

ArtOfCode
źródło
5
Czy próbowałeś przejść przez kod? To prawdopodobnie rozwiąże problem dość szybko.
Matthew Haugen
1
Co się dzieje? (Czy rzucać się wyjątek, jeśli tak, których jedna linia i co?)
user2864740
@ user2864740 Opisałem wszystko, co się dzieje. Bez wyjątków, tylko nieudany test i metoda uruchomienia.
ArtOfCode
7
Iteratory nie są wykonywane, dopóki nie zostaną iterowane
BlueRaja - Danny Pflughoeft
2
Nie ma za co. Ten również znalazł się na liście najgorszych rzeczy Jona: stackoverflow.com/a/241180/88656 . To dość powszechny problem.
Eric Lippert

Odpowiedzi:

158

Używasz yield return. Robiąc to, kompilator przepisuje twoją metodę do funkcji, która zwraca wygenerowaną klasę, która implementuje maszynę stanów.

Mówiąc ogólnie, przepisuje lokalne zmienne do pól tej klasy i każda część algorytmu między yield returninstrukcjami staje się stanem. Możesz sprawdzić za pomocą dekompilatora, czym ta metoda staje się po kompilacji (upewnij się, że wyłączono inteligentną dekompilację, która przyniosłaby efekt yield return).

Ale najważniejsze jest to, że kod twojej metody nie zostanie wykonany, dopóki nie zaczniesz iteracji.

Zwykłym sposobem sprawdzenia warunków wstępnych jest podzielenie metody na dwie części:

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;
    }
}

To działa, ponieważ pierwsza metoda będzie zachowywać się tak, jak się spodziewasz (natychmiastowe wykonanie) i zwróci maszynę stanu zaimplementowaną przez drugą metodę.

Zauważ, że powinieneś również sprawdzić strparametr for null, ponieważ metody rozszerzeń mogą być wywoływane na nullwartościach, ponieważ są one po prostu cukrem składniowym.


Jeśli jesteś ciekawy, co kompilator robi z twoim kodem, oto twoja metoda, zdekompilowana za pomocą dotPeek przy użyciu opcji Pokaż kod wygenerowany przez kompilator .

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()
  {
  }
}

Jest to nieprawidłowy kod C #, ponieważ kompilator może robić rzeczy, których język nie zezwala, ale które są legalne w IL - na przykład nazwanie zmiennych w sposób, w jaki nie można uniknąć kolizji nazw.

Ale jak widać, AllIndexesOfjedyny konstruuje i zwraca obiekt, którego konstruktor inicjuje tylko pewien stan. GetEnumeratorkopiuje tylko obiekt. Prawdziwa praca jest wykonywana, gdy zaczynasz wyliczać (przez wywołanie MoveNextmetody).

Lucas Trzesniewski
źródło
9
Przy okazji, dodałem do odpowiedzi następujący ważny punkt: Zauważ, że powinieneś również sprawdzić strparametr for null, ponieważ metody rozszerzeń mogą być wywoływane na nullwartościach, ponieważ są one po prostu cukrem syntaktycznym.
Lucas Trzesniewski
2
yield returnTo w zasadzie fajny pomysł, ale ma wiele dziwnych problemów. Dzięki za wyciągnięcie tego na światło dzienne!
nateirvin
Więc w zasadzie błąd zostałby wyrzucony, gdyby enumarator został uruchomiony, jak w foreach?
MVCDS
1
@MVCDS Dokładnie. MoveNextjest nazywany pod maską przez foreachkonstrukcję. Napisałem wyjaśnienie, co foreachw mojej odpowiedzi wyjaśnia semantykę zbioru, jeśli chcesz zobaczyć dokładny wzór.
Lucas Trzesniewski
34

Masz blok iteratora. Żaden kod w tej metodzie nie jest nigdy uruchamiany poza wywołaniami MoveNextw zwróconym iteratorze. Wywołanie metody powoduje odnotowanie, ale utworzenie maszyny stanu, a to nigdy nie zawiedzie (poza skrajnościami, takimi jak błędy braku pamięci, przepełnienia stosu lub wyjątki przerywania wątków).

Kiedy faktycznie spróbujesz powtórzyć sekwencję, otrzymasz wyjątki.

Dlatego metody LINQ faktycznie potrzebują dwóch metod, aby mieć żądaną semantykę obsługi błędów. Mają prywatną metodę, która jest blokiem iteratora, a następnie metodę blokową bez iteratora, która nie robi nic poza walidacją argumentów (aby można było to zrobić chętnie, a nie odkładać), jednocześnie odraczając wszystkie inne funkcje.

Więc to jest ogólny wzór:

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;
}
Servy
źródło
0

Enumeratory, jak powiedzieli inni, nie są oceniane, dopóki nie zaczną się wyliczać (tj. IEnumerable.GetNextMetoda zostanie wywołana). Tak więc to

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

nie jest oceniany, dopóki nie zaczniesz wyliczać, tj

foreach(int index in indexes)
{
    // ArgumentNullException
}
Jenna
źródło