Using the same Typeless Container we architected in Blog Post #3, we can make a kick-ass config system that can accept any type of variable. Here’s the header of the container, with a few key changes:

  1. Instead of a vector, we will be accessing the generic type wrapper with an unordered map. The map will be indexed by config key.
  2. Instead of storing a const ref to the data, we will store a pointer to the data.
  3. Instead of having a virtual SetUniform function, we will be directly modifying the stored data with a SetAttribute function.

AttributeContainer.h

#pragma once

#include <string>
#include <unordered_map>


class AttributeWrapper
{
protected:
	friend class AttributeContainer;

	virtual void SetAttribute(const std::string& rawVal) = 0;
};

template<typename T>
class TemplatedAttributeWrapper : public AttributeWrapper
{
public:
	TemplatedAttributeWrapper(T* data) : m_data(data) {};

	// the parser is abstracted to a static function to be
	// used in the general-purpose utility func FromString
	static void ParseFromString(T* data, const std::string& rawVal);
protected:

	void SetAttribute(const std::string& rawVal) override
	{
		ParseFromString(m_data, rawVal);
	};

private:
	T* m_data;
};

class AttributeContainer
{
public:
	template<typename T>
	void AddAttribute(const std::string& attributeName, T* obj)
	{
		m_map[attributeName] = new TemplatedAttributeWrapper<T>(obj);
	};

	void SetAttribute(const std::string& key, const std::string& val)
	{
		auto foundAttr = m_map.find(key);
		if (foundAttr != m_map.end())
		{
			foundAttr->second->SetAttribute(val);
		}
		else
		{
			printf("Unknown Attribute: %s with value %s\n", key.c_str(), val.c_str());
		}
	};

private:
	std::unordered_map<std::string, AttributeWrapper*> m_map;
};

// utility function to use the parser for any data type
template<typename T>
void FromString(T* data, const std::string& rawVal)
{
	TemplatedAttributeWrapper<T>::ParseFromString(data, rawVal);
};

Awesome, now we can make parsers for any data type. Here’s my bool parser, for example:

void TemplatedAttributeWrapper<bool>::ParseFromString(bool* m_data, const std::string& rawVal)
{
	std::string lowerCase = rawVal;
	std::transform(rawVal.begin(),
		rawVal.end(),
		lowerCase.begin(),
		::tolower);

	*m_data = lowerCase == "true" ? true : false;
}

Now I’m gonna do something weird. I’m going to define the config keys in a separate .inl file, because we will be using this code snippet multiple times. I’m doing this instead of duplicating the code because if I want to add a new config, I only want to add one line of code.

ConfigKeys.inl

ConfigKey(bool, useSteam, false);
ConfigKey(bool, useVR, false);
ConfigKey(bool, is3D, false);
ConfigKey(float, tileSize, 2.0f);
ConfigKey(int, windowWidth, 1920);
ConfigKey(int, windowHeight, 1080);
ConfigKey(int, ticksPerSecond, 60);

This file will be #included twice within the Config struct:

  1. To define the actual members of the Config struct
  2. To populate an Attribute container with the keys that can be used in our final config file. This is the function that creates a semantic link between the string “useSteam” and the memory address of the member var Config.useSteam.

Config.h

#pragma once

#include <string>
#include <AttributeContainer.h>

struct Config
{
	Config(const char* filepath);

#define ConfigKey(type, varName, defaultVal) type varName = defaultVal;
#include <ConfigKeys.inl>
#undef ConfigKey

	AttributeContainer GetExpectedAttributes()
	{
		AttributeContainer attributeContainer;
#define ConfigKey(type, varName, defaultVal) attributeContainer.AddAttribute(#varName, &varName)
#include <ConfigKeys.inl>
#undef ConfigKey
		return attributeContainer;
	};
};

Config& GetConfig();

Now we define the function that will lazy load the config file and set attributes in the static Config object. I’m gonna keep the config parsing simple for now, and expect each variable in config.txt to be key=value separated by newlines.

Config.cpp

#include <Config.h>

#include <fstream>
#include <sstream>
#include <algorithm>

Config::Config(const char* filepath)
{
	AttributeContainer attributeContainer = GetExpectedAttributes();

	// load the config
	std::string line;
	std::ifstream myfile(filepath);
	if (myfile.is_open())
	{
		while (getline(myfile, line))
		{
			std::stringstream lineStream(line);
			std::string key, val;
			getline(lineStream, key, '=');
			getline(lineStream, val, '=');
			attributeContainer.SetAttribute(key, val);
		}
	}
}

Config& GetConfig()
{
	static Config config("config.txt");
	return config;
}

Config.txt

useSteam=true
windowWidth=2560
windowHeight=1440
ticksPerSecond=30

Accessing these values in the code base is simple:

Config& config = GetConfig();
if (config.useSteam)
{
	...
}
if (config.useVR)
{
	...
}

The thing I like the most about this implementation is that you can add support for literally any variable type you want. Got a new struct or enum? Just write a templated ParseFromString definition for it somewhere in your code. The v-table will figure the rest out for you:

void AttributeContainer::TemplatedAttributeWrapper<SillyStruct>::ParseFromString(const std::string& rawVal)
{
	// lets pretend that our struct vals are separated by commas or something
	std::stringstream lineStream(line);
	std::string key;
	getline(lineStream, key, ',');
	FromString(&m_floatMemberVar, key);
	getline(lineStream, key, ',');
	FromString(&m_stringMemberVar, key);
	getline(lineStream, key, ',');
	FromString(&m_OtherSillyStructMemberVar, key);
	getline(lineStream, key, ',');
	FromString(&m_vectorMemberVar, key);
}

Bonus: Making Your Config Thread Safe (using Blog Post #1)

Making the config thread safe is as simple as changing the definition of the members of the config struct. Each member variable’s data type will now be a LockedContainer, templated by its original data type. Note the changes in the #define ConfigKey macros:

#pragma once

#include <string>
#include <AttributeContainer.h>
#include <LockedContainer.h>

struct Config
{
	Config(const char* filepath);

#define ConfigKey(type, varName, defaultVal) LockedContainer<type> varName = defaultVal
#include <ConfigKeys.inl>
#undef ConfigKey

	AttributeContainer GetExpectedAttributes()
	{
		AttributeContainer attributeContainer;
#define ConfigKey(type, varName, defaultVal) attributeContainer.AddAttribute(#varName, &(*varName.Access()))
#include <ConfigKeys.inl>
#undef ConfigKey
		return attributeContainer;
	};
};

Config& GetConfig();

Now, accessing the variables will look a little different:

// read lock will be acquired during the lifetime of the windowWidthRO variable:
LockedContainer<int>::ReadContainer windowWidthRO = GetConfig().windowWidth.Get();
int VarThatOnlyReadsWindowWidth = *windowWidthRO + 27;
// write lock will be acquired during the lifetime of the windowWidthW variable:
LockedContainer<int>::WriteContainer windowWidthW = GetConfig().windowWidth.Access();
windowWidthW = 1366; // modifying window width (thread safe)

That wasn’t too bad, right? A fairly simple (thread safe) config system which allows you to define support for literally any data type. Tune in next week where I will completely change gears and mess around with lighting attenuation!