5
\$\begingroup\$

I've been using Monogame for hobby game development for the last year now. My game assets aren't large so I usually load them all on startup.

I would have a static AssetManager class with a public static Load(ContentManager content); method. Assets would be loaded and stored in multiple Dictionaries like so:

private static void Dictionary<string, Spritesheet> _spritesheets = new Dictionary<string, Spritsheet>();
private static void Dictionary<string, Sprite> _sprites = new Dictionary<string, Sprite>();
private static void Dictionary<string, Tile> _tiles = new Dictionary<string, Tile>();

I would have multiple different methods to help with adding and getting assets. After a while, my AssetManager class starts to get rather large.

So to help maintain and easily access assets, I decided to create an AssetCache (to store assets of a specific type) and CacheManager (to manage the different asset caches). I use generic methods to get specific AssetCaches in the CacheManager.

AssetCache.cs
This class stores assets of a specific type using a specific key. It contains various getters and setters.

For example, an asset cache that stores Tile objects using a string as a key: new AssetCache<string, Tile>();

using System;
using System.Collections.Generic;

namespace Project.Assets.Cache
{
  /// <summary>
  /// Stores multiple instances of the same data type.
  /// </summary>
  /// <typeparam name="TKey"></typeparam>
  /// <typeparam name="TAsset"></typeparam>
  public class AssetCache<TKey, TAsset> : ICache where TKey : IEquatable<TKey>
  {
    private readonly Dictionary<TKey, TAsset> _cache = new Dictionary<TKey, TAsset>();

    /// <summary>
    /// The amount of items stored in the cache.
    /// </summary>
    public int Count => _cache.Count;

    /// <summary>
    /// Whether or not the cache is empty.
    /// </summary>
    public bool IsEmpty => Count == 0;

    /// <summary>
    /// Check's if an asset with a specific name exists in the cache.
    /// </summary>
    /// <param name="key">The asset key.</param>
    /// <returns>Whether or not the asset exists.</returns>
    public bool Has(TKey key) => _cache.ContainsKey(key);

    /// <summary>
    /// Gets and sets an asset with a specific key.
    /// </summary>
    /// <param name="key">The asset key.</param>
    /// <returns>An asset instance, if found.</returns>
    /// <exception cref="KeyNotFoundException">Thrown if the asset key does not exist
    /// in the cache.</exception>
    /// <exception cref="InvalidOperationException">Thrown if the asset key is
    /// already in use.</exception>
    public TAsset this[TKey key]
    {
      get => Get(key);
      set => Add(key, value);
    }

    /// <summary>
    /// Gets an asset instance from the cache.
    /// </summary>
    /// <param name="key">The asset key.</param>
    /// <returns>The asset instance.</returns>
    /// <exception cref="KeyNotFoundException">Thrown if the asset key does not exist
    /// in the cache.</exception>
    public TAsset Get(TKey key)
    {
      if (!Has(key))
      {
        throw new KeyNotFoundException($"Cache item '{key}' not found.");
      }

      return _cache[key];
    }

    /// <summary>
    /// Adds an asset to the cache.
    /// </summary>
    /// <param name="key">The asset key.</param>
    /// <param name="asset">The asset instance to add.</param>
    /// <exception cref="InvalidOperationException">Thrown if the key is already
    /// in use.</exception>
    public void Add(TKey key, TAsset asset)
    {
      if (Has(key))
      {
        throw new InvalidOperationException($"Cannot add asset as '{key}'. Key already exists.");
      }

      _cache.Add(key, asset);
    }

    /// <summary>
    /// RemoveAsset a specific asset from the cache.
    /// </summary>
    /// <param name="key">The asset key to remove.</param>
    /// <returns>Whether or not the asset was removed.</returns>
    public bool Remove(TKey key)
    {
      return _cache.Remove(key);
    }

    /// <summary>
    /// Clears all assets from the cache.
    /// </summary>
    public void Clear()
    {
      _cache.Clear();
    }

  }
}

ICache.cs
The ICache interface is implemented by the AssetCache class above, is used by the CacheManager so I can store multiple AssetCache objects with different generic types in the same dictionary.

namespace Project.Assets.Cache
{
  public interface ICache
  {
    void Clear();
  }
}

CacheManager.cs
The generic parameter of this class represents the key type that will be used when storing assets. This means I can use things like int's, string's or even custom structs like Point2D if I really wanted to.

using System;
using System.Collections.Generic;

namespace Project.Assets.Cache
{
  /// <summary>
  /// Stores multiple <see cref="AssetCache{TKey,TValue}"/> instances. All cache's use the
  /// same key type, which must implement <see cref="IEquatable{T}"/>.
  /// </summary>
  /// <typeparam name="TKey"></typeparam>
  public sealed class CacheManager<TKey> where TKey : IEquatable<TKey>
  {
    private readonly Dictionary<Type, ICache> _caches = new Dictionary<Type, ICache>();

    /// <summary>
    /// Count the amount of assets in a specific asset cache.
    /// </summary>
    /// <typeparam name="TAsset">The type of asset cache to check.</typeparam>
    /// <returns>The amount of assets.</returns>
    /// <exception cref="KeyNotFoundException">Thrown if the asset cache type does
    /// not exist.</exception>
    public int Count<TAsset>() => GetCache<TAsset>().Count;

    /// <summary>
    /// Check if an asset cache of a specific type is empty or not.
    /// </summary>
    /// <typeparam name="TAsset">The type of asset cache to check.</typeparam>
    /// <returns>Whether or not the asset cache is empty.</returns>
    /// <exception cref="KeyNotFoundException">Thrown if the asset cache type does
    /// not exist.</exception>
    public bool IsEmpty<TAsset>() => GetCache<TAsset>().IsEmpty;

    /// <summary>
    /// Check if a specific asset exists in a specific asset cache.
    /// </summary>
    /// <typeparam name="TAsset">The type of asset cache to look for.</typeparam>
    /// <param name="key">The key of the asset to look for.</param>
    /// <returns>Whether or not the asset exists or not.</returns>
    /// <exception cref="KeyNotFoundException">Thrown if the asset cache type does
    /// not exist.</exception>
    public bool Has<TAsset>(TKey key) => GetCache<TAsset>().Has(key);

    /// <summary>
    /// Clear an asset cache of a specific type.
    /// </summary>
    /// <typeparam name="TAsset"></typeparam>
    /// <exception cref="KeyNotFoundException">Thrown if the asset cache type does
    /// not exist.</exception>
    public void Clear<TAsset>() => GetCache<TAsset>().Clear();

    /// <summary>
    /// GetAsset a specific asset from a specified asset cache.
    /// </summary>
    /// <typeparam name="TAsset">The type of asset cache to use.</typeparam>
    /// <param name="assetKey">The key of the asset to search for.</param>
    /// <returns>The asset instance.</returns>
    /// <exception cref="KeyNotFoundException">Thrown if the asset cache type does
    /// not exist.</exception>
    public TAsset GetAsset<TAsset>(TKey assetKey)
    {
      return GetCache<TAsset>().Get(assetKey);
    }

    /// <summary>
    /// AddAsset a new asset to an asset cache. If the cache does not exist, it is created.
    /// </summary>
    /// <typeparam name="TAsset">The type of asset to add.</typeparam>
    /// <param name="key">The key of the asset.</param>
    /// <param name="asset">The asset to add.</param>
    /// <exception cref="InvalidOperationException">Thrown if the key is already
    /// in use.</exception>
    public void AddAsset<TAsset>(TKey key, TAsset asset)
    {
      if (!TryGetCache(out AssetCache<TKey, TAsset> cache))
      {
        cache = AddCache<TAsset>();
      }

      cache.Add(key, asset);
    }

    /// <summary>
    /// Removes an asset from a specified asset cache.
    /// </summary>
    /// <typeparam name="TAsset">The type of asset to remove.</typeparam>
    /// <param name="key">The key of the asset to remove.</param>
    /// <returns>Whether or not the asset was removed from the cache.</returns>
    /// <exception cref="KeyNotFoundException">Thrown if the asset cache type does
    /// not exist.</exception>
    public bool RemoveAsset<TAsset>( TKey key)
    {
      return GetCache<TAsset>().Remove(key);
    }

    /// <summary>
    /// Removes all assets from all caches.
    /// </summary>
    public void ClearAll()
    {
      foreach (ICache cache in _caches.Values)
      {
        cache.Clear();
      }
    }

    /// <summary>
    /// Adds a new <see cref="AssetCache{TKey,TValue}"/>. An exception is thrown if a cache
    /// already exists with the same asset type.
    /// </summary>
    /// <typeparam name="TAsset">The type of asset cache to add.</typeparam>
    /// <returns>The created asset cache.</returns>
    /// <exception cref="InvalidOperationException">Thrown if an asset cache with the same type
    /// already exists.</exception>
    private AssetCache<TKey, TAsset> AddCache<TAsset>()
    {
      Type cacheType = typeof(TAsset);

      if (_caches.ContainsKey(cacheType))
      {
        throw new InvalidOperationException($"Asset cache '{cacheType.Name}' already exists.");
      }

      var cache = new AssetCache<TKey, TAsset>();
      _caches.Add(cacheType, cache);
      return cache;
    }

    /// <summary>
    /// Try and get an asset cache based on the methods generic type.
    /// </summary>
    /// <typeparam name="TAsset">The type of asset cache to search for.</typeparam>
    /// <param name="cache">When the method returns, contains the cache associated with the
    /// generic type, if the cache is found; otherwise, null is passed.</param>
    /// <returns>Whether or not the asset cache was found.</returns>
    public bool TryGetCache<TAsset>(out AssetCache<TKey, TAsset> cache)
    {
      if (!_caches.TryGetValue(typeof(TAsset), out ICache cacheFound))
      {
        cache = null;
        return false;
      }

      cache = cacheFound as AssetCache<TKey, TAsset>;
      return true;
    }

    /// <summary>
    /// Gets an asset cache based on the methods generic type. An exception is thrown if the cache
    /// is not found.
    /// </summary>
    /// <typeparam name="TAsset">The type of asset cache to search for.</typeparam>
    /// <returns>The found asset cache.</returns>
    /// <exception cref="CacheNotFoundException">Thrown if the asset cache cannot be found.</exception>
    public AssetCache<TKey, TAsset> GetCache<TAsset>()
    {
      if (!TryGetCache(out AssetCache<TKey, TAsset> cache))
      {
        throw new CacheNotFoundException($"Asset cache for type '{typeof(TAsset).Name}' not found.");
      }

      return cache;
    }

    /// <summary>
    /// Remove an asset cache of a specific type.
    /// </summary>
    /// <typeparam name="TAsset">The type of asset cache to remove.</typeparam>
    /// <returns>Whether or not the asset cache was removed.</returns>
    public bool RemoveCache<TAsset>()
    {
      return _caches.Remove( typeof( TAsset ) );
    }

  }
}

Usage is like so:

private static readonly CacheManager<string> _cache = new CacheManager<string>();

public static void Load()
{
  _cache.AddAsset("floor", new Tile(0, "tile", new Character('#', Color.White), true));

  Tile floor = _cache.GetAsset<Tile>( "floor" );

  // Throws KeyNotFoundException
  _cache.GetAsset<Tile>( "door" );

  // Throws InvalidOperationException
  _cache.AddAsset("floor", new Tile(0, null, null, true)  );

  // No exception, return true
  bool removed = _cache.RemoveAsset<Tile>( "floor" );

  // No exception, return false
  bool removedCache = _cache.RemoveCache<Entity>();

  // Throws CacheNotFoundException
  _cache.GetAsset<Entity>( "monster" );
}

I'm throwing exceptions when trying to add or get an asset that doesn't exist in an AssetCache or when an AssetCache of a specific type does not exist.

When removing, I return a bool, whether the asset was actually removed or not.

The idea being if you are trying to get an asset that doesn't exist, something has gone wrong. Either you haven't added the asset, or the name is spelled wrong.

I don't think using the word "Cache" is appropriate in this case, seeing as assets aren't removed after X amount of time. Perhaps "Store" might be better.

\$\endgroup\$
6
\$\begingroup\$

I strongly dislike your AssetCache type. You are literally wrapping a dictionary. The only difference is the type of the exception you are throwing when adding an existing item. Just use a plain old Dictionary<TKey, TAsset> directly.

Your ICache interface is also incomplete. Of what use is an interface that lets you destroy data without being able to create it? You should either have an interface that is create-only so you can segment the code by duty and only expose parts of it to certain callers, or you should add the create methods do this interface. Don't go half-way on your interface use. Either use them or don't. If you do this, you won't need to return an AssetCache in the following method, but just an ICache.

public bool TryGetCache<TAsset>(out AssetCache<TKey, TAsset> cache)
{
  if (!_caches.TryGetValue(typeof(TAsset), out ICache cacheFound))
  {
    cache = null;
    return false;
  }

  cache = cacheFound as AssetCache<TKey, TAsset>;
  return true;
}

And finally, I'm a trifle concerned about those massive doc comments. They hide the code, and they are almost certain to get out of date as your code changes. I'm not saying to remove them, but with code this simple, I wouldn't have added them. And be careful to never update the methods without updating the doc comments too. For example, how does the following doc comment explain anything better than its accompanying code?

/// <summary>
/// Gets an asset cache based on the methods generic type. An exception is thrown if the cache
/// is not found.
/// </summary>
/// <typeparam name="TAsset">The type of asset cache to search for.</typeparam>
/// <returns>The found asset cache.</returns>
/// <exception cref="CacheNotFoundException">Thrown if the asset cache cannot be found.</exception>
public AssetCache<TKey, TAsset> GetCache<TAsset>()
{
  if (!TryGetCache(out AssetCache<TKey, TAsset> cache))
  {
    throw new CacheNotFoundException($"Asset cache for type '{typeof(TAsset).Name}' not found.");
  }

  return cache;
}
\$\endgroup\$
  • \$\begingroup\$ Thanks for the answer! I'm thinking I could remove the ICache interface and AssetCache class. Inside the CacheManager class I could use a Dictionary<Type, Dictionary<TKey, object>>. I can still keep methods like TAsset GetAsset<TAsset>(TKey key), I just cast the found asset to the TAsset type. I see what you mean about the comments. I find it hard to write useful documentation without repeating myself. \$\endgroup\$ – Matt Clegg Nov 27 '18 at 9:00
  • 2
    \$\begingroup\$ I want to chime in favor of the documentation comments, especially the <summary> tags. Getting a written-english description of a function's purpose and behavior just by mousing over its name is valuable now, even when the function is short and well-named. Plus, the broader but weaker "valuable someday" argument: Making these comments complete is a good habit to get into anyway, because it's feasible the code could be distributed as a .dll without source, but with documentation auto-generated from these comments. \$\endgroup\$ – benj2240 Nov 27 '18 at 14:37

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.