- Published on
Primary Constructors and Their Impact on Classes and Structs in .NET 8
- Authors
- Name
- Ivan Gechev
In this post, we'll take a look at a brand new feature released with C# 12 and .NET 8. It's called Primary Constructors - a thing we already have for Records but that is now extended to Classes and Structs.
Records and Their Primary Constructors
Primary constructors for record
types have been around for a long time. Let's take a look at how they work:
public record Dimensions(int Width, int Height);
With this simple line, we create a record
type called Dimensions
. For the Width
and Height
, the compiler will create two properties behind the scenes that we can later utilize.
This is a very simple, yet vital feature. With it, we can save time and effort when creating Records.
Primary Constructors for Classes and Structs
Before we see how primary constructors work for classes and structs, let's take a look at a simple, old-fashioned class:
public class BankAccount
{
private readonly string _accountNumber;
public decimal Balance { get; set; }
public BankAccount(string accountNumber, decimal balance)
{
_accountNumber = accountNumber;
Balance = balance;
}
public void Deposit(decimal amount)
{
Balance += amount;
Console.WriteLine($"Deposited {amount:C} to {_accountNumber}. New balance: {Balance:C}");
}
public void Withdraw(decimal amount)
{
if (amount <= Balance)
{
Balance -= amount;
Console.WriteLine($"Withdrawn {amount:C} from {_accountNumber}. New balance: {Balance:C}");
}
else
{
Console.WriteLine($"Insufficient funds om {_accountNumber}.");
}
}
}
We have a class called BankAccount
. Its constructor takes in two arguments - the account number and the balance. For the account number, we have a private readonly
field and for the balance, we have a dedicated property. We also have two methods for depositing and withdrawing money.
Now, let's make our class to utilize a primary constructor:
public class BankAccount(string accountNumber, decimal balance)
{
public decimal Balance { get; set; } = balance;
public void Deposit(decimal amount)
{
Balance += amount;
Console.WriteLine($"Deposited {amount:C} to {accountNumber}. New balance: {Balance:C}");
}
public void Withdraw(decimal amount)
{
if (amount <= Balance)
{
Balance -= amount;
Console.WriteLine($"Withdrawn {amount:C} from {accountNumber}. New balance: {Balance:C}");
}
else
{
Console.WriteLine($"Insufficient funds om {accountNumber}.");
}
}
}
Just as with record
types, the constructor arguments are moved to the very first line, where we create and name our class. Everywhere inside our class, we have access to the accountNumber
and balance
values, so we assign the Balance
property directly. As the compiler will generate a field for each constructor argument, we can replace all references to _accountNumber
with accountNumber
and our code will work as expected. The rest of our class remains unchanged. And just like that, we have reduced our code length by eight lines.
Key Takeaways From Primary Constructors for Classes and Structs
Now that we have examined what the primary constructor is all about when it comes to classes and structs, let's note some important takeaways.
Primary Constructors and Validation Logic
Let's imagine we must make sure that the balance is a positive number and the account number is 10 characters long when creating an instance of the BankAccount
class:
public class BankAccount(string accountNumber, decimal balance)
{
private readonly string _accountNumber = VerifyAndAssignAccountNumber(accountNumber);
public decimal Balance
{
get => balance;
set
{
ArgumentOutOfRangeException.ThrowIfNegative(value);
balance = value;
}
}
public void Deposit(decimal amount)
{
Balance += amount;
Console.WriteLine($"Deposited {amount:C} to {_accountNumber}. New balance: {Balance:C}");
}
public void Withdraw(decimal amount)
{
if (amount <= Balance)
{
Balance -= amount;
Console.WriteLine($"Withdrawn {amount:C} from {_accountNumber}. New balance: {Balance:C}");
}
else
{
Console.WriteLine($"Insufficient funds om {_accountNumber}.");
}
}
private static string VerifyAndAssignAccountNumber(string accountNumber)
{
if (accountNumber.Length != 10)
{
throw new ArgumentException("Account number is invalid", nameof(accountNumber));
}
return accountNumber;
}
}
We expand the setter for the Balance
property and validate the passed-in value, making sure it is not a negative number using ThrowIfNegative()
method. For the account number, we have to create a field again and either do an in-line validation, but I've opted for a static
validation method instead. Now, our code has bloated and is becoming more complicated than if we used an old-fashioned constructor.
What Happens Behind the Scenes
It's essential to remember that no properties will be generated for the constructor arguments. Unlike record
types, where we get properties, when we use Primary Constructors with classes we only get fields. This is why we use Snake Case for classes and structs' arguments and Pascal Case for the record
's.
The primary constructor doesn't change things when it comes to equality for classes and structs - we still have reference-based equality.
Code Formatting and Primary Constructors
We've all gotten used to formatting our code in a certain way. But let's look at the following example:
public interface IBankAccountService
{
}
We have an empty IBankAccountService
interface just for illustration purposes.
public class BankAccountService(List<BankAccount> bankAccounts) : IBankAccountService
{
public List<BankAccount> BankAccounts { get; set; } = bankAccounts;
}
We also have a BankAccountService
that implements the IBankAccountService
interface and uses a primary constructor. With one argument this is somewhat tolerable on a single line, but what happens if we have multiple constructor arguments? How we split the lines:
public class BankAccountService(
List<BankAccount> bankAccounts,
Accountant accountManager,
Accountant accountSupervisor) : IBankAccountService
{
public List<BankAccount> BankAccounts { get; set; } = bankAccounts;
public Accountant AccountManager { get; set; } = accountManager;
public Accountant AccountSupervisor { get; set; } = accountSupervisor;
}
Do we split them like this and leave the interface on the last line?
public class BankAccountService(
List<BankAccount> bankAccounts,
Accountant accountManager,
Accountant accountSupervisor)
: IBankAccountService
{
public List<BankAccount> BankAccounts { get; set; } = bankAccounts;
public Accountant AccountManager { get; set; } = accountManager;
public Accountant AccountSupervisor { get; set; } = accountSupervisor;
}
Or do we have the interface on yet another new line? This might seem like a trivial problem but we still need a convention (at least on a company or team-level) on how to format our code. If it was up to me, I'd be inclined to go with the first option
Conclusion
The introduction of Primary Constructors in C# 12 and .NET 8 marks a significant enhancement, extending a feature previously exclusive to records to now include classes and structs. This addition streamlines code by simplifying the creation and initialization of objects, reducing verbosity and improving overall readability. However, it's crucial to recognize that while this feature offers notable benefits, its usage should align with the specific requirements of the application, considering factors like validation logic and code formatting conventions.