BT

Facilitating the Spread of Knowledge and Innovation in Professional Software Development

Write for InfoQ

Topics

Choose your language

InfoQ Homepage Articles Records in C# 9

Records in C# 9

This item in japanese

Lire ce contenu en français

Key Takeaways

  • C# 9 introduces records, a new reference type for encapsulating data developers can use instead of classes and structs.
  • Record instances can have immutable properties through the use of pre-initialized positional parameters.
  • Record types have a compiler-generated ToString method that returns the names and values of public properties and fields in an instance.
  • Differently from classes, equality in Records doesn't necessarily mean reference equality. Two record instances are equal if the values of all their properties and fields are equal.
  • Non-destructive mutation allows the creation of new record instances from existing immutable records. 
  • Records can be inherited.

Introducing records

C# 9 introduces records, a new reference type for encapsulating data developers can use instead of classes and structs.

While records can be mutable, the new reference type is primarily intended to be used with immutable data models. It has the following key features:

  • Positional syntax for creating a reference type with immutable properties
  • Built-in formatting for display
  • Value-based equality
  • Non-destructive mutation with a concise syntax
  • Support for inheritance hierarchies

The declaration syntax of a record type is very similar to the one used by a class, except for the declaration keyword:

Example 1: Declaring records and classes

public class Pet {
	public string Name {get; set;}
	public int Age{get; set;}
}

//switch the keyword “class” to “record”
public record Pet {
	public string Name {get; set;}
	public int Age{get; set;}
}

The example above shows how we can declare a Pet record with traditional getters and setters by nominal creation.

Mutable property

Records are primarily intended to be used with immutable data models, but they are not necessarily immutable.

In the example above, we declared the record properties with the set accessor. That means the object's state can be modified after its creation, making them mutable. Declaring record types with positional parameters, however, makes them immutable by default:

Example 2: Declaration with positional parameters

public record Pet(string Name, int Age);

Immutability can be helpful in specific scenarios where you don't want to allow any modifications to a record variable after its initialization. A Data Transfer Object (DTO) would be an excellent example for using immutability. Using an immutable record as a DTO will ensure that the object is not changed when transferred between a database and a client.

An immutable record type is thread-safe and cannot mutate or change after its creation, although non-destructive mutation is allowed. Record types can only be initialized inside a constructor.

Init-only setters

Init-only setters are introduced in C#9, and they can be used instead of set for properties and indexers. init accessors are very similar to readonly, with two exceptions:

  • Properties with init-only setters can only be set in the object initializer, constructor, or init accessor;
  • Once their values are set, they can't be changed anymore.

In other words, init provides a window for changing the state of a record, but any properties using init-only setters become read-only once the record is initialized.

Example 3: Using init-only setters with nominal record declaration

public record Pet {
	public string Name {get; init;}
	public int Age{get; init;}
} 

One of the greatest advantages of using init-only setters is that we can prevent bugs that might be introduced when we pass the object argument by reference to a method, and the value of its properties is changed for any reason.

Using record types

We can initialize the properties of a record by using positional (constructor) parameters.

Example 4: Declaring a record type with positional parameters

public record Pet(string Name, int Age);

The example above uses the properties Name and Age as positional(constructor) parameters for the Pet record type. But we can also combine nominal and positional declarations when other properties don't necessarily need to be set during the record initialization.

Example 5: Using nominal and positional declaration with records

public record Pet(string Name, int Age)
{
	public string Color{ get; init; }
}

In the example above, the property Color is not declared as a positional parameter. As a result, we don't need to set it when we create a new instance of Pet.

Example 6: Initialization of a record type with nominal and positional declarations

var dog = new Pet("Cookie", 7);
var dog = new Pet("Cookie", 7){Color = “Brown”};

Built-in formatting for display

Unlike classes, the compiler-generated record ToString() method uses a StringBuilder to display the names and values of an instance when its properties and fields are public.

Example 7: Using ToString() with a record type

var dog = new Pet("Cookie", 7){Color = “Brown”};
dog.ToString(); 

Output:

Pet{ Name = Cookie, Age= 7, Color = Brown}

Value Equality

Value equality means that two variables of a record type are equal if their type definitions are identical, and if for every field, the values in both records are equal.

In opposition, two variables of a class type will be considered as not equal even if all of its properties are the same. This is due to the fact that classes use reference equality, meaning that two variables are equal only if they refer to the same object.

Example 8: Comparing two different instances of the same record type

var pet1 = new Pet("Cookie", "7");
var pet2 = new Pet("Cookie", "7");
 
var areEqual = pet1.Equals(pet2);

The above areEqual statement will return false for classes variables in C# because they point to different objects, but it will return true for record variables because they store the same value of the same types for each of their properties.

However, if the variables refer to two different record types, the statement will then return false.

Example 9: Creating two different types of a record

public record Pet(string Name, int Age);
public record Dog(int Age, string Name): Pet(Name, Age);
 
Pet pet = new Pet("Cookie", 7);
Dog dog = new Dog(7, "Cookie");
var areEqual = pet.Equals(dog);

In this case, The areEqual statement will return false because Dog and Pet are not of the same record type.

Non-destructive mutation

By its own definition, immutable record types do not allow any modifications once they are initialized. Let us use an immutable record type with init-only setters as an example:

Example 10: Declaring an immutable record type with init-only setters

public record Pet
{
	public string Name{ get; init; }
	public int Age{ get; init; }
};

var newPet = pet;
newPet.Name = "Cookie";
newPet.Age = 7;

The code snippet above will not be executed since all of its properties have init-only setters: the property values of newPet cannot be changed after the variable is initialized. As a result, the property values for newPet are not set.

In order to be able to modify the properties of an immutable record instance that is already initialized, we need to create a new record instance and modify its properties during the instantiation. This process is called non-destructive mutation:

Example 11: Modifying the properties of an existing record variable

var pet = new Pet("Cookie", 7)
{
	Color = "Brown"
};
 
var modifiedPet = new Pet(pet.Name, pet.Age)
{
	Color = "Black"
};

In the example above, we have changed the properties of the variable pet using non-destructive mutation. We create a new variable modifiedPet based on pet, modifying the value for the property Color during the instantiation process.

We can also use the expression with to specify only the properties that we want to change when creating the new variable:

Example 12: Non-destructive mutation using with

public record Pet(string Name, int Age);
Pet pet = new Pet("Cookie", 7);
 
var modifiedPet = Pet with
{
Age = 10
};

In the example above, we create a copy of pet using the expression with to modify the value of the property Age. All other property values are copied from pet. It is also important to notice that the with expression can be used only with record types in C# 9.

Similarly, we can even use a different syntax of with to copy an existing record:

Example 13: Copying a record variable by using with

var pet1 = new Pet("Cookie", 7);
var pet2 = pet1 with{};
var areEqual = pet1.Equals(pet2);

Inheritance

A record can inherit from another record. However, a record can't inherit from a class, and a class can't inherit from a record.

Example 14: Inheriting a record type

public record Pet(string Name, int Age);
public record Dog(string Name, int Age, string Color): Pet(Name, Age);
var dog = new Dog("Cookie", 7, “Brown”);

In the example above, Dog is a record type that is inherited from the Pet record type.

Using Deconstructors

Records also support deconstructors, which will convert the record instance into a tuple containing all of its properties. In the example below, we create an instance of Dog (dog) that is inherited from Pet, but with its properties in a different order:

Example 15: Creating an inherited Dog instance

public record Pet(string Name, string Color);
public record Dog(string Color, string Name): Pet(Name, Color);
 
var dog = new Dog(“Brown”, "Cookie");

We changed the order of the properties during the inheritance to showcase the consequences of using positional syntax. Deconstructing a variable will return a tuple whose values will depend on the type of the instance being deconstructed:

Example 16: Deconstructing an instance of Dog cast as Pet

string name = null;
string color = null;
 (name, color) = (Pet)dog;
 Console.WriteLine($"{name} {color}");

Output:

Cookie Brown

In the example above, we are deconstructing dog cast as an instance of Pet. As a result, the deconstruction will return the property values as declared in the Pet type, in the same order.

If we don’t cast dog as a Pet, however, the deconstruction process will return the property values in a different order (following the declaration of the record type Dog):

Example 17: Deconstructing an instance of Dog

(color, name) = dog;
 Console.WriteLine($"{name} {color}");

Output:

Cookie Brown

Edge Cases on record types:

Case 1: Bypassing init setters

Immutability is one of the advantages of using records as data-centric types. However, we can still change the property values of any record instance during runtime using reflection, similarly to any other object. This allows the developer to bypass any init-only setters used during the record declaration:

Example 18: Bypassing init setters using reflection

public record Pet(string Name, int Age)
{
 	public string Color {get; init;}
};

Pet pet = new Pet("Cookie", 7)
{
Color = “Brown”
};

var propertyInfo = typeof(Pet).GetProperties()
   .FirstOrDefault(p => p.Name == nameof(pet.Color));

propertyInfo.SetValue(pet, “Black”);
Console.WriteLine(pet.Color);

Output:

Black

The example above shows how we can use the GetProperties() method to alter the value of the Color property of an immutable record variable even after it is initialized.

Case 2: Deconstructing single-parameter records

When deconstructing record variables, it is necessary that the record type has at least two positional parameters (properties):

Example 19: Deconstructing records with only one positional parameter

public record Pet(string Name);
Pet pet = new Pet("Cookie");
String name = “Something”;
(name) = pet 

The code snippet above will not work, since the compiler will understand that we are trying to assign a Pet object to a String variable (even with the parenthesis, (name) is not interpreted as a single-parameter tuple). As an alternative, we can explicitly define the deconstructor output:

public record Pet(string Name);
Pet pet = new Pet("Cookie");
String name = “Something”;
pet.Deconstruct(out name);

When should you use the record type?

You should use record types in your application if:

  • Your data type encapsulates a complex value;
  • There is one and only one way that it can be transferred to other parts of the application(unidirectional data flow);
  • It might be necessary to use inheritance hierarchies.

If your data type can be a value type that holds the data within its own memory allocation (e.g., int, double) and it needs to be immutable, then you should use structs. Structs are value types. Classes are reference types, and records are by default immutable reference types.

Advantages of using record types:

  • Since the state of an immutable object never changes once it’s initialized, record types help the memory management process and make the code easy to maintain and debug.
  • Record types help us to notice data-related bugs earlier, and because of the enhancements in the syntax the codebase will be (usually) much smaller and clean.

Downside of record types:

It does not support IComparable, which means lists of record types are not sortable. As an illustration of this limitation, the following code will throw an exception:

Example 20: Sorting different instances of a record type

var pet1 = new Pet("Cookie", "7");
var pet2 = new Pet("Cookie", "8");
var list = new List<Pet>
{
 	pet1, pet2
};
list.Sort();

About the Author

Tugce Ozdeger holds a Master's Degree in Computer Science from Uppsala University and she has 10+ years of professional working experience with .NET framework as a Senior Software Engineer based in Stockholm, Sweden. She has mainly specialized in C#.NET, Desktop Applications (WinForms, WPF), WCF, LINQ, .NET Core, SQL Server, Web Forms, ASP.NET MVC, EF Core and MVVM. She has a genuine interest in Microsoft Azure as well as AI. She is the Founder of Heart-Centric Tech Mentoring and a Career Acceleration Mentor for Women in Technology. She is currently co-founding a tech startup as a CTO.

 

Rate this Article

Adoption
Style

BT