Struct
A class is a reference type, but not all objects are reference types (saved on the heap). Some objects can be created on the stack, and such objects are made using structs.
A struct is defined like a class, but it is used for slightly different things. Now, create a struct
named Point
:
public struct Point { public readonly int X; public readonly int Y; public Point(int x, int y) { X = x; Y = y; } }
The only real difference here is the struct
keyword, which indicates that this object will be saved on the stack. Also, you might have noticed that there is no use of properties. There are many people who would, instead of Point
, type x
and y
. It is not a big deal, but instead of one variable, you would be working with two. This way of working with primitives is called primitive obsession. You should follow the principles of OOP and work with abstractions, well-encapsulated data, as well as behavior to keep things close so that they have high cohesion. When choosing where to place variables, ask yourself this question: can x
change independently of y
? Do you ever modify a point? Is a point a complete value on its own? The answer to all of this is yes and therefore putting it in a data structure makes sense. But why choose a struct over a class?
Structs are fast because they do not have any allocations on the heap. They are also fast because they are passed by value (therefore, access is direct, not through a reference). Passing them by value copies the values, so even if you could modify a struct, changes would not remain outside of a method. When something is just a simple, small composite value, you should use a struct. Finally, with structs, you get value equality.
Another effective example of a struct
is DateTime
. DateTime
is just a unit of time, containing some information. It also does not change individually and supports methods such as AddDays
, TryParse
, and Now
. Even though it has several different pieces of data, they can be treated as one unit, as they are date- and time-related.
Most structs
should be immutable because they are passed by a copy of a value, so changing something inside a method will not keep those changes. You can add a readonly
keyword to a struct
, making all its fields readonly
:
public readonly struct Point { public int X { get; } public int Y { get; } public Point(int x, int y) { X = x; Y = y; } }
A readonly
struct
can have either a readonly
field or getter properties. This is useful for the future maintainers of your code base as it prevents them from doing things that you did not design for (no mutability). Structs are just tiny grouped bits of data, but they can have behavior as well. It makes sense to have a method to calculate the distance between two points:
public static double DistanceBetween(Point p1, Point p2) { return Math.Sqrt((p1.X - p2.X) * (p1.X - p2.X) + (p1.Y - p2.Y) * (p1.Y - p2.Y)); }
The preceding code has a little bit of math in it—that is, distance between two points is the square root of points x's and y's squared differences added together.
It also makes sense to calculate the distance between this and other points. You do not need to change anything because you can just reuse the existing code, passing correct arguments:
public double DistanceTo(Point p) { return DistanceBetween(this, p); }
If you wanted to measure the distance between two points, you could create them like this:
var p1 = new Point(3,1); var p2 = new Point(3,4);
And use a member function to calculate distance:
var distance1 = p1.DistanceTo(p2);
Or a static function:
var distance2 = Point.DistanceBetween(p1, p2);
The result for each version will be as follows:
– 3.
Note
You can find the code used for this example at https://packt.link/PtQzz.
When you think about a struct, think about it as just a group of primitives. The key point to remember is that all the data members (properties or fields) in a struct must be assigned during object initialization. It needs to be done for the same reason local variables cannot be used without having a value set initially. Structs do not support inheritance; however, they do support implementing an interface.
Structs are actually a great way to have simple business logic. Structs should be kept simple and should not contain other object references within them; they should be primitive-only. However, a class can hold as many struct objects as it needs. Using structs is a great way of escaping the obsessive use of primitives and using simple logic naturally, within a tiny group of data where it belongs—that is, a struct
.
Record
A record is a reference type (unlike a struct
, more like a class). However, out of the box, it has methods for comparison by value (both using the equals
method and the operator). Also, a record has a different default implementation of ToString()
, which no longer prints a type, but instead all the properties. This is exactly what is needed in many cases, so it helps a lot. Finally, there is a lot of syntactic sugar around records, which you are about to witness.
You already know how to create custom types in C#. The only difference between different custom types is the keyword used. For record types, such a keyword is record
. For example, you will now create a movie record. It has a Title
, Director
, Producer
, Description
, and a ReleaseDate
:
public record MovieRecordV1 { public string Title { get; } public string Director { get; } public string Producer { get; } public string Description { get; set; } public DateTime ReleaseDate { get; } public MovieRecordV1(string title, string director, string producer, DateTime releaseDate) { Title = title; Director = director; Producer = producer; ReleaseDate = releaseDate; } }
So far, you should find this very familiar, because the only difference is the keyword. Regardless of such a minor detail, you already reap major benefits.
Note
The intention of having MovieRecordV1
class in chapter, as against MovieClass
in GitHub code, was to have a type, similar to a class and then refactor highlighting how record helps.
Create two identical movies:
private static void DemoRecord() { var movie1 = new MovieRecordV1( "Star Wars: Episode I – The Phantom Menace", "George Lucas", "Rick McCallum", new DateTime(1999, 5, 15)); var movie2 = new MovieRecordV1( "Star Wars: Episode I – The Phantom Menace", "George Lucas", "Rick McCallum", new DateTime(1999, 5, 15)); }
So far, everything is the same. Try to print a movie to the console:
Console.WriteLine(movie1);
The output would be as follows:
MovieRecordV1 { Title = Star Wars: Episode I - The Phantom Menace, Director = George Lucas, Producer = Rick McCallum, Description = , ReleaseDate = 5/15/1999 12:00:00 AM }
Note
You can find the code used for this example at https://packt.link/xylkW.
If you tried doing the same to a class or a struct
object, you would only get a type printed. However, for a record, a default behavior is to print all of its properties and their values.
That is not the only benefit of a record. Again, a record has value-equality semantics. Comparing two movie records will compare them by their property values:
Console.WriteLine(movie1.Equals(movie2)); Console.WriteLine(movie1 == movie2);
This will print true true
.
With the same amount of code, you have managed to get the most functionality by simply changing a data structure to a record. Out of the box, a record provides Equals()
, GetHashCode() overrides
, == and != overrides
, and even a ToString
override, which prints the record itself (all the members and their values). The benefits of records do not end there because, using them, you have a way to reduce a lot of boilerplate code. Take full advantage of records and rewrite your movie record:
public record MovieRecord(string Title, string Director, string Producer, string Description, DateTime ReleaseDate);
This is a positional record, meaning all that you pass as parameters will end up in the right read-only data members as if it was a dedicated constructor. If you ran the demo again, you would notice that it no longer compiles. The major difference with this declaration is that, now, changing a description is no longer possible. Making a mutable property is not difficult, you just need to be explicit about it:
public record MovieRecord(string Title, string Director, string Producer, DateTime ReleaseDate) { public string Description { get; set; } }
You started this paragraph with a discussion on immutability, but why is the primary focus on records? The benefits of records are actually immutability-focused. Using a with
expression, you can create a copy of a record object with zero or more properties modified. So, suppose you add this to your demo:
var movie3 = movie2 with { Description = "Records can do that?" }; movie2.Description = "Changing original"; Console.WriteLine(movie3);
The code would result in this:
MovieRecord { Title = Star Wars: Episode I - The Phantom Menace, Director = George Lucas, Producer = Rick McCallum, ReleaseDate = 5/15/1999 12:00:00 AM, Description = Records can do that? }
As you see, this code copies an object with just one property changed. Before records, you would need a lot of code to ensure all the members are copied, and only then would you set a value. Keep in mind that this creates a shallow copy. A shallow copy is an object with all the references copied. A deep copy is an object with all the reference-type objects recreated. Unfortunately, there is no way of overriding such behavior. Records cannot inherit classes, but they can inherit other records. They can also implement interfaces.
Other than being a reference type, records are more like structs in that they have value equality and syntactic sugar around immutability. They should not be used as a replacement for structs because structs are still preferable for small and simple objects, which have simple logic. Use records when you want immutable objects for data, which could hold other complex objects (if nested objects could have a state that changes, shallow copying might cause unexpected behavior).
Init-Only Setters
With the introduction of records, the previous edition, C# 9, also introduced init
-only setter properties. Writing init
instead of set
can enable object initialization for properties:
public class House { public string Address { get; init; } public string Owner { get; init; } public DateTime? Built { get; init; } }
This enables you to create a house with unknown properties:
var house2 = new House();
Or assign them:
var house1 = new House { Address = "Kings street 4", Owner = "King", Built = DateTime.Now };
Using init
-only setters is especially useful when you want read-only data, which can be known or not, but not in a consistent matter.
Note
You can find the code used for this example at https://packt.link/89J99.
ValueTuple and Deconstruction
You already know that a function can only return one thing. In some cases, you can use the out
keyword to return a second thing. For example, converting a string to a number is often done like this:
var text = "123"; var isNumber = int.TryParse(text, out var number);
TryParse
returns both the parsed number and whether the text was a number.
However, C# has a better way of returning multiple values. You can achieve this using a data structure called ValueTuple
. It is a generic struct
that contains from one to six public mutable fields of any (specified) type. It is just a container for holding unrelated values. For example, if you had a dog
, a human
, and a Bool
, you could store all three in a ValueTuple
struct:
var values1 = new ValueTuple<Dog, Human, bool>(dog, human, isDogKnown);
You can then access each—that is, dog
through values1.Item1
, human
through values1.Item2
, and isDogKnown
through values.Item3
. Another way of creating a ValueTuple
struct is to use brackets. This does exactly the same thing as before, but using the brackets syntax:
var values2 = (dog, human, isDogKnown);
The following syntax proves extremely useful because, with it, you can declare a function that virtually returns multiple things:
public (Dog, Human, bool) GetDogHumanAndBool() { var dog = new Dog("Sparky"); var human = new Human("Thomas"); bool isDogKnown = false; return (dog, human, isDogKnown); }
Note
You can find the code used for this example at https://packt.link/OTFpm.
You can also do the opposite, using another C# feature called deconstruction. It takes object data members and allows you to split them apart, into separate variables. The problem with a tuple type is that it does not have a strong name. As mentioned before, every field will be called ItemX
, where X
is the order in which the item was returned. Working with all that, GetDogHumanAndBool
would require the results to be assigned to three different variables:
var dogHumanAndBool = GetDogHumanAndBool(); var dog = dogHumanAndBool.Item1; var human = dogHumanAndBool.Item2; var boo = dogHumanAndBool.Item3;
You can simplify this and instead make use of deconstruction—assigning object properties to different variables right away:
var (dog, human, boo) = GetDogHumanAndBool();
Using deconstruction, you are able to make this a lot more readable and concise. Use ValueTuple
when you have multiple unrelated variables and you want to return them all from a function. You do not have to always work around using the out
keyword, nor do you have to add overhead by creating a new class. You can solve this problem by simply returning and then deconstructing a ValueTuple
struct.
You can now have hands-on experience of using SOLID principles for writing codes incrementally through the following exercise.
Exercise 2.04: Creating a Composable Temperature Unit Converter
Temperature can be measured in different units: Celsius, Kelvin, and Fahrenheit. In the future, more units might be added. However, units do not have to be added dynamically by the user; the application either supports it or not. You need to make an application that converts temperature from any unit to another unit.
It is important to note that converting to and from that unit will be a completely different thing. Therefore, you will need two methods for every converter. As a standard unit, you will use Celsius. Therefore, every converter should have a conversion method from and to Celsius, which makes it the simplest unit of a program. When you need to convert non-Celsius to Celsius, you will need to involve two converters—one to adapt the input to the standard unit (C), and then another one to convert from C to whatever unit you want. The exercise will aid you in developing an application using the SOLID principles and C# features you have learned in this chapter, such as record
and enum
.
Perform the following steps to do so:
- Create a
TemperatureUnit
that uses anenum
type to define constants—that is, a set of known values. You do not need to add it dynamically:public enum TemperatureUnit { C, F, K }
In this example, you will use three temperature units that are C
, K
, and F
.
- Temperature should be thought of as a simple object made of two properties:
Unit
andDegrees
. You could either use arecord
or astruct
because it is a very simple object with data. The best choice would be picking astruct
here (due to the size of the object), but for the sake of practicing, you will use arecord
:public record Temperature(double Degrees, TemperatureUnit Unit);
- Next, add a contract defining what you want from an individual specific temperature converter:
public interface ITemperatureConverter { public TemperatureUnit Unit { get; } public Temperature ToC(Temperature temperature); public Temperature FromC(Temperature temperature); }
You defined an interface with three methods—the Unit
property to identify which temperature the converter is for, and ToC
and FromC
to convert from and to standard units.
- Now that you have a converter, add the composable converter, which has an array of converters:
public class ComposableTemperatureConverter { private readonly ITemperatureConverter[] _converters;
- It makes no sense to have duplicate temperature unit converters. So, add an error that will be thrown when a duplicate converter is detected. Also, not having any converters makes no sense. Therefore, there should be some code for validating against
null
or empty converters:public class InvalidTemperatureConverterException : Exception { public InvalidTemperatureConverterException(TemperatureUnit unit) : base($"Duplicate converter for {unit}.") { } public InvalidTemperatureConverterException(string message) : base(message) { } }
When creating custom exceptions, you should provide as much information as possible about the context of an error. In this case, pass the unit
for which the converter was not found.
- Add a method that requires non-empty converters:
private static void RequireNotEmpty(ITemperatureConverter[] converters) { if (converters?.Length > 0 == false) { throw new InvalidTemperatureConverterException("At least one temperature conversion must be supported"); } }
Passing an array of empty converters throws an InvalidTemperatureConverterException
exception.
- Add a method that requires non-duplicate converters:
private static void RequireNoDuplicate(ITemperatureConverter[] converters) { for (var index1 = 0; index1 < converters.Length - 1; index1++) { var first = converters[index1]; for (int index2 = index1 + 1; index2 < converters.Length; index2++) { var second = converters[index2]; if (first.Unit == second.Unit) { throw new InvalidTemperatureConverterException(first.Unit); } } } }
This method goes through every converter and checks that, at other indexes, the same converter is not repeated (by duplicating TemperatureUnit
). If it finds a duplicate unit, it will throw an exception. If it does not, it will just terminate successfully.
- Now combine it all in a constructor:
public ComposableTemperatureConverter(ITemperatureConverter[] converters) { RequireNotEmpty(converters); RequireNoDuplicate(converters); _converters = converters; }
When creating the converter, validate against converters that are not empty and not duplicates and only then set them.
- Next, create a
private
helper method to help you find the requisite converter,FindConverter
, inside the composable converter:private ITemperatureConverter FindConverter(TemperatureUnit unit) { foreach (var converter in _converters) { if (converter.Unit == unit) { return converter; } } throw new InvalidTemperatureConversionException(unit); }
This method returns the converter of the requisite unit and, if no converter is found, throws an exception.
- To simplify how you search and convert from any unit to Celsius, add a
ToCelsius
method for that:private Temperature ToCelsius(Temperature temperatureFrom) { var converterFrom = FindConverter(temperatureFrom.Unit); return converterFrom.ToC(temperatureFrom); }
Here, you find the requisite converter and convert the Temperature
to Celsius.
- Do the same for converting from Celsius to any other unit:
private Temperature CelsiusToOther(Temperature celsius, TemperatureUnit unitTo) { var converterTo = FindConverter(unitTo); return converterTo.FromC(celsius); }
- Wrap it all up by implementing this algorithm, standardize the temperature (convert to Celsius), and then convert to any other temperature:
public Temperature Convert(Temperature temperatureFrom, TemperatureUnit unitTo) { var celsius = ToCelsius(temperatureFrom); return CelsiusToOther(celsius, unitTo); }
- Add a few converters. Start with the Kelvin converter,
KelvinConverter
:public class KelvinConverter : ITemperatureConverter { public const double AbsoluteZero = -273.15; public TemperatureUnit Unit => TemperatureUnit.K; public Temperature ToC(Temperature temperature) { return new(temperature.Degrees + AbsoluteZero, TemperatureUnit.C); } public Temperature FromC(Temperature temperature) { return new(temperature.Degrees - AbsoluteZero, Unit); } }
The implementation of this and all the other converters is straightforward. All you had to do was implement the formula to convert to the correct unit from or to Celsius. Kelvin has a useful constant, absolute zero, so instead of having a magic number, –273.15
, you used a named constant. Also, it is worth remembering that a temperature is not a primitive. It is both a degree value and a unit. So, when converting, you need to pass both. ToC
will always take TemperatureUnit.C
as a unit and FromC
will take whatever unit the converter is identified as, in this case, TemperatureUnit.K
.
- Now add a Fahrenheit converter,
FahrenheitConverter
:public class FahrenheitConverter : ITemperatureConverter { public TemperatureUnit Unit => TemperatureUnit.F; public Temperature ToC(Temperature temperature) { return new(5.0/9 * (temperature.Degrees - 32), TemperatureUnit.C); } public Temperature FromC(Temperature temperature) { return new(9.0 / 5 * temperature.Degrees + 32, Unit); } }
Fahrenheit is identical structure-wise; the only differences are the formulas and unit value.
- Add a
CelsiusConverter
, which will accept a value for the temperature and return the same value, as follows:public class CelsiusConverter : ITemperatureConverter { public TemperatureUnit Unit => TemperatureUnit.C; public Temperature ToC(Temperature temperature) { return temperature; } public Temperature FromC(Temperature temperature) { return temperature; } }
CelsiusConverter
is the simplest one. It does not do anything; it just returns the same temperature. The converters convert to standard temperature—Celsius to Celsius is always Celsius. Why do you need such a class at all? Without it, you would need to change the flow a bit, adding if
statements to ignore the temperature if it was in Celsius. But with this implementation, you can incorporate it in the same flow and use it in the same way with the help of the same abstraction, ITemperatureConverter
.
- Finally, create a demo:
Solution.cs
public static class Solution { public static void Main() { ITemperatureConverter[] converters = {new FahrenheitConverter(), new KelvinConverter(), new CelsiusConverter()}; var composableConverter = new ComposableTemperatureConverter(converters); var celsius = new Temperature(20.00001, TemperatureUnit.C); var celsius1 = composableConverter.Convert(celsius, TemperatureUnit.C); var fahrenheit = composableConverter.Convert(celsius1, TemperatureUnit.F); var kelvin = composableConverter.Convert(fahrenheit, TemperatureUnit.K); var celsiusBack = composableConverter.Convert(kelvin, TemperatureUnit.C); Console.WriteLine($"{celsius} = {fahrenheit}");
You can find the complete code here: https://packt.link/ruBph.
In this example, you have created all the converters and passed them to the converters container called composableConverter
. Then you have created a temperature in Celsius and used it to perform conversions from and to all the other temperatures.
- Run the code and you will get the following results:
Temperature { Degrees = 20.00001, Unit = C } = Temperature { Degrees = 68.000018, Unit = F } Temperature { Degrees = 68.000018, Unit = F } = Temperature { Degrees = -253.14998999999997, Unit = K } Temperature { Degrees = -253.14998999999997, Unit = K } = Temperature { Degrees = 20.000010000000003, Unit = C }
Note
You can find the code used for this exercise at https://packt.link/dDRU6.
A software developer, ideally, should design code in such a way that making a change now or in the future will take the same amount of time. Using SOLID principles, you can write code incrementally and minimize the risk of breaking changes, because you never change existing code; you just add new code. As systems grow, complexity increases, and it might be difficult to learn how things work. Through well-defined contracts, SOLID enables you to have easy-to-read, and maintainable code because each piece is straightforward by itself, and they are isolated from one another.
You will now test your knowledge of creating classes and overriding operators through an activity.
Activity 2.01: Merging Two Circles
In this activity, you will create classes and override operators to solve the following mathematics problem: A portion of pizza dough can be used to create two circular pizza bites each with a radius of three centimeters. What would be the radius of a single pizza bite made from the same amount of dough? You can assume that all the pizza bites are the same thickness. The following steps will help you complete this activity:
- Create a
Circle
struct with a radius. It should be astruct
because it is a simple data object, which has a tiny bit of logic, calculating area. - Add a property to get the area of a circle (try to use an expression-bodied member). Remember, the formula of a circle's area is
pi*r*r
. To use thePI
constant, you will need to import theMath
package. - Add two circles' areas together. The most natural way would be to use an overload for a plus (
+
) operator. Implement a+
operator overload that takes two circles and returns a new one. The area of the new circle is the sum of the areas of the two old circles. However, do not create a new circle by passing the area. You need a Radius. You can calculate this by dividing the new area byPI
and then taking the square root of the result. - Now create a
Solution
class that takes two circles and returns a result—the radius of the new circle. - Within the
main
method, create two circles with a radius of3
cm and define a new circle, which is equal to the areas of the two other circles added together. Print the results. - Run the
main
method and the result should be as follows:Adding circles of radius of 3 and 3 results in a new circle with a radius 4.242640687119285
As you can see from this final output, the new circle will have a radius of 4.24
(rounded to the second decimal place).
Note
The solution to this activity can be found at https://packt.link/qclbF.
This activity was designed to test your knowledge of creating classes and overriding operators. Operators are not normally employed to solve this sort of problem, but in this case, it worked well.