Warum löst diese String-Erweiterungsmethode keine Ausnahme aus?

119

Ich habe eine C # -String-Erweiterungsmethode, die einen IEnumerable<int>der Indizes eines Teilstrings innerhalb eines Strings zurückgeben soll. Es funktioniert perfekt für den beabsichtigten Zweck und die erwarteten Ergebnisse werden zurückgegeben (wie durch einen meiner Tests bewiesen, obwohl nicht der folgende), aber ein anderer Komponententest hat ein Problem damit entdeckt: Es kann keine Nullargumente verarbeiten.

Hier ist die Erweiterungsmethode, die ich teste:

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

Hier ist der Test, der das Problem gemeldet hat:

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

Wenn der Test für meine Erweiterungsmethode ausgeführt wird, schlägt er mit der Standardfehlermeldung fehl, dass die Methode "keine Ausnahme ausgelöst hat".

Das ist verwirrend: Ich bin eindeutig nullauf die Funktion übergegangen , aber aus irgendeinem Grund null == nullkehrt der Vergleich zurückfalse . Daher wird keine Ausnahme ausgelöst und der Code wird fortgesetzt.

Ich habe bestätigt, dass dies kein Fehler beim Test ist: Wenn Sie die Methode in meinem Hauptprojekt mit einem Aufruf von Console.WriteLineim Nullvergleichsblock ifausführen, wird auf der Konsole nichts angezeigt und keine Ausnahme wird von einem von catchmir hinzugefügten Block abgefangen . Darüber hinaus hat die Verwendung von string.IsNullOrEmptyanstelle von == nulldas gleiche Problem.

Warum scheitert dieser vermeintlich einfache Vergleich?

ArtOfCode
quelle
5
Haben Sie versucht, den Code durchzugehen? Das wird es wahrscheinlich ziemlich schnell lösen.
Matthew Haugen
1
Was ist passiert? (Wirft es eine Ausnahme; wenn ja, welche und welche Zeile?)
user2864740
@ user2864740 Ich habe alles beschrieben, was passiert. Keine Ausnahmen, nur ein fehlgeschlagener Test und eine Ausführungsmethode.
ArtOfCode
7
Iteratoren werden erst ausgeführt, wenn sie wiederholt wurden
BlueRaja - Danny Pflughoeft
2
Bitte. Dieser machte auch Jons "schlechteste Gotcha" -Liste: stackoverflow.com/a/241180/88656 . Dies ist ein recht häufiges Problem.
Eric Lippert

Antworten:

158

Sie verwenden yield return. Dabei schreibt der Compiler Ihre Methode in eine Funktion um, die eine generierte Klasse zurückgibt, die eine Zustandsmaschine implementiert.

Im Allgemeinen werden die Einheimischen in Felder dieser Klasse umgeschrieben, und jeder Teil Ihres Algorithmus zwischen den yield returnAnweisungen wird zu einem Status. Sie können mit einem Dekompiler überprüfen, wie diese Methode nach der Kompilierung aussehen wird (stellen Sie sicher, dass die intelligente Dekompilierung deaktiviert ist, die dazu führen würdeyield return ).

Das Fazit lautet jedoch: Der Code Ihrer Methode wird erst ausgeführt, wenn Sie mit der Iteration beginnen.

Die übliche Methode, um nach Voraussetzungen zu suchen, besteht darin, Ihre Methode in zwei Teile zu teilen:

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

Dies funktioniert, weil sich die erste Methode wie erwartet verhält (sofortige Ausführung) und die von der zweiten Methode implementierte Zustandsmaschine zurückgibt.

Beachten Sie, dass Sie auch die überprüfen sollten , strParameter für null, da Erweiterungen Methoden können aufgefordert werden , nullWerte, da sie nur syntaktischer Zucker sind.


Wenn Sie neugierig sind, was der Compiler mit Ihrem Code macht, finden Sie hier Ihre Methode, die mit dotPeek mithilfe der Option Vom Compiler generierten Code anzeigen dekompiliert wurde .

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

Dies ist ein ungültiger C # -Code, da der Compiler Dinge tun darf, die die Sprache nicht zulässt, die aber in IL legal sind - zum Beispiel die Variablen so zu benennen, dass Namenskollisionen nicht vermieden werden können.

Wie Sie jedoch sehen können, erstellt AllIndexesOfund gibt der einzige ein Objekt zurück, dessen Konstruktor nur einen bestimmten Status initialisiert. GetEnumeratorkopiert nur das Objekt. Die eigentliche Arbeit ist erledigt, wenn Sie mit der Aufzählung beginnen (durch Aufrufen der MoveNextMethode).

Lucas Trzesniewski
quelle
9
Übrigens habe ich der Antwort den folgenden wichtigen Punkt hinzugefügt: Beachten Sie, dass Sie auch den strParameter überprüfen sollten null, da Erweiterungsmethoden für nullWerte aufgerufen werden können, da es sich nur um syntaktischen Zucker handelt.
Lucas Trzesniewski
2
yield returnist im Prinzip eine schöne Idee, aber es gibt so viele seltsame Fallstricke. Danke, dass du dieses ans Licht gebracht hast!
Nateirvin
Im Grunde genommen würde also ein Fehler ausgegeben, wenn der Enumarator ausgeführt würde, wie in einem Foreach?
MVCDS
1
@ MVCDS Genau. MoveNextwird vom foreachKonstrukt unter der Haube genannt . Ich foreachhabe in meiner Antwort eine Erklärung darüber geschrieben, was die Semantik der Sammlung erklärt, wenn Sie das genaue Muster sehen möchten.
Lucas Trzesniewski
34

Sie haben einen Iteratorblock. Keiner der Codes in dieser Methode wird jemals außerhalb von Aufrufen von ausgeführtMoveNext des zurückgegebenen Iterators ausgeführt. Wenn Sie die Methode aufrufen, wird die Zustandsmaschine zwar notiert, aber erstellt, und dies wird niemals fehlschlagen (außerhalb von Extremen wie Speicherfehlern, Stapelüberläufen oder Thread-Abbruch-Ausnahmen).

Wenn Sie tatsächlich versuchen, die Sequenz zu wiederholen, erhalten Sie die Ausnahmen.

Aus diesem Grund benötigen die LINQ-Methoden tatsächlich zwei Methoden, um die gewünschte Semantik für die Fehlerbehandlung zu erhalten. Sie haben eine private Methode, die ein Iteratorblock ist, und dann eine Nicht-Iteratorblockmethode, die nichts anderes tut, als die Argumentvalidierung durchzuführen (damit sie eifrig durchgeführt werden kann, anstatt sie zu verschieben), während alle anderen Funktionen immer noch zurückgestellt werden.

Das ist also das allgemeine Muster:

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;
}
Servieren
quelle
0

Enumeratoren werden, wie die anderen gesagt haben, erst ausgewertet, wenn sie mit der Aufzählung beginnen (dh die IEnumerable.GetNextMethode wird aufgerufen). Also das hier

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

wird erst ausgewertet, wenn Sie mit der Aufzählung beginnen, d. h

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