Start Debugging

Solução: [MemberNotNull] não tem efeito em um método factory estático e o CS8618 continua aparecendo

O CS8618 é verificado quando o construtor termina, e [MemberNotNull] em um método estático é ignorado em silêncio para membros de instância. Passe os valores para um construtor privado.

[MemberNotNull] em um método factory estático não consegue eliminar o CS8618, porque o aviso é reportado no construtor, no momento em que o construtor termina, e o compilador nunca olha o que o factory faz depois. Além disso, um método estático não tem this, então quando o seu [MemberNotNull] nomeia um membro de instância o compilador ignora o atributo em silêncio: nenhum aviso no método, nenhum efeito para quem chama. A solução é levar a atribuição de volta para um construtor: faça o factory calcular os valores e passá-los para um construtor privado, ou marque os membros como required e atribua-os em um inicializador de objeto dentro do factory. Verificado com o SDK do .NET 10 10.0.302 (C# 14); o comportamento é o mesmo desde que o [MemberNotNull] chegou no C# 9.

O erro em contexto

A saída do build aponta para o construtor privado, não para o factory que você anotou:

Widget.cs(8,13): warning CS8618: Non-nullable property 'Name' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Não existe um segundo diagnóstico dizendo que o atributo foi ignorado. Esse silêncio é o que torna tudo confuso: o [MemberNotNull(nameof(Name))] parece que deveria fazer alguma coisa, e nada no build diz o contrário.

Por que isso acontece

Duas regras diferentes se combinam aqui.

O CS8618 é verificado por construtor, na saída dele. O compilador roda a análise de fluxo de nulabilidade sobre o corpo de cada construtor (mais os inicializadores de campos e propriedades) e faz uma única pergunta na chave de fechamento: todo membro de tipo de referência não anulável recebeu com certeza um valor não nulo? Ele não acompanha o objeto depois que o construtor retorna. Um factory que faz var w = new Widget(); w.Name = name; return w; atribui Name quando o construtor já terminou com Name ainda null, e é nessa saída que o aviso mora. O time do Roslyn decidiu desde cedo não rastrear a inicialização através de chamadas de método (“We’ve decided not to track initialization interprocedurally for now” em dotnet/roslyn#32358). [MemberNotNull] é a válvula de escape opcional para exatamente um caso: um helper chamado de dentro do construtor sobre this.

[MemberNotNull] em um método estático só significa algo para membros estáticos. O atributo é uma pós-condição: “quando este método retorna, estes membros do receptor não são nulos”. Para um método de instância o receptor é this (ou o objeto sobre o qual você chamou). Um método estático não tem receptor, então os únicos membros pelos quais ele pode responder são campos e propriedades estáticos. Quando você lista um membro de instância, o Roslyn aceita o nameof (compila normalmente porque nameof(Name) é válido em qualquer lugar da classe) e depois descarta a anotação. Confirmei as duas metades no SDK 10.0.302: um método estático marcado com [MemberNotNull(nameof(Nick))] que nunca atribui Nick não gera CS8774, e um método de instância que o chama continua recebendo CS8602 ao desreferenciar Nick em seguida. O mesmo atributo nomeando um campo estático é validado e respeitado.

Então o factory estático falha nas duas frentes: ele não é o construtor, e a anotação dele nomeia membros que ele não consegue descrever.

Repro mínimo

// .NET 10 SDK 10.0.302, C# 14, <Nullable>enable</Nullable>
using System.Diagnostics.CodeAnalysis;

public class Widget
{
    public string Name { get; set; }

    private Widget() { }                     // CS8618 is reported here

    [MemberNotNull(nameof(Name))]            // silently ignored: static method, instance member
    public static Widget Create(string name)
    {
        var w = new Widget();
        w.Name = name;
        return w;
    }
}

A segunda forma do mesmo erro é um helper estático que recebe a instância como parâmetro e é chamado a partir do construtor. Parece o padrão documentado, mas não é:

// .NET 10 SDK 10.0.302, C# 14
using System.Diagnostics.CodeAnalysis;

public class Gadget
{
    public string Name { get; set; }

    public Gadget(string name) => Init(this, name);   // CS8618 still fires here

    [MemberNotNull(nameof(Name))]
    private static void Init(Gadget g, string name) => g.Name = name;
}

[MemberNotNull] descreve o receptor da chamada, e Init(this, name) não tem receptor. Passar this como argumento não conta. A única versão estática que funciona é aquela em que tanto o helper quanto os membros são estáticos, por exemplo um construtor estático chamando um carregador estático:

// .NET 10 SDK 10.0.302, C# 14
using System.Diagnostics.CodeAnalysis;

public static class Config
{
    public static string ConnectionString;

    static Config() => Load();                 // no CS8618: static helper, static member

    [MemberNotNull(nameof(ConnectionString))]
    private static void Load() => ConnectionString = "Server=.;Database=app";
}

A correção em detalhes

Todas as correções abaixo levam a atribuição de volta para onde o CS8618 consegue enxergá-la: dentro de um construtor, ou atrás de um contrato que o compilador entende. Estão em ordem de preferência.

1. Passe os valores calculados para um construtor privado

Esse é o formato certo para quase todo factory. O factory faz o trabalho (validação, parsing, E/S) e então entrega valores prontos a um construtor que só atribui:

// .NET 10 SDK 10.0.302, C# 14
public sealed class Widget
{
    public string Name { get; }

    private Widget(string name) => Name = name;

    public static Widget Create(string raw)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(raw);
        return new Widget(raw.Trim());
    }
}

Sem atributo, sem supressão, e Name pode virar somente leitura, algo que um factory do tipo “constrói e depois atribui” jamais poderia oferecer. Se você tem vários membros, o construtor simplesmente ganha parâmetros. Um record com um construtor posicional privado é a versão compacta da mesma ideia.

2. Transforme o helper estático em uma função que retorna o valor

Para o formato Init(this, ...), mude o helper para que ele retorne o que calculou, e deixe o construtor fazer a atribuição:

// .NET 10 SDK 10.0.302, C# 14
public sealed class Gadget
{
    public string Name { get; }

    public Gadget(string raw) => Name = Normalize(raw);

    private static string Normalize(string raw) => raw.Trim().ToLowerInvariant();
}

Um método estático com valor de retorno é mais fácil de testar do que um que altera um parâmetro, e o compilador vê uma atribuição simples no construtor. Quando o helper produz vários valores, retorne uma tupla e faça a desconstrução: (Name, Slug) = Parse(raw); é uma atribuição definitiva dos dois membros.

3. Torne o helper um método de instância e chame-o a partir do construtor

Se a inicialização realmente é um bloco de código imperativo compartilhado, mantenha-a em um helper, mas faça dele um método de instância para que o [MemberNotNull] descreva this:

// .NET 10 SDK 10.0.302, C# 14
using System.Diagnostics.CodeAnalysis;

public sealed class Report
{
    public string Title { get; private set; }

    private Report(string raw) => Normalize(raw);

    [MemberNotNull(nameof(Title))]
    private void Normalize(string raw) => Title = raw.Trim();

    public static Report Create(string raw) => new(raw);
}

Esse é o padrão que a página do Microsoft Learn sobre atributos de nulabilidade documenta. Diferente do caso estático, o compilador verifica o helper: se Normalize puder retornar sem atribuir Title, você recebe CS8774: Member 'Title' must have a non-null value when exiting.

Uma armadilha: chamar o helper de instância a partir do factory sobre o objeto novo (var r = new Report(); r.Normalize(raw);) faz r.Title ser não nulo pelo resto do factory, porque [MemberNotNull] se aplica a qualquer receptor sobre o qual você o chama. Ainda assim não faz nada pelo CS8618, porque o construtor já tinha terminado antes de essa linha rodar.

4. Marque os membros como required e use um inicializador de objeto no factory

Se você prefere construção no estilo de propriedades, required move a obrigação do construtor para a expressão de criação do objeto, e o factory é o único lugar que pode criar um:

// .NET 10 SDK 10.0.302, C# 14
public sealed class Invoice
{
    public required string Number { get; init; }
    public required string Customer { get; init; }

    private Invoice() { }

    public static Invoice Create(string number, string customer) =>
        new() { Number = number.Trim(), Customer = customer.Trim() };
}

O CS8618 some porque um membro required não é responsabilidade do construtor. Você também ganha uma rede de segurança que o factory original nunca teve: esqueça um deles e o build falha com CS9035: Required member 'Invoice.Customer' must be set in the object initializer or attribute constructor. Isso é um erro, não um aviso. O passo a passo do CS9035 cobre o lado de quem chama nesse contrato.

5. Use [SetsRequiredMembers] em um construtor privado

Se o tipo tem membros required para os chamadores públicos, mas o factory quer passar valores de forma posicional, adicione um construtor que promete atribuir todos eles:

// .NET 10 SDK 10.0.302, C# 14
using System.Diagnostics.CodeAnalysis;

public sealed class Tag
{
    public required string Value { get; init; }

    public Tag() { }

    [SetsRequiredMembers]
    private Tag(string value) => Value = value;

    public static Tag Create(string raw) => new(raw.Trim().ToLowerInvariant());
}

Assim como [MemberNotNull], [SetsRequiredMembers] é confiado, não verificado: se esse construtor esquecer um membro, nada avisa. Mantenha o construtor curto o bastante para conferir de olho.

6. Último recurso: suprima no construtor, não na propriedade

Se o tipo é construído por um framework que exige um construtor sem parâmetros e preenche os membros depois, restrinja a supressão a esse construtor para não perder a análise em nenhum outro lugar:

// .NET 10 SDK 10.0.302, C# 14
public sealed class LegacyRow
{
    public string Name { get; private set; }

#pragma warning disable CS8618 // Name is always assigned by Create before the instance escapes
    private LegacyRow() { }
#pragma warning restore CS8618

    public static LegacyRow Create(string name) => new() { Name = name };
}

Isso é honesto sobre o que está acontecendo, e o comentário diz ao próximo leitor qual método sustenta a promessa. Prefira isso a = null! na propriedade: null! silencia o aviso para todo construtor que o tipo venha a ter, inclusive os adicionados depois que de fato esquecem de atribuir.

Armadilhas e variantes

Relacionados

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar