Perché questo metodo di estensione della stringa non genera un'eccezione?


119

Ho un metodo di estensione della stringa C # che dovrebbe restituire uno IEnumerable<int>di tutti gli indici di una sottostringa all'interno di una stringa. Funziona perfettamente per lo scopo previsto e vengono restituiti i risultati attesi (come dimostrato da uno dei miei test, anche se non quello di seguito), ma un altro test unitario ha scoperto un problema con esso: non può gestire argomenti nulli.

Ecco il metodo di estensione che sto testando:

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

Ecco il test che ha segnalato il problema:

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

Quando il test viene eseguito sul mio metodo di estensione, non riesce, con il messaggio di errore standard che il metodo "non ha generato un'eccezione".

Questo crea confusione: sono passato chiaramente nullalla funzione, ma per qualche motivo il confronto null == nullsta tornando false. Pertanto, non viene generata alcuna eccezione e il codice continua.

Ho confermato che questo non è un bug con il test: quando eseguo il metodo nel mio progetto principale con una chiamata a Console.WriteLinenel ifblocco di confronto nullo , non viene mostrato nulla sulla console e nessuna eccezione viene catturata da alcun catchblocco che aggiungo. Inoltre, usare string.IsNullOrEmptyinvece di == nullha lo stesso problema.

Perché questo apparentemente semplice confronto fallisce?


5
Hai provato a leggere il codice? Questo probabilmente lo risolverà abbastanza rapidamente.
Matthew Haugen

1
Cosa non succede? (Lo fa gettare un'eccezione, in caso affermativo, quale e quale linea?)
user2864740

@ user2864740 Ho descritto tutto ciò che accade. Nessuna eccezione, solo un test fallito e un metodo di esecuzione.
ArtOfCode

7
Gli iteratori non vengono eseguiti finché non vengono ripetuti
BlueRaja - Danny Pflughoeft

2
Prego. Questo è anche stato inserito nell'elenco dei "peggiori trucchi" di Jon: stackoverflow.com/a/241180/88656 . Questo è un problema abbastanza comune.
Eric Lippert

Risposte:


158

Stai usando yield return. Quando lo fai, il compilatore riscriverà il tuo metodo in una funzione che restituisce una classe generata che implementa una macchina a stati.

In generale, riscrive i locali nei campi di quella classe e ogni parte del tuo algoritmo tra le yield returnistruzioni diventa uno stato. Puoi controllare con un decompilatore cosa diventa questo metodo dopo la compilazione (assicurati di disattivare la decompilazione intelligente che produrrebbe yield return).

Ma la linea di fondo è: il codice del tuo metodo non verrà eseguito finché non inizi a iterare.

Il modo usuale per verificare le precondizioni è dividere il metodo in due:

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

Questo funziona perché il primo metodo si comporterà proprio come ci si aspetta (esecuzione immediata) e restituirà la macchina a stati implementata dal secondo metodo.

Nota che dovresti anche controllare il strparametro null, perché i metodi di estensione possono essere chiamati sui nullvalori, poiché sono solo zucchero sintattico.


Se sei curioso di sapere cosa fa il compilatore al tuo codice, ecco il tuo metodo, decompilato con dotPeek usando l' opzione Mostra codice generato dal compilatore .

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

Questo non è un codice C # non valido, perché il compilatore è autorizzato a fare cose che il linguaggio non consente, ma che sono legali in IL, ad esempio denominare le variabili in un modo che non è possibile evitare conflitti di nomi.

Ma come puoi vedere, the AllIndexesOfonly costruisce e restituisce un oggetto, il cui costruttore inizializza solo uno stato. GetEnumeratorcopia solo l'oggetto. Il vero lavoro viene fatto quando inizi a enumerare (chiamando il MoveNextmetodo).


9
BTW, ho aggiunto il seguente punto importante alla risposta: Nota che dovresti anche controllare il strparametro null, perché i metodi di estensione possono essere chiamati sui nullvalori, poiché sono solo zucchero sintattico.
Lucas Trzesniewski

2
yield returnè una bella idea in linea di principio, ma ha così tanti strani trucchi. Grazie per aver portato questo alla luce!
nateirvin

Quindi, fondamentalmente, verrebbe generato un errore se l'enumarator fosse eseguito, come in un foreach?
MVCDS

1
@MVCDS Esattamente. MoveNextè chiamato sotto il cofano dal foreachcostrutto. Ho scritto una spiegazione di cosa foreachfa nella mia risposta spiegando la semantica della raccolta se desideri vedere il modello esatto.
Lucas Trzesniewski

34

Hai un blocco iteratore. Nessuno del codice in quel metodo viene mai eseguito al di fuori delle chiamate a MoveNextsull'iteratore restituito. La chiamata al metodo non fa altro che creare la macchina a stati e ciò non fallirà mai (al di fuori di estremi come errori di memoria insufficiente, overflow dello stack o eccezioni di interruzione del thread).

Quando si tenta effettivamente di iterare la sequenza, si ottengono le eccezioni.

Questo è il motivo per cui i metodi LINQ necessitano effettivamente di due metodi per avere la semantica di gestione degli errori che desiderano. Hanno un metodo privato che è un blocco iteratore e quindi un metodo di blocco non iteratore che non fa altro che eseguire la convalida dell'argomento (in modo che possa essere fatto con entusiasmo, piuttosto che essere differito) mentre rimanda ancora tutte le altre funzionalità.

Quindi questo è lo schema generale:

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

0

Gli enumeratori, come hanno detto gli altri, non vengono valutati fino al momento in cui iniziano a essere enumerati (ovvero IEnumerable.GetNextviene chiamato il metodo). Quindi questo

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

non viene valutato finché non inizi a enumerare, ad es

foreach(int index in indexes)
{
    // ArgumentNullException
}
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.