8.6 - Generics (Deeper Dive)
We've already encountered generics in previous modules, particularly when working with collections like List<T>
and Dictionary<TKey, TValue>
. In this section, we'll take a deeper dive into generics, exploring more advanced concepts and how they can be applied in game development.
Quick Recap: What Are Generics?
Generics allow you to define classes, interfaces, methods, and delegates with placeholder types that are specified when the code is used. This enables you to create reusable, type-safe code without sacrificing performance.
The basic syntax uses angle brackets to specify type parameters:
// Generic class
public class Container<T>
{
private T _item;
public Container(T item)
{
_item = item;
}
public T GetItem()
{
return _item;
}
}
// Usage
Container<int> intContainer = new Container<int>(42);
Container<string> stringContainer = new Container<string>("Hello");
Generic Methods
In addition to generic classes, you can define generic methods within non-generic classes:
public class Utilities
{
// Generic method
public T FindMax<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) > 0 ? a : b;
}
}
// Usage
Utilities utils = new Utilities();
int maxInt = utils.FindMax<int>(5, 10); // 10
float maxFloat = utils.FindMax<float>(3.5f, 2.7f); // 3.5f
The compiler can often infer the type arguments for generic methods, allowing for cleaner syntax:
int maxInt = utils.FindMax(5, 10); // Type inference - no need for <int>
Type Constraints
Generic type parameters can be constrained to ensure they have certain capabilities. This allows you to perform operations that would otherwise be impossible with an unconstrained type parameter.
Reference Type Constraint
The class
constraint ensures that the type argument is a reference type:
public class ObjectPool<T> where T : class
{
private List<T> _pool = new List<T>();
// Implementation...
}
Value Type Constraint
The struct
constraint ensures that the type argument is a value type:
public struct Vector3D<T> where T : struct
{
public T X { get; set; }
public T Y { get; set; }
public T Z { get; set; }
// Implementation...
}
Interface Constraint
You can constrain a type parameter to implement a specific interface:
public class SortedList<T> where T : IComparable<T>
{
private List<T> _items = new List<T>();
public void Add(T item)
{
_items.Add(item);
_items.Sort(); // This works because T implements IComparable<T>
}
// Implementation...
}
Base Class Constraint
You can constrain a type parameter to be or derive from a specific base class:
public class EnemySpawner<T> where T : Enemy
{
public T SpawnEnemy(Vector3 position)
{
T enemy = new T(); // This works because T is or derives from Enemy
enemy.Position = position;
enemy.Initialize();
return enemy;
}
// Implementation...
}
Constructor Constraint
The new()
constraint ensures that the type argument has a parameterless constructor:
public class Factory<T> where T : new()
{
public T Create()
{
return new T(); // This works because T has a parameterless constructor
}
}
Multiple Constraints
You can apply multiple constraints to a single type parameter:
public class GameObjectPool<T> where T : Component, IPoolable, new()
{
// T must be a Component, implement IPoolable, and have a parameterless constructor
// Implementation...
}
Practical Example: Generic Object Pool
Object pooling is a common optimization technique in game development. Let's implement a generic object pool that can work with any type of game object:
// Interface for poolable objects
public interface IPoolable
{
void OnSpawn();
void OnDespawn();
bool IsActive { get; set; }
}
// Generic object pool
public class ObjectPool<T> where T : IPoolable, new()
{
private List<T> _pool = new List<T>();
private int _initialSize;
private int _maxSize;
public ObjectPool(int initialSize = 10, int maxSize = 100)
{
_initialSize = initialSize;
_maxSize = maxSize;
// Pre-populate the pool
for (int i = 0; i < initialSize; i++)
{
CreateNewObject();
}
}
private T CreateNewObject()
{
T obj = new T();
obj.IsActive = false;
_pool.Add(obj);
return obj;
}
public T Spawn()
{
// Try to find an inactive object in the pool
T obj = _pool.FirstOrDefault(o => !o.IsActive);
// If no inactive object is found and we haven't reached max size, create a new one
if (obj == null && _pool.Count < _maxSize)
{
obj = CreateNewObject();
}
// If we found or created an object, activate it
if (obj != null)
{
obj.IsActive = true;
obj.OnSpawn();
}
return obj;
}
public void Despawn(T obj)
{
if (obj != null && _pool.Contains(obj))
{
obj.OnDespawn();
obj.IsActive = false;
}
}
public void DespawnAll()
{
foreach (T obj in _pool.Where(o => o.IsActive))
{
Despawn(obj);
}
}
}
// Example usage with a Bullet class
public class Bullet : IPoolable
{
public Vector3 Position { get; set; }
public Vector3 Velocity { get; set; }
public bool IsActive { get; set; }
public void OnSpawn()
{
// Reset bullet state
Velocity = Vector3.zero;
}
public void OnDespawn()
{
// Clean up any resources
}
public void Update()
{
if (IsActive)
{
Position += Velocity * Time.deltaTime;
// Check for collisions, etc.
}
}
}
// Using the object pool
public class WeaponSystem
{
private ObjectPool<Bullet> _bulletPool = new ObjectPool<Bullet>(20, 100);
public void FireWeapon(Vector3 position, Vector3 direction)
{
Bullet bullet = _bulletPool.Spawn();
if (bullet != null)
{
bullet.Position = position;
bullet.Velocity = direction * 10f;
}
}
public void Update()
{
// Check for bullets that have gone off-screen or hit something
// and despawn them: _bulletPool.Despawn(bullet);
}
}
This object pool can be used with any class that implements the IPoolable
interface and has a parameterless constructor.
Generic Inheritance and Interfaces
Generics work well with inheritance and interfaces, allowing for powerful type relationships:
// Generic interface
public interface IStorage<T>
{
void Store(T item);
T Retrieve();
bool Contains(T item);
}
// Generic class implementing generic interface
public class Inventory<T> : IStorage<T>
{
private List<T> _items = new List<T>();
public void Store(T item)
{
_items.Add(item);
}
public T Retrieve()
{
if (_items.Count > 0)
{
T item = _items[_items.Count - 1];
_items.RemoveAt(_items.Count - 1);
return item;
}
return default;
}
public bool Contains(T item)
{
return _items.Contains(item);
}
}
// Specialized implementation
public class WeaponInventory : Inventory<Weapon>
{
public Weapon GetStrongestWeapon()
{
// Additional functionality specific to weapons
return null; // Placeholder
}
}
Covariance and Contravariance
Covariance and contravariance are advanced generic concepts that allow for more flexible type relationships in certain scenarios.
Covariance
Covariance allows you to use a more derived type than originally specified. It's indicated by the out
keyword in generic interfaces and delegates:
// Covariant interface
public interface IReadOnlyStorage<out T>
{
T GetItem(int index);
int Count { get; }
}
// Implementation
public class ReadOnlyInventory<T> : IReadOnlyStorage<T>
{
private List<T> _items;
public ReadOnlyInventory(List<T> items)
{
_items = items;
}
public T GetItem(int index)
{
return _items[index];
}
public int Count => _items.Count;
}
// Usage with covariance
public void ProcessItems(IReadOnlyStorage<Item> storage)
{
for (int i = 0; i < storage.Count; i++)
{
Item item = storage.GetItem(i);
// Process item...
}
}
// This works because Weapon derives from Item
IReadOnlyStorage<Weapon> weaponStorage = new ReadOnlyInventory<Weapon>(weapons);
ProcessItems(weaponStorage); // Covariance allows this
Covariance works because the interface only returns T
(output), it never accepts T
as input.
Contravariance
Contravariance allows you to use a less derived type than originally specified. It's indicated by the in
keyword:
// Contravariant interface
public interface IComparer<in T>
{
int Compare(T x, T y);
}
// Implementation
public class ItemComparer : IComparer<Item>
{
public int Compare(Item x, Item y)
{
return x.Value.CompareTo(y.Value);
}
}
// Usage with contravariance
public void SortWeapons(List<Weapon> weapons, IComparer<Weapon> comparer)
{
weapons.Sort((x, y) => comparer.Compare(x, y));
}
// This works because Weapon derives from Item
IComparer<Item> itemComparer = new ItemComparer();
SortWeapons(weapons, itemComparer); // Contravariance allows this
Contravariance works because the interface only accepts T
as input, it never returns T
.
Generic Type Inference
C# can often infer generic type arguments from the context, making your code cleaner:
// Method with type inference
public static List<T> CreateList<T>(params T[] items)
{
return new List<T>(items);
}
// Usage with type inference
var intList = CreateList(1, 2, 3, 4, 5); // Type is inferred as List<int>
var stringList = CreateList("a", "b", "c"); // Type is inferred as List<string>
Default Value of Generic Types
The default
keyword returns the default value for a type, which is useful with generics:
public T GetDefaultValue<T>()
{
return default(T); // Returns 0 for numeric types, null for reference types, etc.
}
Generics in Unity
Unity fully supports generics, and they're used extensively throughout the engine:
- Collections:
List<T>
,Dictionary<TKey, TValue>
, etc. - Component access:
GetComponent<T>()
,FindObjectOfType<T>()
, etc. - Events:
UnityEvent<T>
,Action<T>
, etc. - Coroutines:
StartCoroutine<T>()
Unity's component system works well with generics. Methods like GetComponent<T>()
use generics to provide type-safe access to components without the need for casting. Understanding generics will help you write more flexible and reusable Unity code.
Best Practices for Generics
-
Use meaningful type parameter names: Use
T
for a single type parameter, or more descriptive names likeTKey
andTValue
for multiple parameters. -
Apply constraints appropriately: Use constraints to express requirements on type parameters and enable more operations within your generic code.
-
Consider performance: Generics provide type safety without the boxing/unboxing overhead of using
object
, making them more efficient. -
Don't overuse generics: Not everything needs to be generic. Use generics when you need true type flexibility, not just for the sake of it.
-
Be careful with static fields in generic classes: Each closed generic type gets its own set of static fields.
Conclusion
Generics are a powerful feature that enable you to write flexible, reusable, and type-safe code. By understanding advanced generic concepts like constraints, covariance, and contravariance, you can create more sophisticated and efficient systems for your games.
In the next section, we'll explore extension methods, which allow you to add new functionality to existing types without modifying their source code.