Start Debugging

Решение: CS8618 "Non-nullable property must contain a non-null value when exiting constructor" в C#

CS8618 означает, что ссылочное поле или свойство, не допускающее null, не было инициализировано к моменту завершения конструктора. Присвойте его в конструкторе, задайте значение по умолчанию, пометьте required или сделайте допускающим null.

CS8618 возникает, когда для ссылочного члена, не допускающего null (поля или автосвойства), не гарантировано наличие ненулевого значения к моменту завершения конструктора. Компилятор не может доказать, что член был присвоен, поэтому предупреждает, что null может просочиться наружу. Исправьте это одним из четырёх способов, в примерном порядке предпочтения: присвойте его в конструкторе, задайте инициализатор поля, пометьте required, чтобы вызывающий код обязан был его задать, или сделайте член допускающим null (string?), если null действительно допустим. Проверено с C# 14 на .NET 11; диагностика ведёт себя так с момента появления ссылочных типов, допускающих null, в C# 8, а .NET 6 стал версией, включившей контекст nullable по умолчанию в новых проектах.

Ошибка в контексте

Текущий компилятор выдаёт единое унифицированное сообщение для полей и свойств:

warning CS8618: Non-nullable variable must contain a non-null value when exiting constructor. Consider declaring it as nullable.

Более старые SDK (и множество до сих пор открытых тем на StackOverflow) показывают варианты, специфичные для поля и свойства, которые многие как раз и вводят в поиск:

warning CS8618: Non-nullable property 'Name' must contain a non-null value when exiting constructor.
warning CS8618: Non-nullable field '_name' must contain a non-null value when exiting constructor.

Все три — это одна и та же диагностика с одной и той же причиной. Обратите внимание на слово warning, а не error: CS8618 по умолчанию не останавливает сборку. Оно превращается в ломающую сборку ошибку только при наличии <TreatWarningsAsErrors>true</TreatWarningsAsErrors> или <WarningsAsErrors>CS8618</WarningsAsErrors> в вашем проекте, что многие команды делают именно для того, чтобы пробелы в null-безопасности нельзя было проигнорировать.

Почему это происходит

Ссылочные типы, допускающие null, введённые в C# 8 и включённые по умолчанию в шаблонах начиная с .NET 6 (<Nullable>enable</Nullable> в .csproj), разделяют каждый ссылочный тип на два состояния: не допускающий null (string) и допускающий null (string?). Член, не допускающий null, — это обещание: “это никогда не будет null.” Задача компилятора — заставить вас сдержать это обещание, а место, где ему проще всего это проверить, — конструирование. Когда конструктор завершается, каждое поле и автосвойство, не допускающее null, должно доказуемо быть не null. Если компилятор не может это доказать, вы получаете CS8618.

Ключевое слово здесь — “доказуемо.” Компилятор выполняет статический анализ; он не запускает ваш код. Он доверяет ровно трём вещам: инициализатору поля или свойства, прямому присваиванию внутри конструктора и вспомогательному методу, аннотированному так, чтобы сообщить, что он присваивает член. Конструктор, который присваивает значение по пути, за которым компилятор не может проследить, или член, который фреймворк задаёт только позже, не считаются вовсе. Это та же модель “докажи, а не покажи”, что и за диагностикой обязательного члена CS9035: компилятор не будет выводить намерение из тел ваших методов.

Тонкая ловушка: защита проверкой на null внутри конструктора не помогает. Код вида if (name is null) throw new ArgumentNullException(nameof(name)); доказывает, что параметр не null, но компилятор по-прежнему видит член как неприсвоенный, пока вы действительно его не присвоите. Это удивляет разработчиков достаточно часто, чтобы иметь собственный давний issue в Roslyn.

Минимальное воспроизведение

Наименьший тип, вызывающий CS8618, в проекте с включённым контекстом nullable:

// .NET 11, C# 14, <Nullable>enable</Nullable>
public class Person
{
    public string Name { get; set; }    // CS8618: never assigned
    public string Email { get; set; }   // CS8618: never assigned
    public int Age { get; set; }        // fine, value type has a default
}

Два предупреждения, по одному на каждое ссылочное свойство, не допускающее null. Age молчит, потому что типы-значения всегда имеют значение по умолчанию (0); предупреждения о допустимости null касаются ссылочных типов. Добавьте конструктор, задающий лишь один член, и вы всё равно получите предупреждение:

// .NET 11, C# 14
public class Person
{
    public Person(string name)
    {
        Name = name;      // Name is proven
    }

    public string Name { get; set; }
    public string Email { get; set; }   // CS8618: still not assigned on this path
}

Компилятор проверяет каждый конструктор независимо. Если любой конструктор оставляет член, не допускающий null, неприсвоенным, этот конструктор порождает предупреждение.

Решение подробно

Пройдите по этим вариантам по порядку. Первые три — те, что нужны в большинстве случаев; последние два — аварийные выходы для случая, когда член действительно инициализируется там, где компилятор этого не видит.

1. Инициализируйте член в конструкторе

Если значение необходимо для построения корректного объекта, примите его как параметр конструктора и присвойте. Это дизайн, к которому подталкивает предупреждение:

// .NET 11, C# 14
public class Person
{
    public Person(string name, string email)
    {
        Name = name;
        Email = email;
    }

    public string Name { get; set; }
    public string Email { get; set; }
}

Оба члена теперь доказуемо присвоены на каждом пути конструирования, поэтому оба предупреждения исчезают. Если у вас несколько конструкторов, направьте их через один, чтобы присваивание жило в одном месте: public Person() : this("John", "Doe") { } удовлетворяет компилятор, потому что связанный конструктор делает работу.

2. Задайте члену значение по умолчанию через инициализатор поля

Когда есть разумное значение по умолчанию и вы не хотите заставлять каждого вызывающего передавать значение, инициализируйте член там, где он объявлен:

// .NET 11, C# 14
public class Person
{
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
}

Инициализатор поля выполняется до тела любого конструктора, поэтому член не null на каждом пути автоматически. Это самое чистое решение для более или менее необязательных значений вроде пустых строк или коллекций new List<string>(). Оно также лучше, чем делать тип допускающим null, если член никогда не должен быть null во время выполнения, потому что сохраняет контракт “не null” для всех, кто читает свойство.

3. Пометьте член required (C# 11 и новее)

Если член обязателен, но вы не хотите параметр конструктора для него, используйте модификатор required. Он переносит обязательство в инициализатор объекта вызывающего кода и, в качестве бонуса, заглушает CS8618, потому что компилятор теперь знает, что член должен быть задан до того, как объект просочится наружу:

// .NET 11, C# 14
public class Person
{
    public required string Name { get; set; }
    public required string Email { get; set; }
}

// the caller is now forced to set both
var p = new Person { Name = "Ada", Email = "ada@example.com" };

Это часто лучший современный ответ для DTO и объектов конфигурации: никакого шаблонного конструктора, никакого фальшивого значения по умолчанию, и гарантия “не null” применяется на каждом месте вызова. Компромисс в том, что пропуск значения становится ошибкой компиляции (CS9035) на месте вызова вместо предупреждения на типе. Если вы прибегаете к этому, прочитайте сопутствующую статью о CS9035 и обязательных членах, чтобы знать, как выглядит ошибка на стороне вызывающего.

4. Сделайте член допускающим null, если null — допустимое состояние

Если член действительно может отсутствовать, он должен быть string?, а не string. Добавление ? сообщает компилятору и каждому читателю, что это значение может быть null, что честно и переносит проверку на null туда, где значение потребляется:

// .NET 11, C# 14
public class Person
{
    public string Name { get; set; } = string.Empty;
    public string? MiddleName { get; set; }   // legitimately optional
}

Не прибегайте к этому лишь для того, чтобы заглушить предупреждение о члене, который никогда на самом деле не null. Пометка члена допускающим null, когда на практике он всегда задан, навязывает фантомные проверки на null (или операторы прощения null !) каждому потребителю. Оставьте ? для значений, которые действительно необязательны.

5. Аннотируйте вспомогательный метод атрибутом [MemberNotNull] или используйте null! для членов, инициализируемых фреймворком

Иногда член инициализирован, просто не там, за чем следит компилятор. Это покрывают два инструмента.

Если общий приватный метод выполняет инициализацию, сообщите об этом компилятору через [MemberNotNull]:

// .NET 11, C# 14
using System.Diagnostics.CodeAnalysis;

public class Student
{
    public string Major { get; set; }

    public Student() => SetMajor();

    [MemberNotNull(nameof(Major))]
    private void SetMajor(string? major = null) => Major = major ?? "Undeclared";
}

[MemberNotNull] утверждает, что после возврата метода названный член не null, поэтому конструктор, вызывающий его, считается присвоившим член. Как и [SetsRequiredMembers], это обещание, которому компилятор верит без проверки, так что держите его честным.

Другой случай — член, который фреймворк задаёт через рефлексию, классический пример — DbSet в EF Core. Базовый DbContext заполняет их, но компилятор этого не видит, поэтому идиома — инициализировать значением null!:

// .NET 11, EF Core 11
public class TodoContext : DbContext
{
    public TodoContext(DbContextOptions<TodoContext> options) : base(options) { }

    public DbSet<TodoItem> TodoItems { get; set; } = null!;
}

null! говорит “считай, что это не null; я знаю, что оно задаётся в другом месте.” Это точечное подавление, а не решение, поэтому используйте его только тогда, когда что-то вне вашего конструктора действительно выполняет инициализацию. Этот шаблон встречается по всему коду EF Core; те же рассуждения применимы к сущностям, которые материализует ORM, что рассмотрено в как правильно использовать record с EF Core 11.

Подводные камни и варианты

Горстка ситуаций порождает CS8618 или нечто смежное по причинам, которые сообщение не расписывает:

Ментальная модель, которую стоит держать: CS8618 — это компилятор, обеспечивающий соблюдение обещания, которое даёт член, не допускающий null. Когда вы его видите, решите, что на самом деле верно, и действуйте соответственно. Член обязателен (присвойте его в конструкторе или пометьте required), у него есть разумное значение по умолчанию (задайте инициализатор поля), он действительно необязателен (сделайте его string?), или он инициализируется кодом, который компилятор не видит ([MemberNotNull] или null!). Прибегать к null! для члена, который должен задать вызывающий, — значит лишь переместить предупреждение времени компиляции в NullReferenceException времени выполнения, а это именно тот баг, ради предотвращения которого и существуют ссылочные типы, допускающие null.

Похожее

Источники

Comments

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

< Назад