Data Types in C#: From Basics to Advanced Concepts

by Gautham Pai

1. Basic Data Types in C#

Numeric Types

Numeric types like int are used for whole numbers. Here’s an example:

int i = 10;
int j = 20;
Console.WriteLine(i + j);  // Output: 30
Console.WriteLine(typeof(int));  // Output: System.Int32

Boolean Types

Boolean types represent true or false values:

bool IsPresent = true;
Console.WriteLine(typeof(bool));  // Output: System.Boolean
Console.WriteLine(IsPresent);  // Output: True

Using var

var is a shorthand for declaring variables with inferred types:

var i = 10;
Console.WriteLine(i);  // Output: 10

However, you cannot assign a value of a different type:

i = "Hello";  // Error: Cannot implicitly convert type 'string' to 'int'

Dynamic Typing

The dynamic type allows changing the type at runtime:

dynamic i = 10;
Console.WriteLine(i);  // Output: 10
i = "Hello";
Console.WriteLine(i);  // Output: Hello

String Types

Strings represent text. Example:

string Name = "John";
Console.WriteLine(typeof(string));  // Output: System.String
Console.WriteLine(Name);  // Output: John

String Interpolation

You can use string interpolation to include variables in strings:

var Name = "John";
var Age = 10;
Console.WriteLine($"The person {Name} is {Age} years old");

Verbatim Strings

Verbatim strings ignore escape sequences:

var s = @"Hello\World";
Console.WriteLine(s);  // Output: Hello\World

2. Working with Collections in C#

Arrays

Arrays are fixed-size collections:

var nums = new[] { 1, 2, 3 };
Console.WriteLine(nums[0]);  // Output: 1
nums[1] = 5;
foreach (var num in nums)
{
    Console.WriteLine(num);  // Output: 1, 5, 3
}

Lists

Lists are dynamic-sized collections:

IList<int> nums = new List<int>();
nums.Add(1);
nums.Add(2);
nums.Add(3);
Console.WriteLine(nums[0]);  // Output: 1
nums[0] = 15;
nums.RemoveAt(1);
foreach (var num in nums)
{
    Console.WriteLine(num);  // Output: 15, 3
}

Dictionaries

Dictionaries store key-value pairs:

var countrywiseParticipants = new Dictionary<string, int>
{
    { "India", 20 },
    { "US", 25 },
    { "UK", 30 }
};
Console.WriteLine($"Participants from India: {countrywiseParticipants["India"]}");
countrywiseParticipants.Remove("US");
foreach(var countrywiseParticipant in countrywiseParticipants)
{
    Console.WriteLine($"{countrywiseParticipant.Key} - {countrywiseParticipant.Value}");
}

3. Object-Oriented Programming in C#

Basic Class

Defining a simple class with getters and setters:

public class Contact
{
    private string name;

    public string GetName()
    {
        return name;
    }

    public void SetName(string name)
    {
        this.name = name;
    }
}

public class Program
{
    public static void Main()
    {
        var c = new Contact();
        c.SetName("John");
        Console.WriteLine(c.GetName());  // Output: John
    }
}

Constructor and Properties

Using constructors and properties for initialization:

public class Contact
{
    public string Name { get; set; }

    public Contact(string name)
    {
        this.Name = name;
    }
}

public class Program
{
    public static void Main()
    {
        var c = new Contact("John");
        Console.WriteLine(c.Name);  // Output: John
        c.Name = "George";
        Console.WriteLine(c.Name);  // Output: George
    }
}

Overriding Methods

Overriding the ToString method:

public class Contact
{
    public string Name { get; set; }

    public Contact(string name)
    {
        this.Name = name;
    }

    public override string ToString()
    {
        return $"Contact({Name})";
    }
}

public class Program
{
    public static void Main()
    {
        var c = new Contact("John");
        Console.WriteLine(c);  // Output: Contact(John)
        c.Name = "George";
        Console.WriteLine(c);  // Output: Contact(George)
    }
}

Inheritance

Using inheritance to extend functionality:

public class Person
{
    public string Name { get; set; }

    public Person(string name)
    {
        Console.WriteLine("The Person constructor is called");
        Name = name;
    }

    public override string ToString()
    {
        return $"Person({Name})";
    }
}

public class Employee : Person
{
    public Employee(string name) : base(name)
    {
        Console.WriteLine("The rest of the Employee constructor runs now");
    }
}

public class Program
{
    public static void Main()
    {
        new Employee("John");
    }
}

4. Interfaces in C#

Basic Interface Implementation

Defining and implementing interfaces:

interface IHello
{
    void SayHello();
}

public class Hello : IHello
{
    public void SayHello()
    {
        Console.WriteLine("Hello");
    }
}

public class Program
{
    public static void Main()
    {
        IHello myHello = new Hello();
        myHello.SayHello();  // Output: Hello
    }
}

Multiple Interfaces

Implementing multiple interfaces with different methods:

interface IHello
{
    void SayHello();
}

interface IHello2
{
    void SayHello(string name);
}

public class Hello : IHello, IHello2
{
    public void SayHello()
    {
        Console.WriteLine("Hello");
    }

    public void SayHello(string name)
    {
        Console.WriteLine($"Hello {name}");
    }
}

public class Program
{
    public static void Main()
    {
        var h = new Hello();
        h.SayHello();          // Output: Hello
        h.SayHello("George");  // Output: Hello George
    }
}

Explicit Interface Implementation

Handling methods with the same signature in different interfaces:

interface IHello
{
    void SayHello();
}

interface IHello2
{
    void SayHello();
}

public class Hello : IHello, IHello2
{
    void IHello.SayHello()
    {
        Console.WriteLine("IHello's Hello");
    }

    void IHello2.SayHello()
    {
        Console.WriteLine("IHello2's Hello");
    }
}

public class Program
{
    public static void Main()
    {
        var h = new Hello();
        ((IHello)h).SayHello();     // Output: IHello's Hello
        ((IHello2)h).SayHello();    // Output: IHello2's Hello
    }
}

5. Understanding Value Equality vs Reference Equality in C#

When working with objects in C#, it’s crucial to understand the difference between value equality and reference equality. This concept determines how two objects are compared, either by their memory location or by their data.

Reference Equality (== Operator)

The == operator in C# checks if two objects refer to the same location in memory. If they do, they are considered equal. Otherwise, they are not, even if the data they hold is identical.

Example 1: Different Objects in Memory

class Contact
{
  public string? Name { get; set; }
}

public class Program
{
  public static void Main()
  {
    Contact c1 = new Contact { Name = "John" };
    Contact c2 = new Contact { Name = "John" };
    Console.WriteLine(c1 == c2); // Output: False
  }
}

In this example, c1 and c2 are two different objects in memory, so c1 == c2 returns false.

Example 2: Same Object Reference

class Contact
{
  public string? Name { get; set; }
}

public class Program
{
  public static void Main()
  {
    Contact c1 = new Contact { Name = "John" };
    Contact c2 = c1;
    Console.WriteLine(c1 == c2); // Output: True
  }
}

Here, c2 is a reference to c1, meaning they are the same object in memory. Therefore, c1 == c2 returns true.

Value Equality (.Equals Method)

The .Equals method checks if two objects have the same data. By default, it behaves like the == operator unless overridden.

Example 1: Default .Equals Implementation

class Contact
{
  public string? Name { get; set; }
}

public class Program
{
  public static void Main()
  {
    Contact c1 = new Contact { Name = "John" };
    Contact c2 = new Contact { Name = "John" };
    Console.WriteLine(c1.Equals(c2)); // Output: False
  }
}

Without overriding, .Equals checks for reference equality, so c1.Equals(c2) returns false.

Example 2: Custom .Equals Implementation

class Contact
{
  public string? Name { get; set; }

  public override bool Equals(object? obj)
  {
    if (obj is Contact)
    {
      return Name == ((Contact)obj).Name;
    }
    return this == obj;
  }
}

public class Program
{
  public static void Main()
  {
    Contact c1 = new Contact { Name = "John" };
    Contact c2 = new Contact { Name = "John" };
    Console.WriteLine(c1.Equals(c2)); // Output: True
  }
}

By overriding .Equals, we can compare the Name properties, so c1.Equals(c2) returns true.

Understanding HashCodes

A hashcode is a numerical value used to identify objects during operations like inserting them into a hash table. Even if two objects are equal in terms of their content, their hashcodes can differ unless overridden.

Example: Comparing Hashcodes

class Contact
{
  public string? Name { get; set; }
}

public class Program
{
  public static void Main()
  {
    Contact c1 = new Contact { Name = "John" };
    Contact c2 = new Contact { Name = "John" };
    Contact c3 = c1;

    Console.WriteLine($"Hashcode of c1: {c1.GetHashCode()}");
    Console.WriteLine($"Hashcode of c2: {c2.GetHashCode()}");
    Console.WriteLine($"Hashcode of c3: {c3.GetHashCode()}");

    c2.Name = "John Doe";
    Console.WriteLine($"Hashcode of c2: {c2.GetHashCode()}");
  }
}

In this example:

  • c1 and c2 initially have different hashcodes.
  • c3 shares the same hashcode as c1 because it refers to the same object.
  • Changing the name of c2 does not change its hashcode.

Passing Objects to Methods

When you pass an object to a method, the method receives a reference to the object. Changes made to the object within the method are reflected outside the method because the reference points to the same memory location.

Example: Modifying an Object in a Method

class Contact
{
  public string? Name { get; set; }
}

public class Program
{
  private static void modifyName(Contact c, string name)
  {
    c.Name = name;
  }
  
  public static void Main()
  {
    Contact c1 = new Contact { Name = "John" };
    modifyName(c1, "John Doe");
    Console.WriteLine(c1.Name); // Output: John Doe
  }
}

Swapping Objects

Swapping two object references within a method does not swap the original objects’ references outside the method.

Example: Swapping References

class Contact
{
  public string? Name { get; set; }
}

public class Program
{
  private static void swapContacts(Contact x1, Contact x2)
  {
    Contact tempContact = x1;
    x1 = x2;
    x2 = tempContact;
  }

  public static void Main()
  {
    Contact c1 = new Contact { Name = "John" };
    Contact c2 = new Contact { Name = "George" };
    swapContacts(c1, c2);
    Console.WriteLine(c1.Name); // Output: John
  }
}

In this case, c1 and c2 are not swapped outside the method since the method only swaps the references locally.

HashCode Consistency

The hashcode of an object remains consistent between the caller and the callee when passed to a method.

Example: Hashcode in Caller and Callee

class Contact
{
  public string? Name { get; set; }
}

public class Program
{
  static void PrintHashCode(Contact c)
  {
    Console.WriteLine(c.GetHashCode());
  }

  public static void Main()
  {
    Contact c1 = new Contact { Name = "John" };
    Console.WriteLine(c1.GetHashCode());
    PrintHashCode(c1);
  }
}

The hashcode printed in both the Main method and the PrintHashCode method will be the same, as they refer to the same object.

6. Understanding Structs in C#

What is a Struct?

In C#, a struct is a value type that is stored on the stack, unlike reference types which are stored on the heap. Structs are particularly useful when you need a lightweight object that does not require the overhead of a class.

Defining a Struct

You can define a struct using the struct keyword. Below is an example of a simple Contact struct:

struct Contact
{
  public string? Name { get; set; }

  // We can have methods in the struct just like classes
  public void PrintName()
  {
    Console.WriteLine($"Contact.PrintName: {Name}");
  }
}

In the example above, the Contact struct has a property Name and a method PrintName.

Working with Structs

When you assign one struct to another, it is copied by value. This means that modifying the copy does not affect the original struct.

public class Program
{
  static void ChangeName(Contact c, string name)
  {
    Console.WriteLine(c.GetHashCode());
    c.Name = name;
  }

  public static void Main()
  {
    Contact c1 = new Contact { Name = "John" };
    Contact c2 = new Contact { Name = "John" };
    
    // When we assign a struct object to another variable, it's a copy by value
    Contact c3 = c1;
    Console.WriteLine(c1.Equals(c2));
    Console.WriteLine($"Hashcode of c1: {c1.GetHashCode()}");
    Console.WriteLine($"Hashcode of c2: {c2.GetHashCode()}");
    Console.WriteLine($"Hashcode of c3: {c3.GetHashCode()}");

    // Invoking the methods of the struct
    c1.PrintName();

    // Modifying the fields, modifies its hashcode
    c1.Name = "John Doe";
    Console.WriteLine($"Hashcode of c1: {c1.GetHashCode()}");

    // Modifying c1 does not modify c3, because it's a different copy
    Console.WriteLine(c3.Name);
    Console.WriteLine($"Hashcode of c3: {c3.GetHashCode()}");

    // Passing a struct to a method passes it by value, so changes are not reflected outside
    ChangeName(c1, "John Doe");
    Console.WriteLine(c1.Name);
  }
}

Limitations of Structs

  • Equality Comparison: You cannot use the == operator with structs unless you override it.

7. Readonly Structs

Readonly Fields in Structs

You can make fields in a struct readonly, meaning they can only be assigned during the struct's initialization and cannot be modified later.

struct Contact
{
  public readonly string? Name { get; }

  public Contact(string? name)
  {
    Name = name;
  }

  // This will throw an error because Name is readonly
  void SetName(string? name)
  {
    this.Name = name;
  }

  public void PrintName()
  {
    Console.WriteLine($"Contact.PrintName: {Name}");
  }
}

Mixing Readonly and Writable Fields

It's possible to have a mix of readonly and writable fields within a struct.

struct Contact
{
  public readonly string? Name { get; }
  public string? Email { get; set; }

  public Contact(string? name)
  {
    Name = name;
  }
}

Readonly Structs

When an entire struct is marked as readonly, all its fields must be readonly as well.

readonly struct Contact
{
  public readonly string? Name { get; }
  public readonly string? Email { get; }

  public Contact(string? name)
  {
    Name = name;
  }
}

Examples in the .NET Framework

Many primitive data types, such as int, are readonly structs.

public readonly struct Int32
{
  // Structure of Int32
}

8. Enums in C#

What is an Enum?

An enum (short for enumeration) is a special value type that allows you to define a group of named constants.

enum Color
{
  Red,
  Green,
  Blue
}

public class Program
{
  public static void Main()
  {
    Color color = Color.Blue;
    Console.WriteLine(color);
    Console.WriteLine((int)color);
  }
}

In the example above, Color is an enum with three possible values: Red, Green, and Blue. You can cast an enum to its underlying integer value.

9. Records in C#

What is a Record?

A record is a reference type that provides built-in functionality for value equality, meaning two records with the same values are considered equal.

record Contact
{
  public string? Name { get; set; }

  // We can have methods in records as well
  public void PrintName()
  {
    Console.WriteLine($"Contact.PrintName: {Name}");
  }
}

Working with Records

Records allow you to use the == operator for equality checks, unlike structs.

public class Program
{
  public static void Main()
  {
    Contact c1 = new Contact { Name = "John" };
    c1.PrintName();
    Contact c2 = new Contact { Name = "John" };

    Console.WriteLine($"Hashcode of c1: {c1.GetHashCode()}");
    Console.WriteLine($"Hashcode of c2: {c2.GetHashCode()}");
    Console.WriteLine(c1 == c2);  // == works with records
  }
}

10. Handling Exceptions in C#

Exception handling is crucial for building robust applications. This section covers how to throw and catch exceptions in C#.

Basic Exception Handling

  1. Throwing an exception:

    The following code demonstrates a method that throws an exception when an error occurs:

    public class Program
    {
        public static void Foo()
        {
            throw new Exception("Exception occurred!");
        }
    
        public static void Main()
        {
            Foo();
        }
    }
    

    If the exception is not handled, it will result in a runtime error.

  2. Catching an exception:

    To handle the exception, wrap the method call in a try-catch block:

    public class Program
    {
        public static void Main()
        {
            try
            {
                Foo();
            }
            catch (Exception)
            {
                Console.WriteLine("Exception occurred when calling Foo");
            }
        }
    }
    
  3. Accessing exception details:

    You can access the exception details using the Exception object:

    public class Program
    {
        public static void Main()
        {
            try
            {
                Foo();
            }
            catch (Exception e)
            {
                Console.WriteLine($"Exception occurred when calling Foo. Callee's message: {e.Message}");
            }
        }
    }
    

Custom Exceptions

Custom exceptions allow you to create specific error types for your application.

  1. Creating a custom exception:

    public class MyException : Exception
    {
        public MyException(string message) : base(message)
        {
        }
    }
    
  2. Using the custom exception:

    public class Program
    {
        public static void Main()
        {
            try
            {
                Foo();
            }
            catch (MyException e)
            {
                Console.WriteLine($"Exception occurred when calling Foo. Callee's message: {e.Message}");
            }
        }
    
        public static void Foo()
        {
            throw new MyException("Exception occurred!");
        }
    }
    

Multiple Catch Blocks

You can use multiple catch blocks to handle different types of exceptions:

public class NegativeNumberException : Exception
{
    public NegativeNumberException(string message) : base(message)
    {
    }
}

public class Program
{
    public static void Main()
    {
        try
        {
            SendOnlyPositiveNumber(-10);
        }
        catch (NegativeNumberException e)
        {
            Console.WriteLine(e.Message);
        }
        catch (Exception e)
        {
            Console.WriteLine(e.Message);
        }
        Console.WriteLine("End of program");
    }

    public static int SendOnlyPositiveNumber(int x)
    {
        if (x < 0)
        {
            throw new NegativeNumberException($"Number {x} < 0");
        }
        return x;
    }
}

The finally Block

The finally block is executed regardless of whether an exception is thrown:

public class Program
{
    public static void Main()
    {
        try
        {
            AnotherMethod(10);
            AnotherMethod(-10);
        }
        catch
        {
            Console.WriteLine("The catch in the Main method");
        }
    }

    public static void AnotherMethod(int x)
    {
        try
        {
            var y = SendOnlyPositiveNumber(x);
            Console.WriteLine(y);
        }
        catch (NegativeNumberException e)
        {
            Console.WriteLine(e.Message);
            throw;
        }
        finally
        {
            Console.WriteLine("finally in AnotherMethod");
        }
    }

    public static int SendOnlyPositiveNumber(int x)
    {
        if (x < 0)
        {
            throw new NegativeNumberException($"Number {x} < 0");
        }
        return x;
    }
}

Exception Filters

Exception filters allow you to catch exceptions based on certain conditions:

public class Program
{
    public static void Main()
    {
        try
        {
            Foo(0);
        }
        catch (Exception e) when (e.Message.Contains(">"))
        {
            Console.WriteLine($"First block: {e.Message}");
        }
        catch (Exception e)
        {
            Console.WriteLine($"Another block: {e.Message}");
        }
    }

    public static void Foo(int num)
    {
        if (num < 10)
            throw new Exception("num < 10");
        else
            throw new Exception("num >= 10");
    }
}

11. Working with Nullable and Non-Nullable Types

C# provides mechanisms to handle null values effectively.

Nullable Properties

A property can be made nullable by appending a question mark (?) to its type:

public class Contact
{
    public string? Name { get; set; }
}

Handling Null Values

To safely access methods of possibly null valued fields:

  1. Using conditional operators:

    var contactNameInLowerCase = contact.Name == null ? null : contact.Name.ToLower();
    
  2. Using null-conditional operator (?.):

    Console.WriteLine($"The contact's name in lowercase is: {contact.Name?.ToLower()}");
    
  3. Using null-coalescing operator (??):

    Console.WriteLine($"The contact's name in lowercase is: {contact.Name?.ToLower() ?? "unknown"}");
    

    This is a shorthand for:

    var contactNameInLowerCase = contact.Name == null ? "unknown" : contact.Name.ToLower();
    

12. Generics

Introduction to Generics

Generics in C# are a powerful feature that allows you to create classes, methods, and data structures that can work with any data type. The main advantage of using generics is that they provide compile-time type safety. This means that the type of data being passed is checked during compilation, reducing the risk of runtime errors.

var l = new List<int>();
l.Add("John"); // This will cause a compile-time error

In the example above, trying to add a string to a list of integers will generate a compile-time error.

Generic Methods and Classes

A generic method is a method that is declared with a type parameter. This allows the method to be more flexible, accepting arguments of various types.

public static T Add<T>(T x, T y)
{
    if (typeof(T) == typeof(int))
    {
        return (T)(object)(Convert.ToInt32(x) + Convert.ToInt32(y));
    }
    else if (typeof(T) == typeof(string))
    {
        return (T)(object)(Convert.ToString(x) + Convert.ToString(y));
    }
    throw new Exception("Pass either int or string as input");
}

In this example, the Add method can work with both integers and strings, providing flexibility while maintaining type safety.

Implementing a Generic Map Function

Generics can also be used in data structures and algorithms. Here's an example of a generic map function:

public class Program
{
    delegate T2 Operation<T1, T2>(T1 x);
    private static IList<T2> MyMap<T1, T2>(IList<T1> inputList, Operation<T1, T2> op)
    {
        var outputList = new List<T2>();
        foreach (var item in inputList)
        {
            outputList.Add(op(item));
        }
        return outputList;
    }
}

This MyMap function applies an operation to each item in a list and returns a new list with the results.

13. Generic Type Constraints

C# allows you to impose constraints on the types that can be used with a generic class or method. Some common constraints include:

  • where T : class - T must be a reference type.
  • where T : struct - T must be a value type.
  • where T : new() - T must have a parameterless constructor.
  • where T : someBaseType - T must derive from someBaseType.

Example: Generic Instance Creation

public class Program
{
    public static T CreateInstance<T>() where T : new()
    {
        return new T();
    }
    public static void Main()
    {
        Contact contact = CreateInstance<Contact>();
        contact.Name = "John";
        Console.WriteLine(contact.Name);
    }
}

This method creates an instance of a type that has a parameterless constructor.

14. Reflection

Exploring Methods with Reflection

Reflection in C# allows you to inspect and interact with objects and types at runtime. For example, you can list all methods in a class using reflection:

using System.Reflection;

class MyClass
{
    public void Method1() { }
    void Method2() { }
    private void Method3() { }
}

public class Program
{
    public static void Main()
    {
        Type t = typeof(MyClass);
        foreach(var method in t.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
        {
            Console.WriteLine(method.Name);
        }
    }
}

Invoking Constructors with Reflection

You can use reflection to invoke constructors dynamically:

using System.Reflection;

public class Contact
{
    public string? Name { get; set; }
    public Contact(string name) { Name = name; }
}

public class Program
{
    public static T CreateInstance<T>(string param) where T : class
    {
        Type? t = typeof(T);
        ConstructorInfo c = t?.GetConstructor(new[] { typeof(string) });
        return (T)c.Invoke(new object[] { param });
    }
    public static void Main()
    {
        Contact contact = CreateInstance<Contact>("John");
        Console.WriteLine(contact.Name);
    }
}

15. Attributes

Introduction to Attributes

Attributes in C# provide a way to add metadata to your code. This metadata can be accessed at runtime using reflection.

public class MyClass
{
    [Obsolete("Use NewMethod instead", false)]
    public static void SomeDeprecatedMethod() { }
    public static void NewMethod() { }
}

public class Program
{
    public static void Main()
    {
        MyClass.SomeDeprecatedMethod();
    }
}

In this example, the [Obsolete] attribute marks a method as deprecated.

Custom Attributes

You can define your custom attributes to add specific metadata to your classes or methods:

public class DeveloperInfoAttribute : Attribute
{
    public string? Developer { get; set; }
}

[DeveloperInfo(Developer = "John")]
public class Program
{
    public static void Main() { }
}

16. Indexers

Indexers allow you to use objects like arrays. They are defined using the this keyword:

public class ShoppingCart
{
    private int[] prices = new int[10];
    public int this[int index]
    {
        get { return prices[index]; }
        set { prices[index] = value; }
    }
}

17. Extension Methods

Introduction to Extension Methods

Extension methods allow you to add new methods to existing types without modifying them. These methods are defined in a static class:

public static class IntExtension
{
    public static bool IsEven(this int number)
    {
        return number % 2 == 0;
    }
}

public class Program
{
    public static void Main()
    {
        int i = 10;
        Console.WriteLine(i.IsEven());
    }
}

Test Your Knowledge

No quiz available

Tags