6
\$\begingroup\$

I recently read "The C++ programming language" chapter 22 and I thought the example given in chapter 22.2.4 was quite interesting. I thought it should be possible to make this system caching, so it could be used as a resource cache. It's unlike most other approaches I found on the net, and I got it working (the code in the book has a couple of typos). I wonder whether this is a good approach to use in real code. The usage is simple, and for every kind of resource, the only thing that has to be added is the load function.

It prints 42 twice, but the second time it's from the cache. I chose to store weak_ptr's and give shared_ptr's, so the cache doesn't keep the resources alive when not used anymore. Of course, it's just as valid to store unique_ptr's and give out non-owning raw pointers. But in that case, the lifetime of the resources is equal to the lifetime of the cache (unless the cache is purged manually).

#include <iostream>
#include <filesystem>
#include <type_traits>
#include <memory>
#include <map>

namespace fs = std::filesystem;
class Resource_base
{
public:
    virtual Resource_base* clone() const = 0;  //must be polymorphic to allow dynamic_cast
    virtual ~Resource_base() {}
};

//using Pf = Resource_base * (*)(const std::string& key);  // classic way
using Pf = std::add_pointer_t<Resource_base*(const std::string& key)>;  // C++ 14 way
std::map<std::string, Pf> loadfuncs;
std::map<std::string, fs::path> paths;
std::map<std::string, std::weak_ptr<Resource_base>> cache;

std::shared_ptr<Resource_base> get_obj(const std::string& key)
{
    if (auto res = cache[key].lock())
    {
        return res;
    }

    if (auto f = loadfuncs[key])
    {
        auto res = std::shared_ptr<Resource_base>(f(key));
        cache[key] = res;
        return res;
    }
    return nullptr;
}

template <typename T>
struct Resource : Resource_base
{
    T val;

    explicit Resource(fs::path);
    Resource* clone()const override { return new Resource(*this); }
    static Resource_base* load_resource(const std::string& key) 
    { 
        auto file = paths[key];
        return new Resource(file); 
    }
};

template <typename T>
T* get_val(Resource_base* p)
{
    if (auto pp = dynamic_cast<Resource<T>*>(p)) return &pp->val;
    return nullptr;
}

using Int_resource = Resource<int>;


Int_resource::Resource(fs::path file)
{
    val = 42;  // dummy, most resources should load from file
}

int main()
{
    loadfuncs["bla"] = &Int_resource::load_resource;  
    paths["bla"] = fs::path("d:/temp");
    auto p{ get_obj("bla") };
    if (auto sp = get_val<int>(p.get()))
    {
        std::cout << *sp << std::endl;
    }

    auto q{ get_obj("bla") };
    if (auto sp2 = get_val<int>(q.get()))
    {
        std::cout << *sp2 << std::endl;
    }
    std::cout << "hello world" << std::endl;
}

Edit: Refined it a bit more: - put the three tables in a struct, to make it easier to control visibility - made get_obj a member function of the Cache struct - moved the construction of the shared_ptr to the creation of the object itself

I like this approach. The best thing about it, is that it subclasses the resource, not the cache, so you don't end up with a Font_cache, Texture_cache, Sound_cache etc. Just one cache for all resources. The point of the original example was to demonstrate dynamic_cast, but I think the really clever bit is the table of function pointers.

The new code:

#pragma once
#include <iostream>
#include <filesystem>
#include <type_traits>
#include <memory>
#include <map>
#include <SFML/Graphics.hpp>

namespace fs = std::filesystem;
class Resource_base
{
public:
    virtual Resource_base* clone() const = 0;
    virtual ~Resource_base() {}
};

//using Pf = std::shared_ptr<Resource_base> (*)(fs::path);  // classic way
using Pf = std::add_pointer_t<std::shared_ptr<Resource_base>(fs::path)>;  // C++ 14 way


struct Cache
{
    std::map<std::string, Pf> loadfuncs;
    std::map<std::string, fs::path> paths;
    std::map<std::string, std::weak_ptr<Resource_base>> cache;

    std::shared_ptr<Resource_base> get_obj(const std::string& key)
    {
        if (auto res = cache[key].lock())
        {
            return res;
        }
        if (auto f = loadfuncs[key])
        {
            auto path = paths.at(key);
            auto res = f(path);
            cache[key] = res;
            return res;
        }
        return nullptr;
    }
};



template <typename T>
struct Resource : Resource_base
{
    T val;

    explicit Resource(fs::path);
    Resource* clone()const override { return new Resource(*this); }
    static std::shared_ptr<Resource_base> load_resource(fs::path path)
    {
        return std::make_shared<Resource>(path);
    }
};

template <typename T>
T* get_val(Resource_base* p)
{
    if (auto pp = dynamic_cast<Resource<T>*>(p)) return &pp->val;
    return nullptr;
}

using Texture_resource = Resource<sf::Texture>;

Texture_resource::Resource(fs::path file)
{
    val.loadFromFile(file.string());
}

Edit 24-5-2019: Recently, I realised this can be simplified quite a bit using a variant instead of the inheritance and dynamic_cast used here. Also, the syntax for function pointers is kind of evil, using std::function and lambda's is more easy to the eye. The code becomes this:

#include <iostream>
#include <filesystem>
#include <memory>
#include <map>
#include <functional>
#include <variant>
//#include <SFML/Graphics.hpp>

namespace fs = std::filesystem;

using Resource = std::variant<int, std::string>;
using Pf = std::function<Resource(fs::path)>;

struct Cache
{
    std::map<std::string, Pf> loadfuncs;
    std::map<std::string, fs::path> paths;
    std::map<std::string, std::weak_ptr<Resource>> cache;

    std::shared_ptr<Resource> get_obj(const std::string& key)
    {
        if (auto res = cache[key].lock())
        {
            return res;
        }
        if (auto f = loadfuncs[key])
        {
            auto path = paths.at(key);
            auto res = std::make_shared<Resource> (f(path));
            cache[key] = res;
            return res;
        }
        return nullptr;
    }
};


auto int_loader = [](fs::path p)->Resource {return 42; };
auto string_loader = [](fs::path p) -> Resource {return "hello world"; };

int main()
{
    Cache c;
    c.loadfuncs["bla"] = int_loader; 
    c.loadfuncs["hello"] = string_loader;
    c.paths["bla"] = fs::path("d:/temp");
    c.paths["hello"] = fs::path("d:/temp");

    auto p{ c.get_obj("bla") };
    auto sp = std::get<int>(*p);
    std::cout << sp << std::endl;

    auto q{ c.get_obj("bla") };
    auto sp2 = std::get<int>(*q);
    std::cout << sp2 << std::endl;

    auto i{ c.get_obj("hello") };
    auto str = std::get<std::string>(*i);
    std::cout << str << std::endl;
}

It prints 42 42 hello world

Of course de load functions are dummies: in reality they would take the path parameter to load the resource from file.

This solution is quite a bit shorter and it does work. But I guess it is not without a cost: the variant is as big as the biggest member (plus some overhead) and if one of the resources is big, that could lead to a waste of memory. In performance terms, I guess the variant does about the same thing under the hood as what the older code did. What do you think is better?

\$\endgroup\$
  • \$\begingroup\$ Why are the lambdas outside main and not directly assigned to the loadfuncs map? Why are you passing std::path along, it doesn't seem to be doing anything? \$\endgroup\$ – yuri May 28 at 20:29
  • \$\begingroup\$ Sorry for the late reply. The idea is to have configurable assets. Actually, in my project I use a sqlite database for configuration. So, I have a table with resources that has columns key, type, path. The type identifies the needed loadfunction. So, the cache gets a type identifier and attaches it in the loadfuncs map to the asset key. \$\endgroup\$ – Theo Jun 26 at 11:41
  • \$\begingroup\$ About the filesystem::path object: it could indeed be just a string. But it is used to load a file (in reality the resources are of course textures, fonts, XML files etc, not integers and strings), so I thought it to be intuitive to use the fs::path object. But that is not crucial. \$\endgroup\$ – Theo Jun 26 at 11:48

Your Answer

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

Browse other questions tagged or ask your own question.