Skip to main content

Types in C#

Primitive Types

In C#, we have data types similar to those in C. Basic or primitive types are declared and used as in C, and a few examples are:

  • short/ushort
  • int/uint
  • long/ulong
  • float
  • double
  • char
  • byte
  • bool

These primitive types are copied by value, just like in C. For this reason, they fall under the category of value types as opposed to reference types which are copied by reference, similar to pointers in C. However, in C#, pointers are not explicitly present. A major advantage that C# has over C or C++ is that programmers do not need to manage memory allocated on the heap using techniques like RAII (Resource Acquisition Is Initialization). The runtime takes care of memory management. We'll see more about this with classes and structures.

In C#, it's important to note that all variables must be initialized. Value types cannot be initialized with null, but reference types can, as they are references to memory. If we want a variable to allow a null value, we can append "?" to the type during declaration. Value types will then become reference types. For instance, while int is a value type, int? is a reference type. These types are also known as nullable types.

Classes and Structures

Similar to C, we can create composite types in C#. In C#, there are structures and classes. The difference is that structures are value types and reside on the stack or in memory allocated on the heap when encapsulated by reference types. On the other hand, classes are reference types and reside on the heap. Regardless of whether we're talking about structures or classes, instances of both are objects, hence the name of the paradigm. An object encapsulates both data and code, which can be observed by how objects are declared.

public class Adder
{
private int _x; // We've declared a field here to hold an int
private int _y; // These fields are private, meaning functions/structs/classes outside cannot access this field

/* We've declared a parameterless constructor to initialize the structure.
* It's not necessary to declare a parameterless constructor unless we have another constructor
* with parameters declared; otherwise, it exists implicitly.
*/
public Adder()
{
Console.WriteLine("Constructor for {0} was called", nameof(Adder));

_x = 0; // In C#, it's automatically initialized with the default value, so it's redundant to initialize here
_y = 0;
}
public Adder(int x, int y)
{
Console.WriteLine("Constructor for {0} was called with {1} and {2}", nameof(Adder), x, y);

_x = x;
this._y = y; /* We can refer to the current object using the "this" keyword if there's
* ambiguity about which variable or function we're referring to in this context.
*/
}

// Here, we have a method which is a function that has access to the instance variables of the object
public int Add()
{
return this._x + this._y;
}

/* Every object is of type "object," which has a ToString method to convert any object to a string.
* Here, it's overridden. We'll see what method overriding means in inheritance.
*/
public override string ToString() => $"{nameof(Adder)}({_x}, {_y})";

public void SetValues(int x, int y)
{
_x = x;
_y = y;
}
}

public struct Multiplier
{
private int _x;
private int _y;

public Multiplier(int x, int y)
{
Console.WriteLine("Constructor for {0} was called with {1} and {2}", nameof(Multiplier), x, y);

_x = x;
_y = y;
}

public int Multiply() => _x * _y; // We can simplify method declaration using => when directly returning a result

public override string ToString() => $"{nameof(Multiplier)}({_x}, {_y})";

public void SetValues(int x, int y)
{
_x = x;
_y = y;
}
}

Adder adder = new Adder(1, 2); // Creating a new instance of the class using the "new" keyword
var sum = adder.Add(); // We can use "var" to declare variables when the type is known

Console.WriteLine("The result for {0} is {1}", adder, sum);

Multiplier multiplier = new(3, 4); // Creating a new instance of the struct using the "new" keyword; constructor name can be omitted
var prod = multiplier.Multiply();

Console.WriteLine("The result for {0} is {1}", multiplier, prod);

As seen, any object is instantiated using the new keyword by invoking one of the class's constructors. Constructors are used to initialize objects with data, and there can be multiple constructors with different signatures, similar to C++ or Java. Objects are not deallocated manually. For value types that reside on the stack, they are deallocated when leaving a scope, which means when the stack frame is destroyed. For heap-allocated items, the runtime keeps track of references, and if they are no longer reachable from the stack, they are automatically deallocated by a runtime routine called the garbage collector when deemed necessary. Structures are used when small, short-lived composite types on the stack are needed; otherwise, classes are used, and in most cases, classes are preferred.

Enum

Another value type is enums; an enum is defined as in C or C++.

public enum MyEnum
{
First = 0, // Numeric values can also be assigned to enums
Second = 1,
Third = 2
}

Strings

In addition to primitives, another fundamental type is strings, which are found in C# under the string type, and it is a reference type.

var hello = "Hello"; // A character string is declared and initialized with a value
var helloWorld = $"{hello} world"; // Another string can be created by interpolation using "$" in front
var finalHelloWorld = helloWorld + "!"; // Character strings can be concatenated using the "+" operator

Console.WriteLine(finalHelloWorld);

Arrays

An array is a special type that is initialized like a class with the new keyword and represents a vector of values with a given length at initialization. An array can be a value type or a reference type depending on whether it's an array of value types or an array of reference types. Arrays with more than 2 dimensions can be declared, but it is not recommended. In general, there are other more useful types that will be utilized.

int[] a = new int[] { 1, 2, 3 }; // Creating an array of 3 elements with initialization
int[] b = new int[a.Length]; // Creating an array of 3 elements with values initialized to the default value
Array.Copy(a, b, a.Length); // We can use utility functions from the standard library to perform operations on arrays

int[,] mat = { { 1, 2, 3, 4 }, { 5, 6, 7, 8 }, { 9, 10, 11, 12 } }; // Creating a 3x4 matrix
int[][] jaggedArray = { new [] { 1, 2, 3, 4 }, new [] { 5, 6, 7 }, new [] { 8, 9 } }; // Creating a jagged array with varying row lengths

for (var i = 0; i < b.Length; ++i) // For an array, we conveniently have the Length property to store its length
{
b[i] *= 2; // We can write to an array similar to how it's done in C.
}

Console.WriteLine("Printing the array:");

for (var i = 0; i < b.Length; ++i)
{
Console.WriteLine("Got array[{0}] = {1}", i, b[i]); // We can format the output when writing to the console
}

Console.WriteLine("Printing the matrix:");

for (var i = 0; i < mat.GetLength(0); ++i)
{
for (var j = 0; j < mat.GetLength(1); ++j)
{
Console.WriteLine("Got matrix[{0}, {1}] = {2}", i, j, mat[i, j]);
}
}

Console.WriteLine("Printing the jagged array:");

for (var i = 0; i < jaggedArray.Length; ++i)
{
for (var j = 0; j < jaggedArray[i].Length; ++j)
{
Console.WriteLine("Got jaggedArray[{0}, {1}] = {2}", i, j, jaggedArray[i][j]);
}
}