strong_type
strong_type copied to clipboard
What is the suggested way to implements constructor invariant checking for a strong type?
After hesitating to use a strong type library for production code for quite a long time, I just started using this library in one of my projects.
I've decided in favour of this library, due to its broad C++ language support and due to the number of contributors. Using this library is easy and the documentation in the README is concise but good.
I do wonder though, if it is possible to add constructor invariant checking using this library. I haven't been able to find anything in the documentation, that's why I'm asking this question.
Consider that I'd like to implement a strong type FirstName, that does not allow to store an empty ("") name.
The best code I've been able to come up with is this:
class FirstName final : public strong::type<std::string, struct FirstName_, strong::equality, strong::ostreamable> {
public:
explicit FirstName(std::string value)
: type<std::string, FirstName_, strong::equality, strong::ostreamable> { std::move(value) }
{
if (_val.empty()) {
throw std::invalid_argument { "The value of 'FirstName' may not be empty." };
}
}
};
This could be "simplified" as follows, with the cost of having a useless visible type alias.
using FirstNameBase = strong::type<std::string, struct FirstNameBase_, strong::equality, strong::ostreamable>;
class FirstName final : public FirstNameBase {
public:
explicit FirstName(std::string value)
: FirstNameBase { std::move(value) }
{
// Check constructor invariant.
if (_val.empty()) {
throw std::invalid_argument { "The value of 'MyStrongType' may not be empty." };
}
}
};
- Is this the correct approach using this library?
- Is there an alternative way (maybe I missed a modifier) to implement this kind of behaviour using this library?
- Is there an easy way to reuse something like this as a "NotEmpty" modifier?
Edit: After updating this question, I assume I've to look into the topic of custom modifiers.
There is no "correct" approach. It depends. I've chosen to not implement it because I don't know how to make a good policy handler that is generic and will always work for all underlying types. You're handling construction, but not assignment, for example. You're choosing to throw invalid_argument, which makes sense, but is not the only option available. And ,of course, there is the Machiavellian usege of value_of(my_obj).clear() which I don't see how it can be dealt with by the library.
I think you'd probably be better off turning the structure inside out. Start with implementing a non_empty_string class, and make different strong types using it.
Thank you for the helpful comment. You're completely right regarding the potential of unwanted modification of the underlying string that would be a violation.
I think I am in search for a general abstraction (that may not exist) that allows to specify arbitrary constraints for the underlying type, e.g.
- A strong
std::uint8_tthat shall be less than or equal to150. - A strong
std::uint8_tthat shall be any ofstd::vector{std::uint8_t{10}, std::uint8_t{20}, std::uint8_t{30}}. - A strong
std::stringthat shall be non-empty. - A strong
std::stringthat shall only consists of basic ASCII characters. - etc.
and that allows to protect the underlying type from violations of the constraints.
As I'm writing this, I remember that I had a similar (great) discussion in the foonathan/type_safe project regarding the topic of constraints back in 2021. 😄
It seems I've to take a step back and figure out what's the best approach for me using "strong_type", in order to implement requirements such as the ones mentioned above in a safe manner, without the need to write too much boilerplate code.
Edit 1: Ops, I just noticed that _val is always public due to https://github.com/rollbear/strong_type/pull/33. Doesn't this makes it effectively impossible to enforce invariants for non-const types created with this library?
Edit 2: It seems I should have performed more basic tests using this library. The following behaviour (both tests are green) of this library look pretty unconventional to me:
TEST(StrongTypeTest, MutableInstance)
{
auto firstName = FirstName{"Jane"};
ASSERT_EQ("Jane", value_of(firstName));
value_of(firstName) = "ImoBroken#1";
ASSERT_EQ("ImoBroken#1", value_of(firstName));
firstName._val = "ImoBroken#2";
ASSERT_EQ("ImoBroken#2", value_of(firstName));
value_of(firstName).clear();
// ImoBroken#3
ASSERT_EQ("", value_of(firstName));
auto firstName2 = firstName;
// ImoBroken#4
ASSERT_EQ("", value_of(firstName2));
}
For my understanding, the underlying type should always be immutable (not only if the strong type is created as const). Being capable of doing the stuff I do above in the test, looks pretty weird to me. In other words: I would not expect to have non-const variants of value_of and a public _val.
I assume this is the desired behaviour in "strong_type", so maybe I've been to rash with the strong type library decision.
Just an update regarding my last comment.
I was basically able to implement what I required in my previous comment, by "turning the structure inside out" as suggested by @rollbear. Thanks again!
Though, this comes with the cost of an additional indirection, and does not protect from direct modification via _val. But: The latter can be worked around by declaring each and every Strong Type as const. It seems that with the following code, that at least the class invariant can be preserved.
Maybe the following example is helpful for people who are interested in this project and also try to implement constraint checking. Note though, that this needs additional testing (and tweaking for more complex use cases).
/// @remarks This class template implements the *Curiously Recurring Template Pattern* (CRTP).
template <typename Derived, typename Value>
class ConstrainedType {
public:
using UnderlyingValue = Value;
explicit ConstrainedType(Value value)
: value_ { std::move(value) }
{
static_cast<Derived const&>(*this).checkConstraints(value_);
}
[[nodiscard]] auto get() const noexcept -> Value const&
{
return value_;
}
friend auto operator==(ConstrainedType const& lhs, ConstrainedType const& rhs) -> bool
{
return lhs.value_ == rhs.value_;
}
friend auto operator!=(ConstrainedType const& lhs, ConstrainedType const& rhs) -> bool
{
return !(lhs == rhs);
}
friend auto operator<<(std::ostream& os, ConstrainedType const& obj) -> std::ostream&
{
return os << obj.value_;
}
private:
Value value_;
};
class NonEmptyString final : public ConstrainedType<NonEmptyString, std::string> {
public:
using ConstrainedType<NonEmptyString, std::string>::ConstrainedType;
private:
auto checkConstraints(UnderlyingValue const& value) const -> void
{
if (value.empty()) {
throw std::invalid_argument("A 'NonEmptyString' cannot hold an empty value.");
}
}
friend ConstrainedType<NonEmptyString, std::string>;
};
template <typename UnderlyingValue, UnderlyingValue lowerBound, UnderlyingValue upperBound>
class ValueInClosedInterval final : public ConstrainedType<ValueInClosedInterval<UnderlyingValue, lowerBound, upperBound>, UnderlyingValue> {
public:
using ConstrainedType<ValueInClosedInterval, UnderlyingValue>::ConstrainedType;
private:
auto checkConstraints(UnderlyingValue const& value) const -> void
{
if (value < lowerBound || value > upperBound) {
throw std::invalid_argument {
(boost::format("The value must be inside [%1%, %2%].") % +lowerBound % +upperBound).str()
};
}
}
friend ConstrainedType<ValueInClosedInterval, UnderlyingValue>;
};
using LastName = strong::type<NonEmptyString, struct LastName_, strong::equality, strong::ostreamable>;
using Percent = strong::type<ValueInClosedInterval<std::uint8_t, std::uint8_t { 0 }, std::uint8_t { 100 }>, struct Range_, strong::equality, strong::ostreamable>;
TEST(LastNameTest, Demonstration)
{
auto lastName = LastName { "Doe" };
ASSERT_EQ(std::string { "Doe" }, value_of(lastName).get());
auto const lastName2 = lastName;
ASSERT_EQ(std::string { "Doe" }, value_of(lastName2).get());
// This breaks encapsulation ...
lastName._val = NonEmptyString { "Incorrect" };
// ... but at least the class invariant is preserved!
ASSERT_EQ(std::string { "Incorrect" }, lastName.value_of().get());
ASSERT_NO_THROW(auto const lastName3 = lastName);
}
TEST(PercentTest, Demonstration)
{
auto const percent = Percent { 50 };
ASSERT_EQ(std::uint8_t { 50 }, value_of(percent).get());
ASSERT_THROW(Percent { 101 }, std::invalid_argument);
}
Sorry, for some reason I completely missed that you added things here.
You may want to have a look at branch immutable, where a new modifier strong::immutable is added. I think it's a good idea, but I would like more input.
This is a fundamentally hard problem. If/when we get contracts to C++, we will at least have a standardized way of expressing constraints and handling violations, which may open up ways of encapsulating constraints in a library. Before that, I think it's very difficult.
No worries! Thanks for the link regarding strong::immutable. I'll try to look at it if I find some time.
After thinking about this in general, I came to the conclusion that I want to use traits such as strong::immutable and std::regular (without default construction!) combined for approx. 99% of my Strong Types. Effectively, this makes a Strong Type a Value Object.
This is a fundamentally hard problem.
This is very true. The more I utilize Strong Types, the more I'd like to be able to constraint them. It would be very nice to constraint a type with one or more arbitrary constraints, without the need to add boilerplate code or more indirections.
As it is now, I need to write distinct types for each combination (NonEmptyString with AsciiString AND-combined would required a new type NonEmptyAsciiString). Another problem is that each newly introduced "backend" type (from the POV of the Strong Type) adds one indirection by introducing a value method (I don't want to use implicit conversions), i.e. obtaining the underlying type results in code such as value_of(strongObj).value().
This approach does work, but doesn't scale well at all.
The "dream" would be a solution that allows to restrict the values of the type and pick an error Strategy. The following come to my mind:
- configurable exception type (default:
std::invalid_argument) std::expected+std::error_codeassert- ...
The constraint itself could also be implemented using different *Strategies`, that also should be combinable:
- Value in interval (closed, open, left half-open, right half-open)
- Value equal, not equal, greater than, greater than or equal, less than, less than or equal
- Value in set ("any of")
- Value not empty
- Value not null
- Value matches regular expression
- Value matches custom check(s)
It seems the latter is implemented in type_safe as building blocks (maybe without the possibility of combination).