More type safety with QSettings

The QSettings class is a very convenient utility that can save and recover application settings. It uses some native facilities for it (so in some cases it can even read system/third party settings, even though they are not a part of your application), it has an extension mechanism to support other storage formats, and has other small, useful features. While I find it easy to use, especially in simple cases, I’ve always found it a bit annoying to have a main API which uses a string for the key, and a QVariant for the value. In this blog post, we are going to see a way to fix that by using QMetaEnum and some C++ 17 features by creating a very thin wrapper.

Written by Alejandro Exojo
2022/1/24

The problematic API

I’ll assume that you have a very basic familiarity with QSettingsbut check the documentation if in doubt. We are going to use just the constructor and the two simplest functions anyway, so you don’t need to be an expert to follow along.

The API to get and set a value is the following:

This is great for ease of use, as you don’t need to define any kind of “schema” (“The user can set the window size preference and gets saved using this type by that key”), so you don’t need to prepare the source code in any way to introduce a new setting. You will typically edit the function where you need to save it, and the one where you need to load it back. Just make sure you use the same string in both places.

However, sometimes using the same string can be error prone if you want to change it later. You will have to be sure that the string is renamed everywhere correctly, for example, and be very wary of typos.

I typically try to plan ahead, and when possible I future proof my code like this (the code is a bit simplified, and it’s supposed to be in a QWidget::event reimplementation):

				
					static const auto splitterKey = QStringLiteral("QSplitterData");

auto loadInterface = [this]() {
    // ...
    auto splitter = findChild<QSplitter*>();
    QSettings settings;
    if (settings.contains(splitterKey))
        splitter->restoreState(settings.value(splitterKey).toByteArray());
    // ...
};

auto saveInterface = [this]() {
    QSettings settings;
    auto splitter = findChild<QSplitter*>();
    settings.setValue(splitterKey, splitter->saveState());
};

// Handle QEvent::Hide and QEvent::Show with this helper functions.
// ...
				
			

This is not terrible, but there is no certainty that I can have the string nicely in the same local scope, so it ends up in uglier places (the code works and will work well, but it is still unnecessarily ugly).

There is also the small issue that we have to cast the value returned by QSettings to the type that we actually want. In this example, the QSplitter gives us a byte array which we just pass to the setter function, QSettings::setValue. This function accepts only a QVariant, but the variant is just constructed implicitly, so we don’t type it out. But when we need to feed it back to the splitter we need to convert it, which is bothersome.

Solving the string key

Avoiding the strings is fairly easy, and there are multiple ways to do it. I would not be surprised if many projects just have a table that maps enum values to strings to be used as keys, which is perfectly fine.

But we can make it happen automatically! Qt has a feature to generate strings from enum values and the other way around by using the QMetaEnum class. The use is pretty straightforward, and we’ll show it in action in the first snippet of the class that we are gonna improve step by step:

				
					// The namespace and the enum would typically be in a different file, of course.
namespace Setting
{
    Q_NAMESPACE
    enum Value { One = 1, Two, Three, Four };
    Q_ENUM_NS(Value)
}

class Settings : private QSettings
{
    Q_OBJECT
public:
    using QSettings::QSettings;

    void setValue(Setting::Value key, const QVariant& value)
    {
        const QMetaEnum meta = QMetaEnum::fromType<Setting::Value>();
        const QString text = QString::fromLatin1(meta.valueToKey(key));
        QSettings::setValue(text, value);
    }

    QVariant value(Setting::Value key) const
    {
        const QMetaEnum meta = QMetaEnum::fromType<Setting::Value>();
        const QString text = QString::fromLatin1(meta.valueToKey(key));
        return QSettings::value(text);
    }
};
				
			

The class does the usual trick of inheriting from another privately (which hides absolute everything from the base class), then exposing the parts that we want like the constructor through a using declaration. We’ll slowly improve this class through the text.

The value and setValue functions are fairly easy to understand, both doing the same QMetaEnum use to map from the passed enum to a string. Progress! Note that we show how we can have enumerations outside of a class that are also enhanced by MOC if we put them in a namespace and use the macros shown above.

We can test that it works with something like this:

				
					int main()
{
    Settings settings(QDir::tempPath() + "/settings-through-enum.ini", QSettings::IniFormat);
    settings.setValue(Setting::One,   1);
    settings.setValue(Setting::Two,   "2");
    settings.setValue(Setting::Three, 3.14);
    settings.setValue(Setting::Four,  true);

    auto print = [&](Setting::Value setting) {
        const QMetaEnum meta = QMetaEnum::fromType<Setting::Value>();
        const QString text = QString::fromLatin1(meta.valueToKey(setting));
        const QVariant value = settings.value(setting);
        qDebug() << text << value;
    };
    print(Setting::One);
    print(Setting::Two);
    print(Setting::Three);
    print(Setting::Four);
}

// If you put the previous class in a "main.cpp" for testing, you'll need to
// include the moc-generated file for Q_OBJECT to work!
#include "main.moc"
				
			

Note the comment at the end of the file. And yes, the main doesn’t return anything despite its signature and doesn’t take any parameters (both are fine and standard, though not everyone knows). Now we can compile and run the program, which will produce this output:

				
					"One" QVariant(int, 1)
"Two" QVariant(QString, "2")
"Three" QVariant(double, 3.14)
"Four" QVariant(bool, true)
				
			

Totally expected, but note that we inserted primitive types and get variants in return. We can cast the returned result to the type that we want, but this is different for each setting, which is error prone and we want to avoid it. So now we’ll apply some vanilla C++ magic from the newer standards to make it better.

Different return types? Why not!

C++ 17 added if constexpr, a powerful way to make decisions at compile time. Since we know ahead of compilation that we want Setting::One to store an integer, or Setting::Four to store a boolean, this is perfect for us. We can simply add the following:

				
					template<Setting::Value key>
auto value()
{
    if constexpr (key == Setting::One) {
        return value(key).toInt();
    } else if constexpr (key == Setting::Two) {
        return value(key).toString();
    } else if constexpr (key == Setting::Three) {
        return value(key).toDouble();
    } else if constexpr (key == Setting::Four) {
        return value(key).toBool();
    }
}
				
			
Excellent! Now settings.value<Setting::One>() prints exactly 1 instead of QVariant(int, 1), because the function returns something different for each enum value. Of course, we’ve achieved that through a function template, as a regular function should always return the same type. The template just produces 4 different functions for us, each with a different return type and having just the body. Often templates take types as template parameters (the ones inside the less-than and greater-than symbols), like QList<int> (the parameter is always a type for this class template). But in this case we are passing a “non-type template parameter”, which happens to be a specific value at compile time, so the code works as naturally as it reads. This is similar to how an std::array<int, 42> is decided at compile-time to have exactly 42 items.

Scaling to more settings

The above implementation is a nice trick, but we only support 4 entries, and it’s a nasty chain of else if already since we don’t have switch constexpr. If we had a hundred settings, we would have to maintain that long list of conditions, maybe grouping them by type, hopefully sorting the settings by type to make it less painful.

But what if we had a table of entries with the types of each setting? That would solve the maintainability problem quite a bit, as we could just look at a nice table. But can we have a container storing enums and types? I know that for sure exist libraries for template metaprogramming that can achieve that feat, but I don’t want to add a library, nor I want to learn metaprogramming at that level, to be honest.

So we’ll settle for using an enum to specify the type as well, and to no surprise QMetaType and its Type nested enum appear just as we are attempting to hide QVariant away. We will use the values of QMetaType::Type to store the intended types. We can add the following as a private detail of our class:

				
					struct Mapping { Setting::Value value; QMetaType::Type type; };
constexpr static Mapping s_mapping[] = {
    { Setting::One,        QMetaType::Int },
    { Setting::Two,        QMetaType::QString },
    { Setting::Three,      QMetaType::Double },
    { Setting::Four,       QMetaType::Bool },
};
				
			

We need to make it a static constexpr to be usable in a constant expression in our new implementation of the value function. And we went with a manual struct and array because of the very strict requirements of a constant expression. This is how the updated function looks:

				
					template<Setting::Value key>
auto value()
{
    constexpr QMetaType::Type type = [&]() {
        for (const Mapping& mapping : s_mapping)
            if (mapping.value == key)
                return mapping.type;
        return QMetaType::Void;
    }();

    if constexpr (type == QMetaType::Bool) {
        return value(key).toBool();
    } else if constexpr (type == QMetaType::Int) {
        return value(key).toInt();
    } else if constexpr (type == QMetaType::Double) {
        return value(key).toDouble();
    } else if constexpr (type == QMetaType::QString) {
        return value(key).toString();
    } else {
        return value(key);
    }
}
				
			

The function now uses another popular trick for producing constants, a lambda which gets called as soon as it gets defined. This produces a constant expression QMetaType::Type variable that we can use in the constexpr if conditions. We have the same number of conditions, but note that we typically will have not as many different types to return as we have settings, so we are checking the types of the values and not the values themselves, so we’ve improved a bit.

A small thing to notice here is that we have an else at the end that, if doesn’t find any expected type to convert to, just returns the “normal” variant type without casting to anything else. This allows a kind of “opt-in” mechanism to avoid having to write all the pairs of values and types, if needed.

Still, this solution has another problem, which is that our class now depends on the enumeration that we had defined externally before, and is also limited to just one possible enum type. Maybe we want parts of the project to use different enumerations, in different name spaces, etc.

Reaching the preferred implementation

First, we need to transform value and setValue into a template, given that we want them to deal with potentially different enumeration types:
				
					template<typename Enum>
void setValue(Enum key, const QVariant& value)
{
    const QMetaEnum meta = QMetaEnum::fromType<Enum>();
    const QString text = QString::fromLatin1(meta.valueToKey(key));
    QSettings::setValue(text, value);
}

template<typename Enum>
QVariant value(Enum key) const
{
    const QMetaEnum meta = QMetaEnum::fromType<Enum>();
    const QString text = QString::fromLatin1(meta.valueToKey(key));
    return QSettings::value(text);
}
				
			

Next, we need to still have a separate table to be able to retrieve the “metadata” on the enum. There are many ways in which we could do this, probably including some kind of template specialization to pick up the right value. But there has already been too many C++ tricks today. Let’s do a good old C or assembler trick, and play with bits in numbers. We are not going to bother with portability, though, so this is only for 64 bit PCs, for the sake of the exposition.

We are lucky that both in Qt 5 and Qt 6, QMetaType::Type is defined with an int as the “underlying type”. It’s not explicitly defined as int in the code, but some functions of the QVariant or QMetaType API return int, and by reading the declaration, the compiler should pick up int given that everything fits there, and it’s the default choice. We can just declare our enum for the settings with long as underlying type and bundle together the 4 bytes of QMetaEnum::Type in the upper half of a long, which still leaves 4 bytes to have millions of different names and values for our settings. We could find many ways to use smaller values (by creating our own enum with the types that we want instead of using the one in Qt, for example), but let’s go with the first approach.

				
					namespace Setting
{
    Q_NAMESPACE
    enum Value : long {
        One   = 1 | (long(QMetaType::Int) << 32),
        Two   = 2 | (long(QMetaType::QString) << 32),
        Three = 3 | (long(QMetaType::Double) << 32),
        Four  = 4 | (long(QMetaType::Bool) << 32),
    };
    Q_ENUM_NS(Value)
}

namespace OtherSetting
{
    Q_NAMESPACE
    enum Value : long {
        Foo = 1 | (long(QMetaType::Bool) << 32),
        Bar = 2 | (long(QMetaType::Int) << 32),
        Baz = 3 | (long(QMetaType::QString) << 32),
    };
    Q_ENUM_NS(Value)
}
				
			

This is not the most impressive piece of code, but it shows the intent. The values of our settings enumeration are written explicitly this time, and we add the numbers (the 1, 2, 3, 4 which will unlikely be higher than what fits in 4 bytes, so it will never need more than the first half of a long) with the types, which have their bits shifted 32 positions (4 bytes) to fill the second half of a long.

We could use a macro to simplify the repetition, or provide a custom enum which already is using only the upper bits to not have to shift them each time. We’ll leave that as an exercise for the reader for now.

Let’s focus on how to use it. First, make a helper that returns the bits that we want from an enum:

				
					template<auto key>
constexpr static int storageType()
{
    using Enum = decltype(key);
    static_assert(std::is_enum_v<Enum>, "The template parameter is not an enum");
    static_assert(std::is_same_v<std::underlying_type_t<Enum>, long>, "The enum should be of underlying type 'long'");

    // Split the value in two, and keep the upper half (4 bytes, hence 4 FF or 00)
    long result = (key & 0xFF'FF'FF'FF'00'00'00'00);
    // Shift them to make them like QMetaType again.
    result >>= 32;
    return static_cast<int>(result);
}
				
			

This uses even more niceties from newer C++ versions:

  • template<auto key> can be used instead of the more verbose template<typename Enum, Enum key> to pass a non-type template parameter, and be able to still get its type (which here is achieved on the first line of the template with decltype(key)).

  • We use some static assertions to ensure at compile time that the passed value is an enum, and has the wanted underlying type, long.

  • The helpers from the standard is_enum_vis_same_v and underlying_type_t are essentials in C++ meta-programming (I am not going to go more into these for this post, I just enjoyed using them). Go watch Walter Brown’s talk on the topic if you want to learn more.

  • We see clearly the 4 bytes that we want to either see or clear with the digit separator (the apostrophe) introduced in C++ 14.

Now we have reached our final version of value, which uses the same feature (auto key) for the template argument, and uses the previous helper function to get the type as a compile time integer. The rest of it looks the same.

				
					template<auto key>
constexpr static int storageType()
{
    using Enum = decltype(key);
    static_assert(std::is_enum_v<Enum>, "The template parameter is not an enum");
    static_assert(std::is_same_v<std::underlying_type_t<Enum>, long>, "The enum should be of underlying type 'long'");

    // Split the value in two, and keep the upper half (4 bytes, hence 4 FF or 00)
    long result = (key & 0xFF'FF'FF'FF'00'00'00'00);
    // Shift them to make them like QMetaType again.
    result >>= 32;
    return static_cast<int>(result);
}
				
			

Now, use the function as we have aimed for from the start.

				
					settings.setValue(Setting::One,   1);
settings.setValue(Setting::Two,   "2");
settings.setValue(Setting::Three, 3.14);
settings.setValue(Setting::Four,  true);

settings.setValue(OtherSetting::Foo, true);
settings.setValue(OtherSetting::Bar, 42);
settings.setValue(OtherSetting::Baz, "txt");

qDebug() << settings.value<Setting::One>();      // 1
qDebug() << settings.value<Setting::Two>();      // "2"
qDebug() << settings.value<Setting::Three>();    // 3.14
qDebug() << settings.value<Setting::Four>();     // true

qDebug() << settings.value<OtherSetting::Foo>(); // true
qDebug() << settings.value<OtherSetting::Bar>(); // 42
qDebug() << settings.value<OtherSetting::Baz>(); // "txt"
				
			

Caveats and exercises to the reader or future-self

There are a few things that could or even should be added in order to make this a nice class to be used in a more real world scenario:

  • The default value on Settings::value is not provided (QSettings::value allows a second argument that will be returned when the class can’t find the value stored on the settings, instead of the default constructed QVariant).

  • We don’t expose the API for arrays or groups that QSettings has.

  • We’ve used private inheritance in order to hide the API of QSettings that we wanted to avoid, but we will need to expose anything else deemed worthy.

  • We’ve used QMetaType::Void for “I don’t care”, but we could have used QMetaType::Unknown or just use our own enumerations, as explained before. The different types in QMetaType::Type are quite numerous, and might lead to interesting problems if used without care (like signed versus unsigned, etc.). I would certainly be pleased to get some feedback on this.

  • We could upgrade the static assertions to use concepts from C++ 20. But this last version of the standard is a huge upgrade, probably as big as C++ 11, so I don’t expect it to be vastly available, even in 2022. Everything else is 17 or older, so I’m happy with it for now.

Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments