Pull to refresh

Record vs struct — когда кто кого

Reading time13 min
Views24K

Привет! Меня зовут Анна Дресвянникова. Я бэкенд-разработчица в компании ЦВТ. В .Net, помимо типов class и struct, с недавних пор есть еще и типы record и record struct. Появление двух новых типов могло внести запутанность по поводу того, в чем их сходства и различия, и в каких случаях стоит их применять.

В этой статье мы разберем, в каких ситуациях использовать class, record, struct, и какие преимущества даёт тот или иной тип в различных ситуациях. В конце мы приведем шпаргалку, которая поможет быстро определиться, какой из видов типов лучше подходит в том или ином случае.

Сходства и отличия

В 9-й версии C# появился тип record (он же record class). Он позволяет создать нечто среднее между class и struct. А в 10-ой версии C# появился record struct. Как можно догадаться, это что-то среднее между record и struct. Подробное описание этих типов есть в документации, а я напомню лишь основные моменты.

Характеристика

сlass

record (он же record class)

record struct

struct

Ссылочный/значимый

ссылочный

ссылочный

значимый

значимый

Есть ли позиционная запись

нет

да

да

нет

Неизменяемость (иммутабельность) по умолчанию

нет

с позиционной записью — да

нет

нет

Наследование

да

только от record

нет

нет

Реализация интерфейсов

да

да

да

да

Автоматически генерируются операторы сравнения

сравнение по ссылке

сравнение по значению

сравнение по значению

нет

Создание через инициализатор объектов

да

с позиционной записью — нет

да

да

Автоматически генерируется конструктор без параметров

да

с позиционной записью — нет

да

да

Особенности record

Основные особенности у record вот какие:

  1. позиционная запись;

  2. неизменяемость при использовании позиционной записи;

  3. создание нового экземпляра на основе уже существующего экземпляра (ключевое слово with);

  4. реализация ToString() по умолчанию возвращает значения публичных полей/свойств.

Рассмотрим их по очереди.

Позиционная запись

Основное преимущество позиционной записи — краткость:

public record Person(string FirstName, string LastName);

В большинстве случаев это сильно упрощает описание типа, позволяя обходиться одной строкой вместо десятка. Эта особенность record неразрывно связана со следующей.

Неизменяемость при использовании позиционной записи

Мы можем также создать неизменяемый тип и без позиционной записи, с помощью init-свойств и readonly-полей. Но при добавлении новых свойств/полей очень просто забыть о необходимости явно пометить их как init или readonly. С позиционной записью в record такая ошибка исключается, так как при добавлении нового поля мы только добавляем в конструктор его тип и имя. Их неизменяемость при этом подразумевается по умолчанию.

Замечание

У record, описанного с помощью позиционной записи, нельзя переприсвоить значения свойствам, но их можно изменить через специальные методы.

record Team(string Name, List<string> Members);
var members = new List<string> { "Monika", "Joe", "Ross" };
var team = new Team("Tigers", members);
team.Members = new List<string> {"Chandler", "Phoebe" }; // compiler error
// (Error CS8852 Init-only property or indexer 'Team.Members' can only be assigned in an
// object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor)
team.Members.Add("Chandler"); // no error

Создание нового экземпляра на основе уже существующего

Ключевое слово with позволяет создать новый экземпляр на основе уже имеющегося с возможным изменением некоторых свойств/полей.

var person2 = person1 with {Name = “John”};

Данная конструкция позволяет коротко записать создание нового неизменяемого экземпляра record, которые часть значений копирует из другого экземпляра record.

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

Замечание

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

Начиная с версии C# 10 для struct также можно использовать ключевое слово with.

Метод ToString() возвращает имена и значения публичных свойств/полей

При попытке вызвать метод ToString() у типа record вы получите нечто вроде такого:

Person { FirstName = Nancy, LastName = Davolio, ChildNames = System.String[] }

В данном случае Person — это имя типа, а FirstName, LastName и ChildNames — его свойства со значениями.

Особенности record struct

record struct имеет те же особенности, что и record, кроме неизменяемости при позиционной записи.

record ItemRecord(int X, int Y); // или record class, что то же самое
record struct ItemRecordStruct(int X, int Y);

var itemRecord = new ItemRecord(0, 1);
var itemRecordStruct = new ItemRecordStruct(2, 3);
itemRecord.X = 4; // compiler error
// (Error	CS8852: Init-only property or indexer 'ItemRecord.X' can only be assigned in an
// object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor)

itemRecordStruct.X = 5; // no error

Сценарии использования

По сути, record хорошо подходит в ситуации, когда нужно описать тип, обладающий следующими характеристиками:

  • требуется неизменяемость объекта;

  • объект будет передаваться между методами (чтобы передавалась ссылка на объект).

Рассмотрим такие случаи детальнее

Случай 1: DTO

В приложениях со сложной структурой нам часто требуется передавать данные между разными слоями или модулями приложения. Использовать для этого единственную модель — не очень удачное решение, так как, как правило, каждому отдельному слою нужен не весь объект, а только какая-то его часть. Используя общую модель, мы тратим ресурсы на передачу лишних данных и, возможно, передаем данные, которые передавать вообще не стоило бы.

Пример: неавторизованный пользователь имеет меньше прав, чем авторизованный, поэтому мы должны передать ему меньше данных, чем авторизованному. То есть в данном случае нужно использовать 2 разных модели DTO.

В таких случаях обычно применяют паттерн DTO. DTO (Data Transfer Object) — это объект, который содержит только данные (и никакой бизнес-логики) и предназначен для передачи данных от одного слоя приложения к другому.

DTO как раз обладает описанными выше характеристиками (ну или должен обладать при хорошей архитектуре проекта):

  • он нужен исключительно для передачи данных,

  • он нужен для отправки только в 1 сторону,

  • нет нужды в его изменении после получения.

То есть, если вам нужно описать DTO, то ваш выбор — record с позиционной записью.

Случай 2: Entity

В данном случае record не подходит. Давайте разберем, почему.

Entity — это объект предметной области, который можно однозначно идентифицировать по уникальному идентификатору.

Сущность должна обладать следующими характеристиками:

  • иметь конструктор без параметров либо параметризованный конструктор (некоторые ORM могут работать только с конструктором без параметров, некоторые — с обоими случаями);

  • должна быть изменяемой;

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

В данном случае мы не сможем использовать record с позиционной записью, так как он по умолчанию имеет только параметризованный конструктор. Если наша ORM требует конструктор без параметров, то нам придется создать цепочку из двух конструкторов:

public record Food(int ID, string Name)
{
  public Food() : this(default, default) { }
}

Или придётся описать record в традиционном стиле, тогда конструктор без параметров будет сгенерирован компилятором.

Если мы используем Entity Framework Core, помните: если в нашей модели есть навигационные свойства, то EF Core не сможет их привязать, если эти свойства не описаны традиционно.

Например, мы хотим использовать позиционную запись для Food:

public record Food(int ID, string Name, int CategoryID, CategoryKind Category)

Здесь Category — навигационное свойство. Используя такую запись, при работе с БД мы получим ошибку:

No suitable constructor was found for entity type 'Food'.
The following constructors had parameters that could not be bound to properties of the entity type:
cannot bind 'Category' in 'Food(int ID, string Name, int CategoryID, CategoryKind Category)';
cannot bind 'original' in 'Food(Food original)'.

Есть два пути решения проблемы:

  1. описать record в традиционном стиле:

public record Food
{
  int ID { get; set; }
  string Name{ get; set; }
  int CategoryID{ get; set; }
  CategoryKind Category{ get; set; }
}
  1. явно описать только свойство Category

public record Food(int ID, string Name, int CategoryID)
{
  CategoryKind Category{ get; set; }
}

В первом случае мы не получаем преимущества краткой записи record. Во втором случае не очень удобно, когда часть свойств указана явно, а часть — только в конструкторе.

Суммируя, приведу полный список причин, по которым record не очень подходит для описания entity:

  • тип record с позиционной записью — неизменяемый, поэтому преимущество краткой записи мы не получим и придется использовать традиционную запись

  • Entity Framework Core использует ссылочное равенство для сравнения двух сущностей;

  • вряд ли нам понадобится использовать with с сущностями

  • ToString() у record можно было бы использовать для записи в лог конкретных значений полей в сущности, с которой работаем. Но если у сущности есть ссылочные поля (а скорее всего они будут), то мы получим результат исполнения их метода ToString(), который при отсутствии переопределения вернет имя типа, а не значение свойства. То есть если мы хотим логировать значение объекта, то нам в любом случае придется переопределять метод ToString().

Таким образом, record не подходит для данного случая и не дает преимуществ перед обычными классами для описания entity.

Случай 3: большая коллекция, используемая внутри одного метода

Если нужно создать большую коллекцию объектов, каким-то образом её обработать внутри метода, после чего она будет не нужна, то отличный вариант — использовать коллекцию структур. В данном случае все данные будут лежать в одной области памяти и при интенсивной работе с элементами такой коллекции есть высокая вероятность, что количество кэш-промахов будет меньше, чем при хранении в коллекции только ссылок на данные.

Если же коллекцию нужно будет использовать не только внутри одного метода, либо вне метода будут использоваться элементы коллекции по отдельности, то лучше выбрать class или record.

Выделение памяти для коллекции ссылочных типов будет заведомо более ресурсоёмким, а сборщику мусора понадобится больше времени на освобождение такой коллекции. При большом размере коллекции struct дает значительное преимущество по производительности, если структура содержит поля/свойства значимых типов (при выделении памяти для структуры с полями/свойствами ссылочных типов выигрыш по времени будет совсем незначительный). Сравнение с подробным объяснением можно посмотреть здесь.

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

Если типы struct или record struct используются в generic-коллекции, то поля/свойства элементов таких коллекций являются неизменяемыми:

struct Element
{
  public int X {get; set;}
  public int Y {get; set;}
}
List<Element> elements = new() { new() { X = 0, Y = 1 }, new() { X = 2, Y = 3 } };
int i = 1;
elements[i].X++; // compiler error (Error CS1612: Cannot modify the return value of 'List<Element>.this[int]' because it is not a variable)

Это вполне объяснимо, если вспомнить, что элементы структуры — это значимые типы, а доступ к элементам структуры через метод-индексатор должен возвращать ссылку на элемент. Поскольу невозможно вернуть ссылку на значимый тип, то в реальности создаётся копия элемента структуры, ссылка на которую и возвращается (бонусом идёт упаковка и распаковка).

Чтобы обойти это ограничение, придется создать новый объект и записать его в elements[i]:

elements[i] = new Element {X = elements[i].X + 1, Y = elements[i].Y };

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

Случай 4: нужно создать много объектов внутри одного метода

Тип struct подойдет, если нужно создать множество небольших объектов внутри метода, причем каждый объект будет жить очень недолго. Например, объект создается внутри цикла и умирает по завершении итерации. В этом случае стоит выделить память под объект на стеке и, как только он станет не нужен (то есть при выходе из области его видимости), память на стеке освободится автоматически. Даже если объект содержит значимые поля/свойства, то в итоге это всё равно будет быстрее, чем выделять память в куче и нагружать сборщик мусора для освобождения памяти. Бенчмарк наглядно демонстрирует справедливость этого утверждения:

public struct ElementStruct
{
  public int X { get; set; }
  public int Y { get; set; }
}

public class ElementClass
{
  public int X { get; set; }
  public int Y { get; set; }
}

public class Benchmarks
{
  [Benchmark]
  public void GetSumXStruct()
  {
    var sumX = 0;
    for (int i = 0; i < 100_000_000; i++)
    {
      var element = new ElementStruct { X = i, Y = i };
      sumX += element.X;
    }
  }

  [Benchmark]
  public void GetSumXClass()
  {
    var sumX = 0;
    for (int i = 0; i < 100_000_000; i++)
    {
      var element = new ElementClass { X = i, Y = i };
      sumX += element.X;
    }
  }
}

Method

Mean

Error

StdDev

ListOfClassesTest

199.6 ms

2.05 ms

1.82 ms

ListOfStructsTest

167.5 ms

3.28 ms

4.15 ms

Случай 5: объект нужно передавать в другие методы

При передаче struct в какой-то метод, передается не сам объект, а его копия (так происходит со всеми значимыми типами). Здесь возможно возникновение следующих двух проблем:

  1. неявное выделение памяти для копии;

  2. если изменится оригинальное значение (например, при использовании многопоточности), мы об этом не узнаем и будем работать с устаревшим значением.

Если экземпляр значимого типа занимает не более 16 байт, то первая проблема не является проблемой вовсе. В этом случае даже оптимальнее передать значимый тип, чем ссылочный. Если же значимый тип слишком большой, то его экземпляр можно передать его по ссылке с помощью модификаторов ref или in.

Вторую проблему можно решить двумя разными способами: либо использовать readonly-структуру (чтобы гарантировать отсутствие изменений), либо передавать структуру с модификаторами ref или in, чтобы значение не копировалось, а объект передавался по ссылке (тогда мы узнаем об изменении).

Использование struct с in - не очень удачный вариант, поэтому лучше его не использовать, и для больших объектов применять class и record. И вот почему: при передаче с модификатором in изменяемой не-POCO (содержащей не только поля, но и методы) структуры при условии, что внутри метода идет обращение к её не-readonly членам, компилятор перед вызовом создает в стеке защитную копию структуры при каждом таком обращении. Защитная копия не будет создаваться только при обращении к readonly-членам структуры.

Если с модификатором in передается readonly-структура или структура является POCO-объектом (то есть, содержит только поля), то защитная копия создаваться не будет. Но этот вариант ненадежен, так как POCO-объект легко по ошибке превратить в не-POCO, например, заменить поле на свойство.

Если невозможно объявить структуру readonly, то поможет struct или record struct + in + обращение только к readonly-членам. Этот вариант достаточно хрупкий, так что его лучше не использовать.

Вывод: если структура не больше 16 байт, то можно передавать ее в метод по значению. Если используется многопоточность и нужна гарантия, что другие потоки не изменят структуру, то объявить ее readonly struct. Если же объект больше 16 байт, то хоть выпутаться из описанных неприятностей можно, но приведенные решения достаточно хрупки и не очевидны. В этих случаях лучше использовать class и record, а не struct.

Случай 6: нужен неизменяемый объект

Лучший выбор для неизменяемого объекта record с позиционной записью. Конечно, это могут быть class и record с readonly либо init-полями/свойствами, readonly struct, readonly record struct. Но все-таки record с позиционной записью дает защиту от потенциального нарушения неизменяемости при изменении/расширении списка полей.

Случай 7: объект является полем/свойством другого объекта

Если тип используется преимущественно как член других типов, то для повышения производительности при выделении и освобождении памяти можно использовать struct, record struct.

Нужно быть внимательными если поле/свойство объявлено с модификатором readonly и имеет при этом тип неиммутабельной структуры. В этом случае если в каком-то методе идет обращение к свойству или методу структуры, то при каждом таком обращении создается её защитная копия. Если же структура является POCO-объектом (содержит только поля), то защитная копия создаваться не будет, так как доступ к полям будет считаться безопасным.

struct Element
{
    public readonly int ReadonlyField;
    public int Property { get; set; }
    public Element(int field, int property1)
    {
        ReadonlyField = field;
        Property = property1;
    }
    public int GetSomething() => 1;
}

class ClassA
{
    readonly Element element1;

    public ClassA()
    {
        element1 = new Element(field: 1, property1: 2);
    }

    public void CheckStructMembersAccess()
    {
        int x=0;
        x += element1.ReadonlyField; // нет копии, т.к. обращение к ПОЛЮ readonly-поля
        x += element1.Property; // защитная копия, т.к. обращение к СВОЙСТВУ readonly-поля
        x += element1.GetSomething(); // защитная копия, т.к. вызов МЕТОДА структуры
    }
}

Возможные решения:

  1. объявить поле/свойство-структуру без readonly;

  2. объявить саму структуру readonly;

  3. сделать структуру POCO.

1 и 3 варианты достаточно хрупки. Второй вариант самый надежный, если разработчики, работающие с вашим кодом будут понимать, что структура является readonly не просто так, и изменять ее на изменяемую не нужно.

Случай 8: объект должен реализовывать интерфейсы

Безопасный вариант — использовать class или record.

С нюансами можно использовать struct, record struct.

Да, структура может реализовывать интерфейсы. Но нужно быть осторожными, так как при обращении к экземпляру структуры через переменную, имеющую тип интерфейса, по умолчанию происходит упаковка/распаковка. В некоторых случаях мы можем защититься от этого. Например, при объявлении метода, в который мы хотим передать структуру под видом интерфейса, можно добавить ограничение:

void DoSomething<T>(T data) where T : IPrintable

При объявлении поля, свойства или локальной переменной с типом интерфейса и сохранении в него структуры избежать упаковки не получится.

Случай 9: объект — примитивный тип

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

Пример: точка в n-мерном пространстве, вектор, особенная единица измерения. Хорошо известный пример из .NET — типы для даты и времени.

Дополнительные вопросы

А если нужно наследование?

Если нужно наследоваться от классов, то class. Если нужно наследоваться от record, то record.

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

Что лучше подходит для сравнения по ссылке и по значению?

По умолчанию struct, record struct, record подходят для сравнения по значению, а class — по ссылке. Но на деле можно сравнить class по значению, а record — по ссылке.

Тип struct сравниваются только по значению, да и сравнение их по ссылке просто не имеет смысла. Тип class по умолчанию сравниваются по ссылке, но мы можем реализовать интерфейс IEquatable<T>, чтобы сравнить их по значению. Тип record по умолчанию сравниваются по значению, но мы можем использовать метод ReferenceEquals(obj1, obj2), чтобы сравнить их по ссылке.

А если объект содержит только значимые типы?

Тогда struct, record struct.

Использовать структуру с полями/свойствами ссылочного типа конечно можно, но при копировании структуры будет скопировано не само значение ссылочного поля, а лишь ссылка на него. То есть, ссылочное поле внутри двух экземпляров структур будут ссылаться на одно и то же значение. Это не совсем очевидно для значимого типа и открывает возможность для ошибок.

А если при выборе struct экземпляр будет часто упаковываться и распаковываться?

Откажитесь от struct. Упаковка и распаковка не только снижает производительность, но и может привести к ошибкам.

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

interface IPrintable { }

struct Element : IPrintable
{
    public int X { get; set; }
    public int Y { get; set; }
}

var element = new Element { X = 0, Y = 1 };
IPrintable printable0 = element;
IPrintable printable1 = element;
Console.WriteLine(printable0 == printable1); // false

Еще одна ошибка возникает, когда мы что-то меняем в упакованном объекте и ожидаем, что изменится и оригинальный объект. Чтобы изменился оригинальный объект, нам нужно не забыть распаковать в него упакованный измененный объект.

Вопросы, связанные со способами написания кода

Эти вопросы касаются вашего личного удобства и принципиально не должны влиять на выбор.

Хотите использовать позиционную запись?

Тогда record и record struct.

Хотите генерируемый компилятором конструктор без параметров?

Тогда class, record в традиционной записи и record struct.

Хотите создавать объекты через инициализатор объектов?

Всё, кроме record с позиционной записью.

Хотите автоматически генерируемые компилятором операторы сравнения?

Тогда class, record, record struct.

Хотите автоматически генерируемый компилятором механизм копирования с возможностью изменения некоторых полей/свойств?

Тогда record, struct и record struct (ключевое слово with).

Напоследок — картинки-шпаргалки

Tags:
Hubs:
+20
Comments8

Articles

Change theme settings