Por que esse método de extensão de cadeia não gera uma exceção?

119

Eu tenho um método de extensão de string c # que deve retornar um IEnumerable<int>de todos os índices de uma substring dentro de uma string. Funciona perfeitamente para a finalidade pretendida e os resultados esperados são retornados (como comprovado por um dos meus testes, embora não o abaixo), mas outro teste de unidade descobriu um problema: ele não pode lidar com argumentos nulos.

Aqui está o método de extensão que estou 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;
    }
}

Aqui está o teste que sinalizou o problema:

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

Quando o teste é executado no meu método de extensão, ele falha, com a mensagem de erro padrão de que o método "não lançou uma exceção".

Isso é confuso: eu claramente passei nullpara a função, mas por algum motivo a comparação null == nullestá retornando false. Portanto, nenhuma exceção é lançada e o código continua.

Confirmei que este não é um erro no teste: ao executar o método no meu projeto principal com uma chamada Console.WriteLineno ifbloco de comparação nula , nada é mostrado no console e nenhuma exceção é capturada por nenhum catchbloco adicionado. Além disso, usar em string.IsNullOrEmptyvez de == nulltem o mesmo problema.

Por que essa comparação supostamente simples falha?

ArtOfCode
fonte
5
Você já tentou percorrer o código? Isso provavelmente resolverá rapidamente.
Matthew Haugen
1
O que acontece? (Será que ela jogue uma exceção, em caso afirmativo, qual e que linha?)
user2864740
@ user2864740 descrevi tudo o que acontece. Sem exceções, apenas um teste com falha e um método de execução.
ArtOfCode
7
Iteradores não são executados até que eles estão iterated-over
BlueRaja - Danny Pflughoeft
2
De nada. Este também fez a lista de "piores pegadinhas" de Jon: stackoverflow.com/a/241180/88656 . Este é um problema bastante comum.
Eric Lippert

Respostas:

158

Você está usando yield return. Ao fazer isso, o compilador reescreverá seu método em uma função que retorna uma classe gerada que implementa uma máquina de estado.

Em termos gerais, ele reescreve os locais nos campos dessa classe e cada parte do seu algoritmo entre as yield returninstruções se torna um estado. Você pode verificar com um descompilador o que esse método se torna após a compilação (desative a descompilação inteligente que produziria yield return).

Mas a linha inferior é: o código do seu método não será executado até que você comece a iterar.

A maneira usual de verificar pré-condições é dividir seu método em dois:

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

Isso funciona porque o primeiro método se comportará exatamente como você espera (execução imediata) e retornará a máquina de estado implementada pelo segundo método.

Observe que você também deve verificar o strparâmetro null, pois os métodos de extensões podem ser chamados em nullvalores, pois são apenas açúcar sintático.


Se você estiver curioso sobre o que o compilador faz com o seu código, aqui está o seu método, descompilado com dotPeek usando a opção Mostrar código gerado pelo compilador .

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

Este é um código C # inválido, porque o compilador pode fazer coisas que a linguagem não permite, mas que são legais na IL - por exemplo, nomear as variáveis ​​de uma maneira que você não pode evitar colisões de nomes.

Mas como você pode ver, o AllIndexesOfúnico constrói e retorna um objeto, cujo construtor inicializa apenas algum estado. GetEnumeratorapenas copia o objeto. O trabalho real é feito quando você começa a enumerar (chamando o MoveNextmétodo).

Lucas Trzesniewski
fonte
9
BTW, adicionei o seguinte ponto importante à resposta: Observe que você também deve verificar o strparâmetro null, pois os métodos de extensões podem ser chamados em nullvalores, pois são apenas açúcar sintático.
Lucas Trzesniewski
2
yield returné uma boa ideia em princípio, mas tem muitas dicas estranhas. Obrigado por trazer este à luz!
Nateirvin
Então, basicamente, seria gerado um erro se o enumarator fosse executado, como em um foreach?
MVCDS 13/05
1
@MVCDS Exatamente. MoveNexté chamado sob o capô pela foreachconstrução. Eu escrevi uma explicação do que foreachfaz na minha resposta explicando a semântica da coleção, se você quiser ver o padrão exato.
Lucas Trzesniewski
34

Você tem um bloco iterador. Nenhum código nesse método é executado fora das chamadas MoveNextno iterador retornado. A chamada do método não é importante, mas cria a máquina de estado, e isso nunca falha (fora de extremos, como erros de falta de memória, estouros de pilha ou exceções de interrupção de encadeamento).

Quando você realmente tenta repetir a sequência, obtém as exceções.

É por isso que os métodos LINQ realmente precisam de dois métodos para obter a semântica de manipulação de erros que desejam. Eles têm um método privado que é um bloco iterador e, em seguida, um método de bloco não iterador que não faz nada além da validação do argumento (para que possa ser feito com entusiasmo, em vez de ser adiado), enquanto adia todas as outras funcionalidades.

Portanto, este é o padrão geral:

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
fonte
0

Os enumeradores, como os outros disseram, não são avaliados até o momento em que começam a ser enumerados (ou seja, o IEnumerable.GetNextmétodo é chamado). Assim, este

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

não é avaliado até você começar a enumerar, ou seja,

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