Every BiblioTech example you've seen so far has worked with concrete types: a method that
takes a Book, a list meant only for LibraryItem. But what happens when you need the same
logic — storing elements, searching for them, counting them — for Member, for Loan, or for
any future type that doesn't even exist yet? Writing a separate class for each type would mean
repeating the same code over and over. Generics solve exactly this problem: they let you
write a class or a method once, leaving the concrete type it will work with as just another
parameter, decided when it's used. This lesson builds a reusable Repository<T> and
specializes it for BiblioTech's items and members, laying the foundation on which the
Library class will be built in the next lesson.
Contents
- The problem without generics: code duplication, or
objectand boxing - Generic classes:
Repository<T> - Generic methods
- Constraints:
where T : ... - Specializing
Repository<T>for BiblioTech - Generics with several type parameters (a preview of
Dictionary<TKey, TValue>)
- The problem without generics: code duplication, or
object and boxing
object and boxingWithout generics, there are two common ways of writing a reusable class for "any type," and neither is satisfactory. The first is to duplicate the entire class, changing only the type:
class BookRepository
{
private List<Book> _items = new List<Book>();
public void Add(Book item) => _items.Add(item);
}
class MemberRepository
{
private List<Member> _items = new List<Member>();
public void Add(Member item) => _items.Add(item);
}The code is identical except for the type: any fix or improvement has to be repeated in every
copy. The second alternative, which predates generics in very old versions of .NET, was to use
object as a universal type:
class ObjectRepository
{
private List<object> _items = new List<object>();
public void Add(object item) => _items.Add(item);
}This is indeed reusable, but it has two serious problems: all compile-time type checking is
lost (nothing prevents adding a Member to a repository meant for Book, and the error would
only show up at run time when converting it back), and, for value types like int or
DateTime, storing them as object forces a process called boxing: wrapping the value in
a dynamically allocated object (and unboxing when retrieving it), with a measurable
performance cost that repeats on every operation.
List<object> numbers = new List<object>();
numbers.Add(42); // boxing: the int 42 is wrapped in an object on the heap
int value = (int)numbers[0]; // unboxing: extracted back, with an explicit conversionGenerics solve both problems at once: compile-time type safety, no code duplication, and no boxing for value types.
- Generic classes:
Repository<T>
Repository<T>A generic class declares one or more type parameters between < and > next to its name
(by convention, a single uppercase letter such as T, for Type), and uses them as if they
were a regular type inside the class:
class Repository<T>
{
private List<T> _items = new List<T>();
public void Add(T item)
{
_items.Add(item);
}
public int Count => _items.Count;
}T isn't any concrete type: it's a placeholder that the compiler replaces with the real type
at the point where the class is used, given between <>:
Repository<Book> bookRepository = new Repository<Book>();
bookRepository.Add(new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4"));
// bookRepository.Add(new Member(1, "Ana Martinez")); // Compilation error: Member isn't a Book
Repository<Member> memberRepository = new Repository<Member>();
memberRepository.Add(new Member(1, "Ana Martinez"));
Console.WriteLine(bookRepository.Count); // 1
Console.WriteLine(memberRepository.Count); // 1With a single definition of Repository<T>, the compiler generates (conceptually) a
specialized version for each type it's used with, with full type checking: the error of
trying to add a Member to Repository<Book> is caught at compile time, not at run time.
A Find method completes the class, taking a search criterion as a parameter (using
Func<T, bool>, already familiar from the Delegates and Events lesson):
class Repository<T>
{
private List<T> _items = new List<T>();
public void Add(T item)
{
_items.Add(item);
}
public T? Find(Func<T, bool> criterion)
{
foreach (T item in _items)
{
if (criterion(item))
{
return item;
}
}
return default; // default(T): null for reference types, 0/false/etc. for value types
}
public int Count => _items.Count;
}default (equivalent to default(T)) returns T's default value, whatever it may be: null
if T is a reference type (like Book), or the corresponding default value (0, false, a
minimum date...) if T is a value type. This is something only a generic can write without
knowing in advance what T is.
- Generic methods
Besides full generic classes, a single method (inside a regular or a generic class) can declare its own type parameter, independent of the one belonging to the containing class:
class Utilities
{
public static T FirstOrDefault<T>(List<T> list, T defaultValue)
{
return list.Count > 0 ? list[0] : defaultValue;
}
}List<string> names = new List<string> { "Ana", "Luis" };
string first = Utilities.FirstOrDefault(names, "No name"); // "Ana"
List<int> empty = new List<int>();
int firstNumber = Utilities.FirstOrDefault(empty, -1); // -1Notice that there was no need to write Utilities.FirstOrDefault<string>(...) explicitly: the
compiler infers the type T from the arguments (names is List<string>, so T is
string). Writing the type explicitly between <> is still valid, and sometimes necessary,
but it's rarely needed.
- Constraints:
where T : ...
where T : ...With no constraint at all, inside a generic class or method you can only assume that T is
"any type," which severely limits what you can do with it (you couldn't even call
item.Title, because not every type has that property). A constraint (where T : ...)
narrows the set of accepted types in exchange for being able to assume more about them inside
the class:
class LendableRepository<T> where T : ILendable
{
private List<T> _items = new List<T>();
public void Add(T item)
{
_items.Add(item);
}
public List<T> GetAvailable()
{
List<T> result = new List<T>();
foreach (T item in _items)
{
if (item.Available) // valid: T is constrained to ILendable, which declares Available
{
result.Add(item);
}
}
return result;
}
}Thanks to where T : ILendable (recalling the interface from this module's first lesson), the
compiler knows that any T this class is used with will have, at minimum, Available,
Lend(), and Return(), and it allows them to be used inside the generic class with no
additional conversion.
| Constraint | Meaning |
|---|---|
where T : LibraryItem |
T must be LibraryItem or an inheriting class |
where T : ILendable |
T must implement the ILendable interface |
where T : class |
T must be a reference type (not a struct) |
where T : struct |
T must be a value type |
where T : new() |
T must have a public parameterless constructor, allowing new T() inside the generic class |
| Several at once | where T : LibraryItem, ISearchable — all must be satisfied |
- Specializing
Repository<T> for BiblioTech
Repository<T> for BiblioTechWith Repository<T> already defined, it's specialized for the two central types of
BiblioTech's model:
Repository<LibraryItem> itemRepository = new Repository<LibraryItem>();
itemRepository.Add(new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4"));
itemRepository.Add(new Magazine("National Geographic", "Various authors", 302));
LibraryItem? found = itemRepository.Find(i => i.Title == "Hopscotch");
if (found is Book foundBook)
{
Console.WriteLine($"Found: {foundBook.Isbn}");
}
Repository<Member> memberRepository = new Repository<Member>();
memberRepository.Add(new Member(1, "Ana Martinez"));
memberRepository.Add(new Member(2, "Luis Gomez"));
Member? foundMember = memberRepository.Find(m => m.Id == 2);
Console.WriteLine(foundMember?.Name); // "Luis Gomez"Repository<LibraryItem> and Repository<Member> share exactly the same Add, Find, and
Count code, written once. This same Repository<T> could, in principle, back the Library
class's catalog internally; in the next lesson, however, Library will be built directly on
top of List<T> and Dictionary<TKey, TValue> (.NET's standard collections), which is the
more common approach in real code when the extra logic of a custom repository isn't needed.
Both approaches are valid; what matters in this section is seeing generics applied to a
complete example.
- Generics with several type parameters (a preview of
Dictionary<TKey, TValue>)
Dictionary<TKey, TValue>)A generic class or method can declare more than one type parameter at once, separated by
commas. The most common example, which you'll use in the next lesson, is
Dictionary<TKey, TValue>, with two independent type parameters: the key's type and the type
of the associated value.
class Pair<TKey, TValue>
{
public TKey Key { get; }
public TValue Value { get; }
public Pair(TKey key, TValue value)
{
Key = key;
Value = value;
}
}
Pair<int, Member> memberPair = new Pair<int, Member>(1, new Member(1, "Ana Martinez"));
Console.WriteLine($"{memberPair.Key} -> {memberPair.Value.Name}"); // "1 -> Ana Martinez"This idea — two independent type parameters, one for the key and one for the value — is
exactly what Dictionary<TKey, TValue> uses, which you'll study in depth in the next lesson to
index BiblioTech's members by their Id.
Common Mistakes and Tips
- Forgetting
where T : ...and expecting to use members of a concrete type: without a constraint, insideRepository<T>you can't writeitem.Title, because the compiler doesn't know thatTwill have that property; add the necessary constraint (where T : LibraryItem, or an interface) to be able to access those members. - Confusing a class's type parameter with a method's:
class Repository<T>andT Method<T>(...)inside a non-generic class are different things; a generic method inside a generic class can even reuse the nameT, although it's best avoided to keep the code easy to read. - Using
object"for simplicity" instead of a generic: it gives up compile-time type checking and, for value types, introduces unnecessary boxing; with generics available since the early modern versions of C#, there's rarely a good reason to preferobject. - Forgetting that
defaultcan be surprising for value types:default(int)is0,default(bool)isfalse; if a generic method returnsdefaultto signal "not found" andTisint, a result of0could be confused with an actual value that was found. With reference types (likeLibraryItem),defaultis alwaysnull, easier to distinguish withis null. - Tip: name type parameters with a single letter when the purpose is generic and obvious
from context (
T,TKey,TValue), and with a more descriptive name (TItem,TMember) only if it adds real clarity to a class with several related type parameters.
Exercises
-
Define
class Repository<T>with an internalList<T>, a methodAdd(T item), and aCountproperty. Create aRepository<Book>and aRepository<Member>, add two elements to each, and show the count of each repository. -
Add to
Repository<T>a methodT? Find(Func<T, bool> criterion)that returns the first element satisfying the criterion, ordefaultif none does. Search by title in aRepository<Book>and byIdin aRepository<Member>. -
Define
class LendableRepository<T> where T : ILendablewith a methodList<T> GetAvailable()that returns only the elements withAvailable == true. Add two books (one lent out, one available) and check thatGetAvailable()returns only the free one.
Solutions
class Repository<T>
{
private List<T> _items = new List<T>();
public void Add(T item)
{
_items.Add(item);
}
public int Count => _items.Count;
}
Repository<Book> bookRepository = new Repository<Book>();
bookRepository.Add(new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4"));
bookRepository.Add(new Book("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1"));
Repository<Member> memberRepository = new Repository<Member>();
memberRepository.Add(new Member(1, "Ana Martinez"));
memberRepository.Add(new Member(2, "Luis Gomez"));
Console.WriteLine(bookRepository.Count); // 2
Console.WriteLine(memberRepository.Count); // 2
public T? Find(Func<T, bool> criterion)
{
foreach (T item in _items)
{
if (criterion(item))
{
return item;
}
}
return default;
}
Book? foundBook = bookRepository.Find(b => b.Title == "Hopscotch");
Console.WriteLine(foundBook?.Isbn); // "978-84-376-0495-4"
Member? foundMember = memberRepository.Find(m => m.Id == 2);
Console.WriteLine(foundMember?.Name); // "Luis Gomez"
class LendableRepository<T> where T : ILendable
{
private List<T> _items = new List<T>();
public void Add(T item) => _items.Add(item);
public List<T> GetAvailable()
{
List<T> result = new List<T>();
foreach (T item in _items)
{
if (item.Available)
{
result.Add(item);
}
}
return result;
}
}
LendableRepository<Book> repository = new LendableRepository<Book>();
Book available = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
Book lentOut = new Book("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1");
lentOut.Lend();
repository.Add(available);
repository.Add(lentOut);
List<Book> availableBooks = repository.GetAvailable();
Console.WriteLine(availableBooks.Count); // 1
Console.WriteLine(availableBooks[0].Title); // "Hopscotch"
Conclusion
In this lesson you've learned to write generic classes and methods, to constrain a type
parameter with where T : ... so you can use concrete members inside the generic class, and
to avoid both code duplication and object boxing. Repository<T> demonstrates that a single
implementation can safely serve both LibraryItem and Member.
Generics are, precisely, the foundation .NET's collections are built on, which you'll use from
now on without a second thought: List<T>, already familiar from Module 1, and
Dictionary<TKey, TValue>, with two type parameters like the Pair<TKey, TValue> from the
last section. The next lesson, Collections, revisits both to finally build the complete
Library class: a central catalog of items and members, managed in memory, ready to be
queried with LINQ in the following lesson.
C# Programming Course
Module 1: Introduction to C#
- Introduction to C#
- Setting Up the Development Environment
- Hello World Program
- Basic Syntax and Structure
- Variables and Data Types
- Arrays and Strings
Module 2: Control Structures
Module 3: Object-Oriented Programming
- Classes and Objects
- Methods
- Constructors and Destructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- Structs and Records: Value Types and Reference Types
Module 4: Advanced C# Concepts
- Interfaces
- Delegates and Events
- Pattern Matching and Modern C# Features
- Generics
- Collections
- LINQ (Language Integrated Query)
- Asynchronous Programming
Module 5: Working with Data
- File I/O
- Serialization
- Database Connectivity
- Entity Framework
- Working with JSON and Consuming REST APIs
Module 6: Advanced Topics
- Reflection
- Attributes
- Dynamic Programming
- Memory Management and Garbage Collection
- Multithreading and Parallel Programming
Module 7: Building Applications
Module 8: Best Practices and Design Patterns
- Coding Standards and Best Practices
- Design Patterns
- Dependency Injection and Inversion of Control
- Unit Testing
- Code Review and Refactoring
