Usability issue with implicit/explicit status_code construction and assignment
jwtowner opened this issue · comments
Hi Niall!
So, we've run into an issue with the status_code
template class where it seems a little inconsistent with regards to how it only allows explicit construction from value types without a make_status_code
overload, while still allowing for implicit assignment from values of the same type.
Below is a code sample modeled after real world code that illustrates the problem. The sample fails to compile when attempting to construct the cc
object with the return value from calling CreateDXGIFactory2
. Note that further down in the sample function, it's perfectly fine to assign values of the same type as it uses the operator=
overload taking a const value_type&
argument.
Microsoft::WRL::ComPtr<ID3D12Device> InitializeDevice()
{
Microsoft::WRL::ComPtr<ID3D12Device> device;
// Obtain the DXGI factory
Microsoft::WRL::ComPtr<IDXGIFactory4> dxgiFactory;
system_error2::com_code cc = CreateDXGIFactory2(0, IID_PPV_ARGS(&dxgiFactory)); // <-- compilation error here, no implicit construction allowed with HRESULT value types
assert(cc.success());
// Create the D3D graphics device with the most dedicated video memory
Microsoft::WRL::ComPtr<IDXGIAdapter1> adapter;
SIZE_T maxSize = 0;
for(UINT adapterIndex = 0; DXGI_ERROR_NOT_FOUND != dxgiFactory->EnumAdapters1(adapterIndex, &adapter); ++adapterIndex)
{
DXGI_ADAPTER_DESC1 desc;
cc = adapter->GetDesc1(&desc);
if(cc.failure() || (desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) != 0 || desc.DedicatedVideoMemory <= maxSize)
continue;
cc = D3D12CreateDevice(adapter.Get(), D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&device));
if(cc.failure())
continue;
cc = adapter->GetDesc1(&desc);
if(cc.failure())
continue;
maxSize = desc.DedicatedVideoMemory;
}
return device;
}
It seems reasonable to expect that if assignment from a value_type
object compiles fine that initialization by assignment should also work without issue. The situation becomes further compounded when you also consider that if you create a custom status_code_domain
for an enum with an overloaded make_status_code
, implicit construction is enabled and initialization by assignment is allowed. So you end up with implicit construction of status_code
s working in some circumstances but not others.
It seems what we really want is to have the ability to enable implicit construction from a value_type
object on those status_code
s where you can't overload make_status_code
because the value_type
is an integral or fundamental type that is probably going to be used as the value_type
of other status_code_domain
s.
I took a stab at trying to solve this problem by allowing status_code_domain
to provide an optional is_explicitly_constructible
type alias of std::bool_constant
and using type traits to detect this and enable explicit or implicit constructors and an assignment operator taking a forwarding reference type T
, so long as std::is_convertible_v<T&&, value_type>
is also true. When is_explicitly_constructible
is not provided, it defaults to std::true_type
. It should probably also check to make sure T
isn't the status_code
instantiation itself, but not currently doing that. So basically, I've got something that looks like this in status_code
:
template <typename T, typename D = domain_type,
std::enable_if_t<
std::is_convertible_v<T&&, value_type> &&
detail::is_status_code_explicitly_constructible<D>::value, int> = 0>
constexpr explicit status_code(T&& v)
noexcept( std::is_nothrow_constructible_v<value_type, T&&> )
: _base(typename _base::_value_type_constructor{}, &domain_type::get(), static_cast<T&&>(v)) {}
template <typename T, typename D = domain_type,
std::enable_if_t<
std::is_convertible_v<T&&, value_type> &&
!detail::is_status_code_explicitly_constructible<D>::value, int> = 0>
constexpr status_code(T&& v)
noexcept(std::is_nothrow_constructible_v<value_type, T&&>)
: _base(typename _base::_value_type_constructor{}, &domain_type::get(), static_cast<T&&>(v)) {}
template <typename T, typename D = domain_type,
std::enable_if_t<
std::is_convertible_v<T&&, value_type> &&
!detail::is_status_code_explicitly_constructible<D>::value, int> = 0>
constexpr status_code& operator= T&& v)
noexcept(std::is_nothrow_assignable_v<value_type, T&&>)
{
this->_value = static_cast<T&&>(v);
return *this;
}
In C++20, we of course have explicit(bool)
and requires
at our disposal to aid in implementing the above, but that's the basic idea.
Looking at the current state of the library, I see now that there is a traits::is_move_bitcopying
template class, so I'm wondering if it might be better to externalize things and have a traits::is_explicitly_constructible<StatusCodeDomain>
, or a traits::is_implicitly_constructible_from_value_type<StatusCodeDomain>
or something else along those lines?
Another benefit with the above is that it also disables the assignment operator from a value_type
unless the is_explicitly_constructible
trait is set to std::false_type
. So now when assigning a value_type
object for which there is a make_status_code
overload, the compiler will instead use the implicit constructor that utilizes make_status_code
.
What are your thoughts on all of this? Do you think we could get something like this integrated into the status-code library and proposed design?
Firstly, thanks for raising this. The assignment operator for value_type
should never have been there in the first place, so I just removed it. The defaulted assignment operator would force assignment to run through the constructor overload set, which is what was originally intended.
Regarding the bigger picture you raise though, I think that, generally speaking, you never ever want implicit construction from integrals for status code, these ought to always be explicit. COM codes irritatingly can have the type of void*
, so that to me would rule out ever allowing implicit construction from them i.e. your construction by =
, I would insist on brackets. For your code segment above, I don't think it's much to ask for com_code(expr)
to wrap every COM code assigned, and lots of benefits in terms of less surprise comes with that. Implicit construction is a major source of unpleasant surprise.
I'll stop now, as it is past 11pm, and my brain has clearly turned into putty and I cannot say much which is useful. If removing the implicit assignment was not at all what you wanted, do let me know.
Now that I think about it some more, you're definitely right about implicit construction being a bad thing here, and removing it is probably the way to go. Thanks for pushing back on that feature request. I have removed the assignment operator and the explicit(bool) constructor I was experimenting with from our implementation as well, and set out to fix up our code base.
As I was fixing things up, I came to realize it would be really nice if we had a convenience function to create win32_code
s with the result of GetLastError()
. It could be a non-member utility function defined outside of the win32_code
class. It could be a static member function in win32_code
that would call GetLastError
and construct and return a win32_code
object. Alternatively, it could be another implicit constructor that takes a tag type, and does the same thing. I think it's a very minor improvement, but an improvement nonetheless, just so long as the name of the function or tag type is less than or equal to in character length with "win32_code{GetLastError()}." The name could be something like win32_code::last()
for the static member factory function or struct win32_last_code_t{}
for the tag type, etc.
Here are some examples.
Old code with status_code assignment operator:
win32_code code;
if (BOOL success = FlushViewOfFile(data, size); UNLIKELY(!success))
code = GetLastError();
if (BOOL success = UnmapViewOfFile(data); UNLIKELY(!success && !code.failure()))
code = GetLastError();
// more API calls closing mapping and file here
return code;
With the assignment operator removed:
win32_code code;
if (BOOL success = FlushViewOfFile(data, size); UNLIKELY(!success))
code = win32_code{GetLastError()};
if (BOOL success = UnmapViewOfFile(data); UNLIKELY(!success && !code.failure()))
code = win32_code{GetLastError()};
// more API calls closing mapping and file here
return code;
With a non-member factory function that calls GetLastError()
and returns a win32_code
:
win32_code code;
if (BOOL success = FlushViewOfFile(data, size); UNLIKELY(!success))
code = get_last_win32_code();
if (BOOL success = UnmapViewOfFile(data); UNLIKELY(!success && !code.failure()))
code = get_last_win32_code();
// more API calls closing mapping and file here
return code;
With a static member factory function that calls GetLastError()
and returns a win32_code
:
win32_code code;
if (BOOL success = FlushViewOfFile(data, size); UNLIKELY(!success))
code = win32_code::last();
if (BOOL success = UnmapViewOfFile(data); UNLIKELY(!success && !code.failure()))
code = win32_code::last();
// more API calls closing mapping and file here
return code;
With an implicit constructor taking a tag type named system_error2::win32_last_code_t
and tag value with same name minus the _t
suffix.
win32_code code;
if (BOOL success = FlushViewOfFile(data, size); UNLIKELY(!success))
code = win32_last_code;
if (BOOL success = UnmapViewOfFile(data); UNLIKELY(!success && !code.failure()))
code = win32_last_code;
// more API calls closing mapping and file here
return code;
Thoughts? It's okay if you think its too minor of a feature to bother. I imagine users of the library can always define their own non-member free function if they feel the need to do so.
Actually I had intended there to be a mixin for the win32_code which means default construction pulls the value from GetLastError()
. Again you have surprised me that code I had thought was the case is not. Sigh.
Thanks once again for pointing out a mistake. I'll fix that shortly.
Ok, there is now a static member function win32_code::current()
and posix_code::current()
. Does this solve your request?
Yep, those do the trick! Thanks!