📜 ⬆️ ⬇️

Functional C #: Non-nullable reference types (non-zero reference types)

The third article in the "Functional C #" series.


Non-zero reference types in C # - current state of affairs


Let's look at this example:

Customer customer = _repository.GetById(id); Console.WriteLine(customer.Name); 

Looks familiar, right? What problems can be found in this code?
')
The problem here is that we do not know whether or not the GetById method can return null. If the method returns null for some id, we risk getting a NullReferenceException at runtime. Even worse, between how a customer will be assigned null, and how we use this object, it can take a significant amount of time. This code is difficult to debug, because it will be difficult to find out exactly where the object has been assigned null.

The faster we get feedback, the less time is required to fix problems in the code. Of course, the fastest feedback could be given by the compiler. How cool would it be to write the following code and allow the compiler to do all the checks for us?

 Customer! customer = _repository.GetById(id); Console.WriteLine(customer.Name); 

Here is the type of Customer! means a non-zero type, i.e. A type whose objects cannot be null under any circumstances. Or even better:

 Customer customer = _repository.GetById(id); Console.WriteLine(customer.Name); 

Those. to make all reference types non-zero by default (exactly the same as value-types now) and we need exactly the zero type, then we need to specify this explicitly, like this:

 Customer? customer = _repository.GetById(id); Console.WriteLine(customer.Name); 

Unfortunately, non-zero reference types cannot be added to C # at the language level. Such decisions need to be made from the very beginning, otherwise they break almost all the existing code. Links to this topic: one , two . In the new versions of C #, nonzero reference types will probably be added at the level of warnings , but with this innovation, so far, too, everything is not smooth.

And although we cannot force the compiler to detect errors related to incorrect use of null, we can solve the problem with the help of workaround. Let's look at the code of the Customer class, with which we ended the previous article :

 public class Customer { public CustomerName Name { get; private set; } public Email Email { get; private set; } public Customer(CustomerName name, Email email) { if (name == null) throw new ArgumentNullException(“name”); if (email == null) throw new ArgumentNullException(“email”); Name = name; Email = email; } public void ChangeName(CustomerName name) { if (name == null) throw new ArgumentNullException(“name”); Name = name; } public void ChangeEmail(Email email) { if (email == null) throw new ArgumentNullException(“email”); Email = email; } } 

We moved all the validation related to emails and customers to separate classes, but we couldn’t do anything with cash checks. As you can see, these are the only remaining checks.

We removed checks on null


So how can we get rid of them?

Using IL rewriter. We can use the NuGGard NuGGard.Fody package, which was created specifically for this purpose: it adds null checks to your code, forcing your classes to throw exceptions if null comes in as an input parameter, or is returned as a result of the method.

To start using it, install the NullGuard.Fody package and mark your build with the attribute

 [assembly: NullGuard(ValidationFlags.All)] 

From now on, all methods and properties within an assembly will automatically be validated to null for all incoming and outgoing parameters. Our Customer class can now be written as follows:

 public class Customer { public CustomerName Name { get; private set; } public Email Email { get; private set; } public Customer(CustomerName name, Email email) { Name = name; Email = email; } public void ChangeName(CustomerName name) { Name = name; } public void ChangeEmail(Email email) { Email = email; } } 

And even easier:

 public class Customer { public CustomerName Name { get; set; } public Email Email { get; set; } public Customer(CustomerName name, Email email) { Name = name; Email = email; } } 

Here's what we get at the output thanks to IL rewriter:

 public class Customer { private CustomerName _name; public CustomerName Name { get { CustomerName customerName = _name; if (customerName == null) throw new InvalidOperationException(); return customerName; } set { if (value == null) throw new ArgumentNullException(); _name = value; } } private Email _email; public Email Email { get { Email email = _email; if (email == null) throw new InvalidOperationException(); return email; } set { if (value == null) throw new ArgumentNullException(); _email = value; } } public Customer(CustomerName name, Email email) { if (name == null) throw new ArgumentNullException(“name”, “[NullGuard] name is null.”); if (email == null) throw new ArgumentNullException(“email”, “[NullGuard] email is null.”); Name = name; Email = email; } } 

As you can see, validations are equivalent to what we wrote manually, except that validation for return values ​​is also added here, which is also very useful.

How to be with null now?


What if we need null? We can use the Maybe structure:

 public struct Maybe<T> { private readonly T _value; public T Value { get { Contracts.Require(HasValue); return _value; } } public bool HasValue { get { return _value != null; } } public bool HasNoValue { get { return !HasValue; } } private Maybe([AllowNull] T value) { _value = value; } public static implicit operator Maybe<T>([AllowNull] T value) { return new Maybe<T>(value); } } 

Incoming values ​​in Maybe are marked with the AllowNull attribute. This indicates to the rewriter that he should not add null checks for these particular parameters.

Using Maybe, we can write the following code:

 Maybe<Customer> customer = _repository.GetById(id); 

And now, when reading the code, it becomes obvious that the GetById method can return null. There is no need to look at the code of the method to understand its semantics.

Moreover, now we cannot accidentally confuse the zero type with a non-zero one; such code will lead to a compiler error:

 Maybe<Customer> customer = _repository.GetById(id); ProcessCustomer(customer); // Compiler error private void ProcessCustomer(Customer customer) { // Method body } 

Of course, not all builds make sense to change with the help of the rewriter. For example, to apply such rules in an assembly with WFP would probably not be the best idea, since too many system components in it are nullable in nature. In such conditions, checks for null do not make sense, since you still can not do anything with most of these deeds.

As for domain assemblies, they are definitely worth improving in this way. Moreover, it is the domain classes that will benefit most from this approach.

Conclusion


Advantages of the described approach:


The remaining articles in the cycle



English version of the article: Functional C #: Non-nullable reference types

Source: https://habr.com/ru/post/267063/


All Articles