Class modifiers in C#
It’s often easy to overlook class modifiers when we’re coding in C#. I’ve found myself regularly simply flagging something as public
for the sake of convenience. Class modifiers don’t end with public
and private
though. Let’s dig into some of the class and property modifiers we find in C# 12.
Public
public
is an access modifier that allows access unrestricted. This class can be instanced anywhere, whether it’s inside the same namespace or not. It’s the simplest of modifiers and often our default.
A simple example of an application that reads a driver’s name:
namespace Streets
{
public class Driver
{
public string Name { get; set; }
}
class Program
{
static void Main()
{
var newDriver = new Driver();
Console.WriteLine("Please enter the driver's name:");
newDriver.Name = Console.ReadLine();
Console.WriteLine($"Driver name: {newDriver.Name}");
}
}
}
We can see how we can easily instantiate the Driver
class and read the Driver’s name.
Output looks as follows:
Please enter the driver's name:
Ricky Bobby
Driver name: Ricky Bobby
Process finished with exit code 0.
Private
private
is another access modifier and the near-antithesis of public
. Classes and properties marked with private are only accessible within their parent class. Outside of that, they are inaccessible even to their derived classes (unlike their friend protected
).
Though it is primarily used on a property level, as I will show you in an example down below, you can also use it on inner (or sub) classes. In other words, you do get private classes but only if they are nested inside other classes.
Let’s make our driver’s details a bit more private through the use of a private setter:
public class Driver
{
public string Name { get; private set; }
}
It’s a minor change but what we’re effectively telling the compiler is that though we can publically view this property, we cannot change it publically - the change needs to happen inside the Driver
class. Once we change it we’ll notice instantly that our original code breaks, we can no longer use the line:
newDriver.Name = Console.ReadLine();
What we do instead is set a public
accessor for the property and use that instead:
namespace Streets
{
public class Driver
{
public string Name { get; private set; }
public void SetName(string newName)
{
Name = newName;
}
}
class Program
{
static void Main()
{
var newDriver = new Driver();
Console.WriteLine("Please enter the driver's name:");
newDriver.SetName(Console.ReadLine());
Console.WriteLine($"Driver name: {newDriver.Name}");
}
}
}
Using the SetName
method we are exposing a way to update the name but within our own control. We could similarly do it via the constructor. Let’s chance the whole property to private
and use the constructor to read the value instead - along with some validation for our driver’s name:
namespace Streets
{
public class Driver
{
public Driver(string name)
{
if (string.IsNullOrEmpty(name))
{
throw new ArgumentNullException(nameof(name));
}
Name = name;
}
private string Name { get; set; }
public string GetName()
{
return Name;
}
}
class Program
{
static void Main()
{
Driver newDriver;
Console.WriteLine("Please enter a valid name:");
while (true)
{
try
{
newDriver = new Driver(Console.ReadLine());
break;
}
catch (ArgumentNullException)
{
Console.WriteLine("Please enter a valid name:");
}
}
Console.WriteLine($"Driver name: {newDriver.GetName()}");
}
}
}
What we’ve done now is implemented a bit of a null guard into the constructor of the Driver
class. If we find a null or an empty string, we throw an ArgumentNullException
which gets picked up in the app. We also introduced a new public GetName
method that will return the name, thus eliminating any public access to the property. Output looks as follows:
Please enter a valid name:
Please enter a valid name:
Please enter a valid name:
Ricky Bobby
Driver name: Ricky Bobby
Process finished with exit code 0.
Protected
protected
is yet another access modifier. Not dissimilar to private
it limits access to it’s derived classes only:
namespace Streets
{
public class Person
{
protected string Name { get; set; }
}
public class Driver : Person
{
public Driver(string name)
{
if (string.IsNullOrEmpty(name))
{
throw new ArgumentNullException(nameof(name));
}
Name = name;
}
public string GetName()
{
return Name;
}
}
class Program
{
static void Main()
{
Driver newDriver;
Console.WriteLine("Please enter a valid name:");
while (true)
{
try
{
newDriver = new Driver(Console.ReadLine());
break;
}
catch (ArgumentNullException)
{
Console.WriteLine("Please enter a valid name:");
}
}
Console.WriteLine($"Driver name: {newDriver.GetName()}");
}
}
}
Simply enough, if we made the property private
, it would be inaccessible from the Driver
derivative. This is a great way for sharing access within inheritance while keeping fields “protected”.
Internal
internal
is the last of our basic access modifiers. It dictates that access is shared but only within the same assembly (dll). This is to say, if you’re coding a library and you have a field you don’t want to be shared when that library is referenced in another project, you can flag it with internal
.
Let’s add another assembly, called Council
. It’s the Council’s job to keep track of how many drivers are on the street but they don’t need to know the names of those drivers. We change our original Driver
class as follows:
// Streets.dll assembly
namespace Streets
{
...
public class Driver : Person
{
public Driver(string name)
{
if (string.IsNullOrEmpty(name))
{
throw new ArgumentNullException(nameof(name));
}
Name = name;
}
internal string GetName()
{
return Name;
}
}
...
}
and the Council
assembly can be implemented as below:
// Council.dll assembly
namespace Council
{
class Program
{
static void Main()
{
Driver[] drivers = GetAllDrivers();
foreach (var driver in drivers)
{
// Produces error CS0122
Console.WriteLine(driver.GetName());
}
}
}
}
By changing the GetName
method to internal it becomes declaratively public
but only within it’s own assembly. It is inaccessibly to the Council
assembly.
Protected Internal
protected internal
is a more complex access modifier - allowing derived classes, in other assemblies, to access the property. That is to say, if we moved our person class to the Council
assembly we could mark Name
as protected internal
and still use it in our derived class:
// Council.dll assembly
namespace Council
{
...
public class Person
{
protected internal string Name { get; set; }
}
...
}
We can now simply reference the Council
assembly in our Streets
assembly and we’d have no errors.
Private Protected
The reverse of protected internal
, private protected
limits access within derived types within it’s own assembly only. In other words, if we changed our Council
assembly as follows:
namespace Council
{
...
public class Person
{
private protected internal string Name { get; set; }
}
...
}
We’d not only get an Error CS0122
in our Streets
assembly, but we’d also be unable to directly access that value outside of derived classes within the Council
assembly.
Abstract
abstract
is a decorater indicating a class that:
- Cannot be instantiated.
- May contain abstract methods and accessors (not properties).
- A non-abstract class derived from an abstract class must include implementations of abstract methods and accessors. Imagine a class designed to describe derived classes. For instance, let’s assign our drivers a vehicle. We know what a vehicle should look like in that it should have some wheels and be able to honk it’s horn:
public abstract class Vehicle
{
public abstract int GetNumberOfWheels();
public string Horn()
{
return "Beep beep";
}
}
Seems like it’s descriptive even though we make the assumption that every vehicle’s horn sounds the same. We can now create two derivatives of Vehicle
:
public class MotorBike : Vehicle
{
}
public class Car : Vehicle
{
}
We are instantly greeted by Abstract inherited member 'int Streets.Vehicle.GetNumberOfWheels()' is not implemented
but that’s easy enough to solve:
public class MotorBike : Vehicle
{
public override int GetNumberOfWheels() => 2;
}
public class Car : Vehicle
{
public override int GetNumberOfWheels() => 4;
}
Notice the override
keyword. It indicates that this method is to override an abstract
or virtual
method or accessor. Now, let’s give our Driver
class the ability to own more than one Vehicle
:
public class Driver : Person
{
public Driver(string name)
{
if (string.IsNullOrEmpty(name))
{
throw new ArgumentNullException(nameof(name));
}
Name = name;
}
private List<Vehicle> Vehicles { get; } = [];
public void AddVehicles(IEnumerable<Vehicle> vehicles)
{
Vehicles.AddRange(vehicles);
}
public override string ToString()
{
var sb = new StringBuilder();
sb.AppendLine($"Driver name: {Name}");
foreach (var vehicle in Vehicles)
{
sb.AppendLine($"Vehicle type: {vehicle.GetType().Name}");
sb.AppendLine($"Number of wheels: {vehicle.GetNumberOfWheels()}");
sb.AppendLine($"Horn sound: {vehicle.Horn()}");
}
return sb.ToString();
}
}
I’ve also overridden the ToString
method to give us all the details for the driver. Now, all we have to do in our Main
method is the following:
newDriver.AddVehicles([
new Car(),
new Car(),
new MotorBike()]);
Console.Write(newDriver.ToString());
Output looks as follows:
Please enter a valid name:
Ricky Bobby
Driver name: Ricky Bobby
Vehicle type: Car
Number of wheels: 4
Horn sound: Beep beep
Vehicle type: Car
Number of wheels: 4
Horn sound: Beep beep
Vehicle type: MotorBike
Number of wheels: 2
Horn sound: Beep beep
Process finished with exit code 0.
A similar keyword to abstract
is virtual
but we’ll come back to that later.
Async
The async
decorator goes hand in hand with await
. It indicates that a method is asynchronous. It is used to do potentially long-running calls without blocking the caller’s thread.
For instance, if you’re making a call to an API, there’s no point the thread you used should sit there and do nothing until you get a response back - it can continue working thanks to async
methods.
This is a much bigger topic to discuss and not something I’ll cover in this article, perhaps in a later one. In the meanwhile, you can always go and read up on it here: https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/
Const
const
is an age old keyword indicating a value is constant and once set will remain at the set value. It makes the value immutable. Should you try to change the value, the compiler will kick up a fuss and prevent it from happening:
class Program
{
static void Main()
{
const int retryLimit = 3;
var retryAttempts = 0;
Driver newDriver;
Console.WriteLine("Please enter a valid name:");
while (retryAttempts < retryLimit)
{
try
{
newDriver = new Driver(Console.ReadLine());
break;
}
catch (ArgumentNullException)
{
retryLimit++;
Console.WriteLine("Please enter a valid name:");
}
}
newDriver.AddVehicles([
new Car(),
new Car(),
new MotorBike()]);
Console.Write(newDriver.ToString());
}
}
In the above code, lo-and-behold, I made a mistake, instead of incrementing retryAttempts
I’ve tried to increment retryLimit
which would lead me to a bad loop. Fortunately, past me was clever enough to implement it as a const and the compiler kicked up a fuss so I could fix it as below:
class Program
{
static void Main()
{
const int retryLimit = 3;
var retryAttempts = 0;
Driver newDriver = null;
Console.WriteLine("Please enter a valid name:");
while (retryAttempts < retryLimit)
{
try
{
newDriver = new Driver(Console.ReadLine());
break;
}
catch (ArgumentNullException)
{
retryAttempts++; // Compiler error
Console.WriteLine("Please enter a valid name:");
}
}
if (newDriver == null)
return;
newDriver.AddVehicles([
new Car(),
new Car(),
new MotorBike()]);
Console.Write(newDriver.ToString());
}
}
Override
We’ve come across override
already as part of the abstract
modifier. We’ll also use it below as part of the virtual
modifier. It allows overriding an abstract
or virtual
member.
Readonly
The readonly
modifier works similarly to const
. While const
is a compile-time constant, readonly
is a runtime constant. This means readonly
variables can be instanced, static, or reference types while const
variables are implicitly static and limited to basic types.
Sealed
sealed
forms part of inheritance. When a class is specified as sealed
it means that no other class can be derived from it. We can also use the sealed
modifier on overrides to prevent derivatives from overriding specific methods or accessors. Let’s implement an SUV
class, derived from our Car
class, only we never want a car that has more than 4 wheels so we seal it:
public class Car : Vehicle
{
public sealed override int GetNumberOfWheels() => 4;
}
public class SUV : Car
{
// Generates an error - cannot override inherited method
public override int GetNumberOfWheels() => base.GetNumberOfWheels() + 2;
}
Now the SUVs will be forced to have 4 wheels. If you want to drive a car with 6, you’ll probably have to register it as a lorry instead.
Static
static
is a fairly common modifier. It is used to declare static
members that belong to the type itself, instead of an instance of the type. You may have noticed, our Main
method is static, meaning it is a method on the Program
type and we don’t need to instance Program
to access it.
When we make a method or accessor static
it will not have access to any instanced properties of the class. For instance, we can make our Horn()
method static and everything will still work fine, that’s simply because it doesn’t need to access anything instanced:
public abstract class Vehicle
{
public abstract int GetNumberOfWheels();
public static string Horn()
{
return "Beep beep";
}
}
Unsafe
unsafe
is less frequently used but it allows the use of pointers. A very simple example, straight from learn.microsoft.com would be the below:
class UnsafeTest
{
// Unsafe method: takes pointer to int.
unsafe static void SquarePtrParam(int* p)
{
*p *= *p;
}
unsafe static void Main()
{
int i = 5;
// Unsafe method: uses address-of operator (&).
SquarePtrParam(&i);
Console.WriteLine(i);
}
}
Which would output the square of 5 which is 25. Generally, when something is marked as unsafe
it won’t compile unless you specify the -unsafe
parameter to the compiler https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-options/language#allowunsafeblocks. Unsafe code is not verifiable by the common language runtime and I have never seen it used at large scale.
Virtual
Finally we get to the virtual
keyword. Not dissimilar to abstract
a virtual
method or accessor can be overridden as well but it allows us a base method, property, or accessor (unlike an abstract variant).
Let’s modify our Horn()
method:
public virtual string Horn()
{
return "Beep beep";
}
What we’re telling the compiler here is if a derivative of this class wants, it can override Horn()
but if it’s not overridden, do what the base class says.
Now we can override this in our derivatives:
public class MotorBike : Vehicle
{
public override int GetNumberOfWheels() => 2;
public override string Horn()
{
return "Beep!";
}
}
public class Car : Vehicle
{
public sealed override int GetNumberOfWheels() => 4;
public override string Horn()
{
return "Honk!";
}
}
public class SUV : Car
{
}
And we modify our Main
to add an SUV to our driver’s collection:
static void Main()
{
const int retryLimit = 3;
var retryAttempts = 0;
Console.WriteLine("Please enter a valid name:");
var newDriver = new Driver(Console.ReadLine());
while (retryAttempts < retryLimit)
{
try
{
newDriver = new Driver(Console.ReadLine());
break;
}
catch (ArgumentNullException)
{
retryAttempts++; // Compiler error
Console.WriteLine("Please enter a valid name:");
}
}
newDriver.AddVehicles([
new Car(),
new SUV(),
new MotorBike()]);
Console.Write(newDriver.ToString());
}
The output now looks as follows:
Please enter a valid name:
Ricky Bobby
Driver name: Ricky Bobby
Vehicle type: Car
Number of wheels: 4
Horn sound: Honk!
Vehicle type: SUV
Number of wheels: 4
Horn sound: Honk!
Vehicle type: MotorBike
Number of wheels: 2
Horn sound: Beep!
It’s worth noting that SUV
gets the override
implementation of Car
in this case.
Volatile
The volatile
keyword is useful for working in a multi-threaded environment where some threads might be executing at the same time. When you declare a field as volatile
you’re telling the compiler to exclude that piece from certain types of optimisation. You can see more in my write-up on the Singleton Design Pattern.
Honorable mentions
I haven’t made mention of the extern
or event
modifiers as they’re extremely niche and I’ve used them so rarely (if ever) that I would just be copy-pasting online documentation. I would prefer to spend some more time learning about them before returning and updating this post.
Conclusion
I hope that with the knowledge above you’re armed a bit better to create cleaner, more maintainable code.