Junior Csharp DotNet 15 Essential Junior Level Topics
Junior Csharp DotNet 15 Essential Junior Level Topics
C#/.NET
15 ESSENTIAL
INTERVIEW
QUESTIONS
Junior Level
HELLO!
This e-book is a part of my course
"C#/.NET - 50 Essential Interview
Questions (Mid Level)".
https://bit.ly/3sC7FsW
https://bit.ly/3hSRpOq
INTRODUCTION
Hello, I'm Krystyna! I'm a programmer
who loves to write elegant code.
I've been working as a software
developer since 2013. About half of this
time I've been engaged in teaching
programming.
I believe that with a proper
explanation, everyone can understand
even the most advanced topics related
to programming.
I hope I can show you how much fun
programming can be, and that you will
enjoy it as much as I do!
CONTENTS
1. What is the Common Intermediate
Language (CIL)?
2. What is the Common Language
Runtime?
3. What is the difference between
value types and reference types?
4. What is boxing and unboxing?
5. What is the difference between a
class and a struct?
6. What is LINQ?
7. What are extension methods?
8. What is IEnumerable?
9. What is the Garbage Collector?
10. What are nullable types?
11. What are generics?
12.What is the difference between an
interface and an abstract class?
13.What is the Bridge design pattern?
1 4 .W h a t i s t h e S i n g l e R e s p o n s i b i l i t y
Principle?
15.What is the Open-Closed Principle?
1. What is the Common Intermediate
Language?
Let's write a simple C# code and see what it looks like after being translated to CIL:
To see the CIL code, we must first make sure to build the solution in the Visual
Studio. Then we are going to use Ildasm to view the CIL code. Ildasm is the
Intermediate Language Disassembler, and it gets installed when you install .NET on
your machine. On my machine it got installed in C:\Program Files (x86)\Microsoft
SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\ildasm.exe. Let's run this tool:
Now, we must find the *.dll for which we want to see the CIL code. The simplest
solution is to right-click the project in the Visual Studio and then select "Open
Folder in File Explorer". Then, we must go to the output folder. In my case it is
/bin/Debug/net5.0. There I can find the *.dll that was built by the Visual Studio:
This might not be the most beautiful programming language you've ever seen, but
on the other hand, it is not completely unreadable, as one could expect.
Remember - all .NET compatible languages, not only C#, get compiled to the CIL.
That enables communication between, for example, a C# and an F# libraries. For
example, we can have a C# class derived from an F# class exactly because they
both get compiled to the same programming language - the CIL.
Tip: other interview questions on this topic:
● "How can you see the CIL code a project got compiled to?"
Some tools can decompile a *.dll file and read the CIL code. One of those tools
is Ildasm.
Before we move on, I would like to say one thing: you may think that CLR is not a
junior-level topic, and you might be right - this is a pretty low-level feature of .NET
and you certainly can start programming .NET applications without even knowing
that the CLR exists. Nevertheless, I wanted to introduce this topic as throughout
this course I mention the CLR very often, as it affects many aspects of .NET
programming. I want to make sure every subject in this course is explained in
detail, even if it sometimes exceeds the junior level. Some of the topics of this
course simply can't be understood thoroughly without a basic understanding of
the role of the CLR.
All right, let's move on then. The important thing to understand is that the CLR is
the .NET component that is not exclusive for C# applications. All programs written
for the .NET are executed by the CLR. All code executed under the CLR is called the
managed code. Thanks to the CLR, cross-language integration is supported in .NET.
For example, you can have a C#'s class derived from a class defined in F#, because
the CLR can understand both languages (because both are compiled to the
Intermediate Language).
The CLR is responsible for many operations essential for any .NET application to
work. Some of them are:
● JIT (Just-in-time) compilation - the compilation of the Common
Intermediate Language to the binary code. Thanks to that the .NET
applications can be used cross-platform because the code is compiled to
platform-specific binary code only right before execution. See the "What is
the Common Intermediate Language (CIL)?" lecture for more information on
that.
● Memory management - CLR allocates the memory needed for every object
created within the application. CLR also includes the Garbage Collector,
which is responsible for releasing and defragmenting the memory. See the
"What is the Garbage Collector?" lecture for more information.
● Exception handling - when the exception is thrown, the CLR makes sure the
code execution is redirected to the proper catch clause. See the "How are
exceptions handled in C#?" lecture for more information.
● Thread management - threads are beyond junior level, so let's just shortly
say that the CLR manages the execution of the multi-threaded applications,
making sure all threads work together well
● Type safety - part of the CLR is the CTS - Common Type System. CTS
defines the standard for all .NET-compatible languages. Thanks to that, the
CLR can understand types defined in C#, F#, Visual Basic, and so on,
enabling cross-language integration.
● And many more
The CLR is the implementation of the CLI - Common Language Infrastructure. CLI
was originally created by Microsoft and is standardized by ISO and ECMA. Sounds
confusing? Let's explain it in this way - CLI is like a design of a house - it describes
where walls, windows, piping, and electricity goes. It was originally created by
Microsoft. ISO and ECMA are like state authorities who approve the design and
make sure it is safe and reasonable, and that houses built based on this design will
all function properly. Using this design, Microsoft builds a house. In this metaphor,
the design is the CLI and the house built by Microsoft is the CLR. The thing about
designs is that we can build many things based on the same design. That means
another company could implement its own version of the project in accordance
with the CLI. Does it ever happen? Actually, it does! For example, there is Mono
Runtime, a counterpart of the CLR developed by a company called Ximian.
By now you should have a general idea of what the CLR does, but before we wrap
up let's go step-by-step through a (simplified) process of creating and running the
application to see when and how the CLR plays its role.
1. The programmer writes the program, which at first is just a bunch of text
files.
2. The compiler compiles the text file to Common Intermediate Language,
which is platform-independent. The compiler also prepares the metadata
that describes all the types along with the methods they include.
3. Now the CLR comes into play. It starts the program under the specific
operating system.
4. The CLR's Just-In-Time compiler compiles the Intermediate Language to
binary code that can be interpreted by the machine's operating system. It
uses the metadata prepared by the compiler.
5. As the application runs, the CLR manages all its low-level aspects - memory
management, threads, exceptions handling, and so on.
6. When the application stops, the CLR's job is done.
As you can see, the CLR is the critical component of .NET. It manages basically
everything that happens under the hood of the running application, allowing us,
the programmers, to focus on business aspects of the development. Before tools
like the CLR were introduced, programmers must have dealt with all things like
memory management, which is the case in languages like C (can you imagine
allocating memory for the objects by hand?).
Brief summary: The differences between value types and reference types are:
1. Value types inherit from System.ValueType while reference types inherit
from System.Object.
2. When a value type is passed as a parameter, its copy is given to the
method. When a reference type is passed as a parameter, a copy of the
reference is given to the method.
3. On assignment, a variable of a value type is copied. For reference types,
only a reference is copied.
4. All value types are sealed (which means, they cannot be inherited)
5. Value types are stored on the stack, reference types are stored on the
heap (because of that, the Garbage Collector only cleans up reference
types)
The fundamental difference between them is that a reference type variable only
holds a reference (you can think of it as a link or an address) to the actual data,
while the value type variable holds the actual data.
Now, let's look closer and more technically at the differences between value and
reference types.
The first basic difference is that all reference types inherit from System.Object
whereas all value types inherit from System.ValueType.
Another difference is that when a value type is passed as a parameter, its copy is
given to the method. When a reference type is passed as a parameter, a copy of
the reference is given to the method. Let's see some code to understand what it
means:
As you can see we defined two methods. Both of them alter the parameter that
was passed to them. The fundamental difference is that the AddOne method takes
a value type, while the AddOneToList takes a reference type. What do you think
this code will print?
Well, it will print "5" twice! This is because the a variable has been passed to the
AddMethod by a copy. The AddMethod incremented the copy, so the original a
variable has not been affected.
Let's see a similar situation, but for the reference types:
What do you think will be printed? Well, since the List is a reference type, the
AddOneToList does not operate on its copy, but on the same object that is
referenced by the list variable. Because of that, the number of elements in the list
variable has been incremented by one.
Another difference between value and reference types is that when assigning the
value of the value type to the new variable, a copy is created. The change in the
original object will not affect the new object. For reference types, when assigning
the value to the new variable, only the reference is copied, while the original
object still exists in one copy only. That means that the change in the original
object will also affect what is stored in the new variable.
At assignment, no copy of the listB was created. Only the reference was copied,
and it still points to the same object. Because of that adding an element to the
listC variable also affected listB - because listC and listB point to the same object.
There are also some more technical differences between value types and
reference types. Firstly, all value types are sealed, which means other types can't
inherit from them. Because of that, value types can't have virtual or abstract
members. See the "What is the purpose of the "sealed" modifier?" lecture for more
information.
Secondly, value types are stored on the stack whereas reference types are stored
on the heap (only the reference itself is stored on the stack). The value of the
value type variable is cleaned out from the stack when the code execution leaves
the scope this variable lived in. For reference types it is not the case - the object
addressed by the reference will be cleaned up by the Garbage Collector and the
exact time of that is unknown.
Let's summarize the differences between value types and reference types:
● All reference types inherit from System.Object whereas all value types
inherit from System.ValueType.
● When a value type is passed as a parameter, its copy is given to the method.
When a reference type is passed as a parameter, a copy of the reference is
given to the method.
● When assigning the value of the value type to the new variable, a copy is
created. The change in the original object will not affect the new object. For
reference types, when assigning the value to the new variable, only the
reference is copied, while the original object still exists in one copy only.
That means that the change in the original object will also affect what is
stored in the new variable.
● All value types are sealed, which means other types can't inherit from them.
Because of that, value types can't have virtual or abstract members.
● Value types are stored on the stack whereas reference types are stored on
the heap (only the reference itself is stored on the stack).
● The value of the value type variable is cleaned out from the stack when the
code execution leaves the scope this variable lived in. For reference types it
is not the case - the object addressed by the reference will be cleaned up by
the Garbage Collector and the exact time of that is unknown.
● "What will happen if you pass an integer to a method and you increase it
by one in the method's body? Will the variable you passed to the method
be incremented?"
The number will be increased in the scope of the method's body, but the
variable outside this method will stay unmodified because a copy was passed
to the method.
Brief summary: Boxing is the process of wrapping a value type into an instance
of a type System.Object. Unboxing is the opposite - the process of converting the
boxed value back to a value type.
As we know, value types are stored on the stack while reference types are stored
on the heap. Only the reference itself (so an "address" or "pointer" to the object
stored on the heap) is stored on the stack. Let's see a short piece of code:
As you can see, boxing is done implicitly. On the other hand, the unboxing must be
done explicitly by using a cast:
Unboxing unwraps the original value from the object and assigns it to a value type
variable.
The unboxing requires the exact type match. For example, this would throw an
exception, because integer is not the same as short:
Please be aware that boxing and unboxing come with a performance penalty.
Unlike regular variables assignment, boxing requires the creation of a new object
and allocating memory on the heap for it. The unboxing requires a cast, which is
also computationally expensive.
Now we know how boxing and unboxing are done and what exactly they do. But
what's their use? Well, boxing and unboxing are necessary for providing a unified
type system - that is, that we can treat any variable in C# as an object. Without
boxing and unboxing, we couldn't have the ultimately generic code that accepts
any type of variable - we would have to distinguish value and reference types and
possibly provide separate implementations for both of them. That was particularly
useful before the generic types were introduced, and classes like ArrayList (used
for storing any type of data) were commonly used. Even nowadays it is still used,
for example in ADO.NET which is used to store objects in databases - at some
point, this framework treats every piece of data as an object. Without boxing, it
wouldn't be able to handle value types.
Brief summary:
1. Structs are value types and classes are reference types.
2. Structs can only have a constructor with parameters, and all the struct's
fields must be assigned in this constructor.
3. Structs can't have explicit parameterless constructors.
4. Structs can't have destructors.
Brief summary: LINQ is a set of technologies that allow simple and efficient
querying over different kinds of data.
Data can be stored in various types of containers - C# data structures like Lists or
arrays, databases, XML documents, and many more. LINQ allows us to query such
data in a uniform way, allowing the programmer to focus on what the program is
supposed to do with the data, not on the technical details of accessing different
data containers. We can use different LINQ providers, like LINQ to SQL that allows
querying over SQL databases, or LINQ to XML that allows querying over XML
documents. Each LINQ provider must implement IQueryProvider and IQueryable
interfaces. We can create our own LINQ providers if we need to support querying
over a new type of data container.
Let's try to use LINQ in practice. We will work on the following data:
● Method syntax:
Both expressions do the same thing - they filter out those pirates who were born
after 1685. There isn’t any distinct advantage of one over the other. Any query
syntax can be transformed into method syntax.
From my personal experience, most developers prefer method syntax, as it is just
pure C#, over query syntax which is kind of a new language. But it is up to you (or
your team) which one you should use.
Let's see some of the most useful methods from the System.LINQ namespace.
From now on I'm going to stick to method syntax.
As you can see, those expressions are really easy to read and understand. LINQ is
widely considered an amazing library with intuitive syntax that was well designed
by its developers. If you are not familiar with LINQ, I highly recommend you to start
learning it, as it may be one of the most powerful tools .NET developers use.
Let's see this in practice. Imagine your code operates on long, multiline strings. You
need a method that, given a string, counts a number of lines. Unfortunately, there
is no such method in the built-in String class. We must create our own method.
Something like that. But where to define the new GetNumberOfLines method?
Ideally, it would belong to some static class aggregating all string operations. Let's
do it:
All right - this code works, but it seems a bit clumsy. Over time, more and more
methods could join the StringOperations class, and each of them would have to be
called with StringOperations.MethodName syntax. Wouldn't it be better if we
could simply call "multilineString.NumberOfLines()"? Unfortunately, we can't
modify the String class. But we can use extension methods, and that's exactly their
purpose. Let's do it:
Now the NumberOfLines method is an extension method for the String class.
Please note "this" before the parameter type. We need to use the "this" keyword in
order to create an extension method. It also must be static and must belong to a
static, non-generic class. Notice how we call this method now - exactly like it
belonged to the String class.
As you can see, the extension method is an easy way to add functionality to a class
without modifying it. It's most commonly used with external classes (not defined in
our project, so built-in classes or classes from external libraries) but we can also
use it with our own classes:
If the class already contains a method with the same signature as the extension
method, the member method will be called and the extension method will be
ignored:
Please note that both of those methods would be called upon a duck object with
"duck.Quack()". In this case, calling the Quack method on a duck object will result
in:
As you can see the non-extension method has been called, as it has a priority over
extension methods.
To summarize: extension methods allow us to add functionality to a class without
modifying it, which is especially useful when we use an external class and we don't
have access to its source code. Extension methods are used very often and you
might even have used them without knowing it - for example, whenever calling
LINQ's methods OrderBy, GroupBy, or Where, you are actually calling extension
methods for IEnumerable. Here is a snippet from LINQ source code to prove it:
Requirements:
● The class in which the extension methods are defined must be static and
non-generic
● The extension method must take the object of the extended class as a first
parameter, with "this" modifier proceeding this parameter
● "What will happen if you call a member method that has the same
signature as the existing extension method?"
The member method has priority and it will be the one to be called. The
extension method will not be called.
8. What is IEnumerable?
The above code works because the array we used implements IEnumerable
interface. If it hadn't, the code would not compile:
All right. Let's take a closer look at this interface. It contains a single method:
As you can see, GetEnumerator simply returns IEnumerator:
We will implement our own type supporting enumeration in a second, but first,
let's see how foreach loop is interpreted by .NET. Let's see the code with a foreach
loop again:
.NET actually translates this code to something like this:
All right, let's create our own collection that will hold a group of strings, and that
will support being iterated over by foreach loop. To do so, we need to create a
class that implements IEnumerable interface:
Now, we will have to create the WordsEnumerator class that will implement
IEnumerator interface:
As you can see, the MoveNext method is called first, and then we access the
Current element. The MoveNext method will be incrementing the value of
_position field by one. If we initialized the _position field with 0, it would have a
value of 1 after the MoveNext method was called. And because of that, the "first"
element returned by the Current property would actually be the second in the
collection. This is why we set the position to -1, so after the first use of MoveNext
it is 0.
All right, now it is pretty obvious what the Reset method will do:
MoveNext will simply increment the _position by one. Please note that MoveNext
returns a bool. This bool should be true if we successfully advanced to the next
element, and false if we passed the end of the collection. Let's implement that:
Finally, let's implement the Current property:
● "What is an enumerator?"
An enumerator is a mechanism that allows iterating over collection elements.
It's a kind of a pointer that points to a "current" element in the collection.
The Garbage Collector is the CLR's (Common Language Runtime) mechanism that
manages the memory used by the application.
As soon as the application starts it begins to create new objects, and those objects
need to be stored in memory. C#'s objects live in two areas of memory: the
stack and the heap.
The stack holds value types, so types like ints, bools, floats, and structs. Memory
allocated for those objects is automatically freed when the control reaches the end
of the scope those objects live in:
When variable a was created, it was put on the stack. When the control reaches the
end of the scope this variable lives in - so the "if" clause - this variable is removed
from the stack and its memory is freed. This is NOT done by the Garbage Collector,
but by the CLR itself.
Garbage Collector manages objects that live on the heap, so the reference types
(strings, Lists, arrays, and all other objects that are defined as classes). Garbage
Collector determines if there are any existing references to the object - if not, it
decides this object is no longer used and frees the memory used by this object.
The important thing here is that Garbage Collector will not clean the memory
immediately - it is important to know that we can't deterministically say when
exactly will that happen (so if you must clean up some resources at once, don't
leave that to Garbage Collector - implement IDisposable interface and use the
Dispose method). There is a way to force collection of the memory by the Garbage
Collector by using GC.Collect method, but it shouldn't be the default solution if
you want to have the memory immediately freed.
So basically, the Garbage Collector does not start to work unless there is a need for
it.
The important thing to understand is that Garbage Collector runs on its own,
separate thread, and as this happens all other threads are being stopped until
Garbage Collector finishes its work. This might obviously cause performance issues.
For example, consider a video game created in C# (in case you don't know, one of
the most popular video games development environments is Unity, where you can
create games in C#). If plenty of short-lived objects would be created every second,
Garbage Collector would have a lot of work to do, and it would often "freeze" the
game for a fraction of a second to do its work. The experience for the player would
not be perfect. In such cases, it is recommended to avoid frequent triggering of the
Garbage Collector work (there are some techniques to do it, for example by using a
pool of objects that are being reused, instead of frequent creation and destruction
of short-lived objects). The important thing to remember is that Garbage Collector
freezes all other threads for the time of the collection.
After Garbage Collector finishes freeing the memory, it also executes memory
defragmentation.
To understand what it is, imagine a computer's memory as a long array of bits
(that's actually pretty close to the real thing). Let's say we create a variable of type
string, and value of "abc". It consists of 3 characters, and each char in C# is 2 bytes.
One byte is 8 bits. So we need 3 * 2 * 8 bits of memory, so 48 bits.
The Garbage Collector finds a 48-bits long part of empty memory in this long array
of memory assigned to the process in which our application runs, and reserves it
for the "abc" string. Now the memory looks like this:
Now, let's assume the "def" string is no longer used and its memory is freed by the
Garbage Collector.
Now, if we want to add some new, long string to the memory, we might actually
not find a place for it to fit. We do have two blocks of free memory, but none of
them is long enough.
What the Garbage Collector needs to do, is to move some pieces of memory and
make them contiguous, thus creating a bigger free block of memory:
So as we can see, the Garbage Collector is a pretty smart tool. It makes our work
much easier, but it doesn't mean we can forget completely about memory issues.
Let's consider memory leaks. A memory leak is a situation when some piece of
memory is not being cleaned up, even if the object using it is no longer in use. It is
important to understand that Garbage Collector does not give us 100%
protection from memory leaks.
One of the most common sources of memory leaks in .NET is related to event
handlers. Events are topics beyond Junior level, but let's for now just assume they
are used to handle some specific situations (like clicking on a button) by
"attaching" a method that will be called to the event "handler".
Imagine you have a window-based application. There is the main window, and when
a button in this window is clicked, a child window opens. Here is a very simplified
code for that:
In this case, the solution is to unsubscribe from the event handler - for example on
form closing:
This was just an example of how we can encounter memory leaks that
GarbageCollector is unable to protect us against. You should always be considerate
of the memory and remember that Garbage Collector is just a tool, even if pretty
clever.
There are many low-level details on how exactly Garbage Collector works, but they
are beyond Junior level. The most important things you need to remember about
Garbage Collector are:
● It manages the memory of the application
● It frees up memory allocated for objects that are no longer referenced
● It's hard to say when exactly it will collect the memory - it has its' own
mechanism that depends on how much memory is being used
● It defragments the memory
● It does not guarantee protection from memory leaks
Brief summary: Nullable type is any type that can be assigned a value of null.
Nullable<T> struct is a wrapper for a value type allowing assigning null to the
variable of this type. For example, we can't assign null to an integer, but we can
to a variable of type Nullable<int>.
Nullable type is any type that can be assigned a value of null. In C#, all reference
types are nullable by default, and value types are non-nullable by default. That
means, we can't assign null to an integer variable, but it is fine to assign null to, for
example, a List variable, since List is a reference type and int is a value type.
Null represents "lack of value", or "missing value". There are many business
cases when such special value is needed, even for value types. For example,
imagine you have a collection of people, and each Person has a Height property. If
we decide that Height is an integer (so a non-nullable value type) we might
encounter a problem - what if we don't know some person's height? We could make
a risky assumption that in this case, we would use some special value, like 0 or -1.
But then, what if we want to calculate the average height of all people in this
collection? Having people of height 160, -1, 185, -1, 170, we would end up with an
average value of 102,6, which obviously doesn't make much sense. It's better to
represent height as a nullable integer type, and only include non-null values in the
average height calculation.
In C#, we can declare value type as nullable with the "?" operator:
Nullable exposes useful properties like HasValue, which indicates whether the
value is null or not, or Value, which unpacks the internal value (so for the nullable
type int? Value property will return an int).
● "Can a value type be assigned null?" No, it can't. It must be wrapped in the
Nullable<T> struct first if we want to make it nullable.
Brief summary: Generic classes or methods are parametrized by type - like, for
example, a List<T> that can store any type of elements.
Let's consider a simple example of a List. Without generics, we would need to have
a separate class for all data types we want to store in a list:
That would be absolutely awful. Not only would we need to copy this code each
time we want a new type to be stored in a list, but also if we decided a change
must be made in the List classes (for example, if we found a bug) we would need to
modify each and every one of them.
This is where generics come in handy - we can create a single class, that will be
parameterized by a type:
We can parametrize a class or a method with more than one type parameter. An
example of such a class is a Dictionary. Dictionary is a data structure that holds
pairs of keys and values. We can use any types as keys and values:
In such a case, when defining a generic type, we simply provide two type
parameters. Let's see how the C#'s dictionary is defined:
We sometimes need to put some kind of constraint on a generic type. For example,
the Nullable<T> allows only value types to be used as T parameter because
non-value types are nullable by definition. To put a constraint on a type we use a
"where" keyword. Let's see some of the basic type constraints in C#:
Class - the type must be a reference type:
If the type does not meet the criteria defined in the constraint, a compilation error
appears:
● "What are type constraints?" Type constraints allow limiting the usage of a
generic type only to the types that meet specific criteria. For example, we
may require the type to be a value type, or require that this type provides a
public parameterless constructor.
● "What is the "where" keyword used for?" It's used to define type
constraints. Also, it is used for filtering when using LINQ.
● "What are the benefits of using generics?" They allow us to reduce code
duplication by creating a single class that can work with any type. Reducing
code duplication makes the code easier to maintain and less error-prone.
12. What is the difference between an
interface and an abstract class?
Before we delve into more technical details, let's try to understand the difference
between interfaces and abstract classes at the conceptual level:
● An interface is an abstraction over behavior. It defines what an object can
do. When you have a group of objects and they share similar behavior, they
might have a common interface. For example, a bird, a kite, and a plane fly -
so it makes sense for all of them to implement the IFlyable interface. When
you are given an object implementing IFlyable interface, you might not be
sure what that is - but you'll know it is able to fly. When you try to find what
some objects have in common and a verb comes to mind, it means you
probably want to use an interface.
● An abstract class is an abstraction over alikeness. It defines what an object
is. When you have a group of objects, and they all belong to some general
category of things, they might inherit from the same abstract class. For
example, a bird, a snake and a dog all are animals - so it makes sense for
them to inherit from abstract class Animal. When you are given an object
that inherits from the Animal abstract class, you might not be sure how it
behaves - but you know that it is some kind of animal. When you try to find
what some objects have in common and a noun comes to mind, it means you
probably want to use an abstract class.
Let's look more technically on what an interface and an abstract class are:
● An interface is a set of definitions of methods - it does not provide any
implementation (at least in 99% of the cases - see the note about interfaces
change in C# 8.0 at the end of this lecture). It specifies a contract that an
implementing method will have to fulfill. When you implement an interface
in your class, it means you declare that this class will provide all the methods
from this interface. For example, one of the most commonly used interfaces
in C# is ICollection - an interface that defines a set of methods related to
working on collections - methods like Add, Contains, Remove, Clear, etc.
ICollection only defines what methods a collection must provide, but they
are not implemented in the interface itself. The concrete classes
implementing this interface - for example List - provide the
implementations.
● An abstract class is a type that is too - well - abstract for the actual instances
of it to exist. It represents some general category of things. It can have
method implementations, but it can also contain abstract methods -
methods with no bodies, that will have to be implemented in the inheriting
classes. As in the example before, you can imagine an Animal abstract class.
You can't have an object of type Animal - it's always some specific kind of
animal, like a dog or a horse. Animal is just an abstraction over a whole
category of creatures a bit similar to each other (and not similar at all to
plants or fungi).
As you can see you can't create instances of an abstract class:
Before we end this lecture I would like to mention one thing - starting with C# 8.0
interfaces can have methods with bodies. I decided to skip it in this course, as it
exceeds junior level. If you are interested, please see
https://docs.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/default-inter
face-methods-versions
● "Why can't you specify the accessibility modifier for a method defined in
the interface?"
The point of creating an interface is to specify the public contract that a class
implementing it will expose. Modifiers other than "public" would not make
sense. Because of that, the "public" modifier is the default and we don't need
to specify it explicitly.
The Bridge design pattern allows us to split an inheritance hierarchy into a set of
hierarchies, which can then be developed separately from each other. It is the
implementation of the "composition over inheritance" principle, which states that
it is better to introduce new features to a class by extending what this class
contains, instead of extending the inheritance hierarchy.
This all sounds a bit complicated, but let's consider a really simple example: we
have a base class Car with two inheritors: Pickup and Sedan.
That looks very simple for now. But then the application you develop grows, and
there is a need to distinguish electric cars from petrol cars. Let's see how this could
affect the inheritance hierarchy:
The hierarchy grew a lot. We suddenly have seven classes instead of the three we
had before. What if we are asked to add another trait that characterizes a car, like
manual and automatic gear? We would have to create classes like
ManualElectricPickup and AutomaticElectricPickup, and then ManualPetrolPickup
and AutomaticPetrolPickup, and so on, and so forth. That would be an explosion of
classes, completely unmanageable.
What is the alternative? Well, we can use the Bridge design pattern. Instead of
expressing a trait of a Car as another layer in the inheritance hierarchy, we split the
inheritance hierarchy in two. We simply add a new class - like Motor - and then add
two inheritors - ElectricMotor and PetrolMotor. Then, the Car class will contain a
Motor object within. Let's see it in a diagram:
Now, the Car and the Motor are separate entities. We can extend them to our
needs, without affecting one another. Also, adding another entity to the picture
(like ManualGear or AutomaticGear) is not a problem at all - we will simply add
another class hierarchy that represents them.
Let's see this in the C# code. First, the classes representing cars:
Now it should be simple to create any car we want - for example, an electric pickup
with manual gear or a petrol sedan with automatic gear:
Thanks to the Bridge pattern, our inheritance hierarchy is kept simple and clean.
We won't have any problem with adding new characteristics to the Car class. We
can work on each of the families of classes without affecting the other.
Brief summary: "S" in the SOLID principles stands for Single Responsibility
Principle (sometimes referred to as the SRP). This principle states that a class
should be responsible for only one thing. Sometimes the alternative definition is
used: that a class should have no more than one reason to change.
First of all, SOLID is a set of five principles that should be met by well-designed
software.
"S" in the SOLID principles stands for Single Responsibility Principle (sometimes
referred to as the SRP). This principle states that a class should be responsible for
only one thing. Sometimes the alternative definition is used: that a class should
have no more than one reason to change.
`
So this class definitely breaks the Single Responsibility Principle. Let's refactor it. If
one class needs to be responsible for one thing only, we probably need some more
classes:
1. A class that reads the list of people from a database:
This class is responsible only for reading the list of people from the database.
The only reason to change it could have is if the way of reading would change -
for example, the "Name" column in the database would be changed to
"FirstName".
`
Again, this class would only have one reason to change - if the way how the
text is formatted would be changed, for example if we decided to use ";"
instead of a new line as a separator between a particular person's
information.
The same thing here - this class has only one reason to change, for example if
we decided to write to another file format than a text file (in this case it might
be a better idea to just create a new implementation of the IWriter interface).
All those classes are small, cohesive, and clean. It is much easier to read them and
understand what exactly they do.
Now all that's left is to use those classes in the PeopleInformationPrinter class:
`
Some may argue "but hey, this class still does three things! It still reads from the
reader, it still asks the TextFormatter to build the text, and it still writes to a
Writer!". Well, not exactly. This class only orchestrates work of other classes - in
this case with IReader, IPeopleTextFormatter, and IWriter interfaces. They do the
actual work - connecting to the database, writing to the file - and this class only
works as their manager. It has only one reason to change - if the flow of this
process changes, for example, if there is a new requirement to somehow filter the
data after reading it from the database, but before sending it to the
TextFormatter.
Why is the Single Responsibility Principle important and why should we care to
follow it?
● A class responsible for one thing only is smaller, more cohesive, and more
readable
● Such code is reusable - in the example above, the original class would not be
likely to be used in any other context. After the refactoring it is easy to
imagine plenty of other usages for the DatabaseReader or the TextWriter
● Such code is much easier to maintain, as it is much easier to introduce
changes and fixes to a class that only does one thing
● Overall, the development speed will be faster and the number of bugs will
be smaller
Let's summarize. "S" in the SOLID principles stands for Single Responsibility
Principle (sometimes referred to as the SRP). This principle states that a class
should be responsible for only one thing. Sometimes the alternative definition is
used: that a class should have no more than one reason to change.
`
Brief summary: "O" in the SOLID principles stands for Open-Closed Principle
(sometimes referred to as the OCP). This principle states that modules, classes,
and functions should be opened for extension, but closed for modification.
First of all, SOLID is a set of five principles that should be met by well-designed
software.
"O" in the SOLID principles stands for Open-Closed Principle (sometimes referred
to as the OCP). This principle states that modules, classes, and functions should be
opened for extension, but closed for modification. In other words - we should
design the code in a way that if a change is required, we can implement it by adding
new code instead of modifying the existing one.
It might be a bit hard to understand what exactly it means from a practical point of
view, but don't worry - we will take a closer look at this principle in a moment. But
first, let's understand what is the reasoning behind this principle, and why it is so
important. Have you ever heard the expression "The only constant in life is
change"? Those are the words of Ancient Greek philosopher Heraclitus, and they
are surprisingly fitting to the modern problem of software development. When
designing an application, we must always keep in mind that whatever the business
requirements are at the moment, they are very likely to change in the future. When
they do, we want to be able to:
● Introduce the changes quickly and easily
● Don't break any existing functionality
Following the Open-Closed Principle helps us to achieve it. According to this rule,
the code should be:
● Opened - that means, it can be extended so new functionality can be added
● Closed - that means, we shouldn't be forced to modify the existing code to
introduce this piece of functionality
problem in small projects, but the bigger the project, the bigger the impact
of such change might be (not to mention projects that are publicly
accessible and can be used by people all around the world). Whenever
possible, we want to keep backward compatibility.
Modifying the existing code is particularly dangerous when it affects base types.
It's sometimes very hard to anticipate the impact on all the derived types.
Following the Opened-Closed principle mitigates the risk when introducing new
functionality. When no changes are done to the existing code, we are sure it still
works. The project is more stable and less error-prone.
All right. I hope you now see the benefits of following the Open-Closed Principle.
Let's see an example of this principle being broken, and how such a code can be
fixed.
`
This code looks pretty simple. We have two classes representing shapes and an
AreaCalculator class that, well, calculates areas. This design breaks the
Open-Closed Principle. If you are not sure why, imagine what would happen if
there was a new business requirement - for example, to start supporting
Rectangles and Squares. We would have to add new Rectangle and Square classes
(this is fine - we would be adding new classes) but we would also have to modify
the AreaCalculator class. Each time a new shape is introduced, we would need to
modify the AreaCalculator class:
`
Let's refactor this code. Instead of having area calculation logic in the
AreaCalculator class, let's move it to where it belongs - to each of the shapes. To
keep backward compatibility, let's leave the AreaCalculator class, but only as a
proxy to call the methods from each of the shape classes:
`
`
That looks better. Now, if a new shape is needed, the only thing we will have to do
will be adding a new class implementing the IShape interface. The AreaCalculator
class will not be affected, as it now depends on an abstract interface instead of a
concrete class.
Let's consider one more example, that will point out some of the limitations of the
Opened-Closed Principle. Imagine we are creating a system for an ice cream parlor.
They sell some kinds of ice cream (like vanilla, chocolate, strawberry). They noticed
that a huge amount of time is wasted on clients who can't decide which type of ice
cream they want. That's why they asked you to create a mechanism that will
randomly pick the ice cream for the client. Let's see this code:
`
You probably know what the problem is - what if a new type of ice cream is
introduced? We will have to modify the RandomIceCreamGenerator. That's not
right. You might have also noticed that not only the Open-Closed Principle is
violated here, but also the Single Responsibility Principle. This class has two
`
responsibilities - picking a random type of ice cream and creating the ice cream
basing on the type. Let's create a factory whose only responsibility will be to create
the ice cream. You can learn more about Factory Method design pattern in the
"What is the Factory Method design pattern?" lecture.
`
Great. Now this class will not be affected when a new type of ice cream is
introduced… but the IceCreamFactory will be! We will have to add another case to
the switch. That's actually one of the limitations we encounter when following the
Open-Closed Principle. When adding new classes instead of modifying the existing
ones, we still need some kind of toggle mechanism to switch between the original
and extended behavior. The best we can do is to keep the code implementing such
a toggle mechanism in one place - for example a factory class. This way, such
change will be simple and will have a small impact on the application as a whole.
The other limitation is that we can't always predict every possible change, and
sometimes we will simply be forced to modify the existing code to make it meet
the business requirements. We should try to predict the most likely changes that
may be needed, but we can't always do it perfectly. But, trying to predict
everything is also a bad thing. Preparing the code to be modified in every way
possible often leads to overcomplication and introducing premature abstraction.
As with all things, moderation is recommended. Sometimes it is better to introduce
a small modification in the existing code than to spend days on designing advanced
mechanisms and abstractions and find out later that we actually never needed
them.
`
Of course, bug fixing is another case when modification of the code is simply
needed.
Let's summarize. The Open-Closed Principle states that the code should be
opened for extension, but closed for modification. That means, when new business
requirements are implemented, we should be able to do so by adding new classes
instead of modifying existing ones. Following this principle makes the changes
easier to be introduced, and mitigates the risk of causing bugs. There are
reasonable scenarios when simple code modification is still required - mostly to
implement a toggle mechanism between original and extended classes and bug
fixing.
Link: https://bit.ly/3hSRpOq
Link: https://bit.ly/3sC7FsW
Link: https://bit.ly/3HqGR33