C++ For C# Developers: Part 31 – Deconstructing and Attributes
Both languages have both deconstructing (var (x, y) = vec;
) and attributes ([MyAttribute]
). C++ differs from C# in several ways, so today we’ll take a look at those differences and learn how to make use of these language features.
Table of Contents
- Part 1: Introduction
- Part 2: Primitive Types and Literals
- Part 3: Variables and Initialization
- Part 4: Functions
- Part 5: Build Model
- Part 6: Control Flow
- Part 7: Pointers, Arrays, and Strings
- Part 8: References
- Part 9: Enumerations
- Part 10: Struct Basics
- Part 11: Struct Functions
- Part 12: Constructors and Destructors
- Part 13: Initialization
- Part 14: Inheritance
- Part 15: Struct and Class Permissions
- Part 16: Struct and Class Wrap-up
- Part 17: Namespaces
- Part 18: Exceptions
- Part 19: Dynamic Allocation
- Part 20: Implicit Type Conversion
- Part 21: Casting and RTTI
- Part 22: Lambdas
- Part 23: Compile-Time Programming
- Part 24: Preprocessor
- Part 25: Intro to Templates
- Part 26: Template Parameters
- Part 27: Template Deduction and Specialization
- Part 28: Variadic Templates
- Part 29: Template Constraints
- Part 30: Type Aliases
- Part 31: Deconstructing and Attributes
- Part 32: Thread-Local Storage and Volatile
- Part 33: Alignment, Assembly, and Language Linkage
- Part 34: Fold Expressions and Elaborated Type Specifiers
- Part 35: Modules, The New Build Model
- Part 36: Coroutines
- Part 37: Missing Language Features
- Part 38: C Standard Library
- Part 39: Language Support Library
- Part 40: Utilities Library
- Part 41: System Integration Library
- Part 42: Numbers Library
- Part 43: Threading Library
- Part 44: Strings Library
- Part 45: Array Containers Library
- Part 46: Other Containers Library
- Part 47: Containers Library Wrapup
- Part 48: Algorithms Library
- Part 49: Ranges and Parallel Algorithms
- Part 50: I/O Library
- Part 51: Missing Library Features
- Part 52: Idioms and Best Practices
- Part 53: Conclusion
Structured Bindings
“Deconstructing” in C# is the process by which we extract the fields out of a struct or class and into individual variables. It looks like this:
// C# // A type we want to deconstruct struct Vector2 { public float X; public float Y; // Create a Deconstruct method that takes an 'out' param for each variable // to deconstruct into and returns void public void Deconstruct(out float x, out float y) { x = X; y = Y; } } // Instantiate the deconstructable type var vec = new Vector2{X=2, Y=4}; // Deconstruct. Implicitly calls vec.Deconstruct(out x, out y). // x is a copy of vec.X and y is a copy of vec.Y var (x, y) = vec; DebugLog(x, y); // 2, 4
In C++ terminology, we don’t “deconstruct” but rather “create structured bindings.” Here’s the equivalent code:
// A type we want to create structured bindings for struct Vector2 { float X; float Y; }; // Instantiate that type Vector2 vec{2, 4}; // Create structured bindings. x is a copy of vec.X and y is a copy of vec.Y. auto [x, y] = vec; DebugLog(x, y); // 2, 4
So far it’s essentially the same in the two language except for two changes. First, C++ uses square brackets ([x, y]
) instead of parentheses ((x, y)
). Second, C++ doesn’t require us to write a Deconstruct
function. Instead, the compiler simply uses the declaration order of the fields of Vector2
so that x
lines up with X
and y
with Y
. This mirrors initialization where Vector2{2, 4}
initializes the data members in declaration order: X
then Y
.
This can be customized to by “tuple-like” types, but it takes more work than in C#:
struct Vector2 { float X; float Y; // Get a data member of a const vector template<std::size_t Index> const float& get() const { // Assert the only two valid indices static_assert(Index == 0 || Index == 1); // Return the right one based on the index if constexpr(Index == 0) { return X; } return Y; } // Get a data member of a non-const vector template <std::size_t Index> float& get() { // Cast to const so we can call the const overload of this function // to avoid code duplication const Vector2& constThis = const_cast<const Vector2&>(*this); // Call the const overload of this function // Returns a const reference to the data member const float& constComponent = constThis.get<Index>(); // Cast the data member to non-const // This is safe since we know the vector is non-const float& nonConstComponent = const_cast<float&>(constComponent); // Return the non-const data member reference return nonConstComponent; } }; // Specialize the tuple_size class template to derive from integral_constant // Pass 2 since Vector2 always has 2 components template<> struct std::tuple_size<Vector2> : std::integral_constant<std::size_t, 2> { }; // Specialize the tuple_element struct to indicate that index 0 of Vector2 has // the type 'float' template<> struct std::tuple_element<0, Vector2> { // Create a member named 'type' that is an alias for 'float' using type = float; }; // Same for index 1 template<> struct std::tuple_element<1, Vector2> { using type = float; }; // Usage is the same Vector2 vec{2, 4}; auto [x, y] = vec; DebugLog(x, y);
The result of the above code is that Vector2
is now a “tuple-like” type, usable with structured bindings and several generic algorithms of the C++ Standard Library.
The final kind of object we can create structured bindings for is an array. This isn’t allowed by default in C#:
// Create an array of two int elements int arr[] = { 2, 4 }; // Create structured bindings. x is a copy of arr[0] and y is a copy of arr[1]. auto [x, y] = arr; DebugLog(x, y); // 2, 4
It’s important to note that the structured bindings we’ve been creating have to be automatically-typed with auto
. Even if we know the type, we can’t use it:
int arr[] = { 2, 4 }; // Compiler error: must use the 'auto' type here int [x, y] = arr;
What we are allowed to do is create structured binding references with auto&
:
int arr[] = { 2, 4 }; // Create copies of arr[0] and arr[1] auto [xc, yc] = arr; // Create references to arr[0] and arr[1] auto& [xr, yr] = arr; // Modify the elements of the array arr[0] = 20; arr[1] = 40; DebugLog(xc, yc); // 2, 4 DebugLog(xr, yr); // 20, 40
The same is also true for rvalue references via auto&&
:
Vector2 Add(Vector2 a, Vector2 b) { return Vector2{a.X+b.X, a.Y+b.Y}; } // Compiler error: return value of Add isn't an lvalue so can't take lvalue ref auto& [x, y] = Add(Vector2{2, 4}, Vector2{6, 8}); // OK auto&& [x, y] = Add(Vector2{2, 4}, Vector2{6, 8}); DebugLog(x, y); // 8, 12
Both forms of references as well as copies may also be const
:
Vector2 vec{2, 4}; // Constant copy const auto [xc, yc] = vec; // Constant lvalue reference const auto& [xr, yr] = vec; // Constant rvalue reference const auto&& [xrr, yrr] = Add(Vector2{2, 4}, Vector2{6, 8});
We can also use other forms of initialization when creating structured bindings. So far we’ve copy-initialized our variables with =
but we can also direct-initialize them with {}
and ()
:
Vector2 vec{2, 4}; // Copy-initialize auto [x1, y1] = vec; // Direct-initialize with curly braces auto [x2, y2]{vec}; // Direct-initialize with parentheses auto [x3, y3](vec);
Finally, C# supports ignoring some deconstructed variables with “discards” in the form of _
. This isn’t supported in C++, so we’ll need to explicitly name and ignore them to avoid a compiler warning:
Vector2 vec{2, 4}; auto [x, y] = vec; static_cast<void>(x); // One way of explicitly ignoring x (void)x; // Another way of ignoring x // Use only y DebugLog(y); // 4
Attributes
C# attributes associate some metadata with an entity like a class, a method, or a parameter. This metadata can be used in one of two ways. First, the compiler can query attributes like [MethodImplAttribute(MethodImplOptions.AggressiveInlining)]
to modify how compilation works. Second, our C# code can query attributes at runtime via reflection to make use of it in various custom ways.
As C++ doesn’t support reflection, it’s usage of attributes doesn’t cover the runtime use cases. It does, however, cover the compile-time use cases. Because this functionality is implemented by the compiler, not us, we can’t create custom attributes. Instead, we use two built-in sets of attributes. First, there are several attributes defined by the C++ language:
Attribute | Version | Meaning |
---|---|---|
[[noreturn]] |
C++11 | This function will never return |
[[carries_dependency]] |
C++11 | Unnecessary memory fence instructions for this can be removed in some situations |
[[deprecated]] |
C++14 | This is deprecated |
[[deprecated("why")]] |
C++14 | This is deprecated for a specific reason |
[[fallthrough]] |
C++17 | This case in a switch intentionally falls through to the next case |
[[nodiscard]] |
C++17 | A compiler warning should be generated if this is ignored |
[[nodiscard("msg")]] |
C++20 | A compiler warning with a particular message should be generated if this is ignored |
[[maybe_unused]] |
C++17 | No compiler warning should be generated for not using this |
[[likely]] |
C++20 | This branch is likely to be taken |
[[unlikely]] |
C++20 | This branch is unlikely to be taken |
[[no_unique_address]] |
C++20 | This non-static data member doesn’t need to have a unique memory address |
There are two aspects of these to take notice of. First, and trivially, C++ attributes use two square brackets ([[X]]
) instead of one in C# ([X]
). Second, all of the attributes are for one of two purposes: controlling compiler warnings and optimizing generated code.
The second set of attributes is compiler-specific and not part of the C++ standard. Clang, for example, has a ton of them for various purposes. If these are ever specified and the compiler doesn’t support them, the’re simply ignored.
Now let’s look at some code that uses them:
class File { FILE* handle = nullptr; public: ~File() { if (handle) { ::fclose(handle); } } // Generate a compiler warning if the return value is ignored [[nodiscard]] bool Close() { if (!handle) { return true; } return ::fclose(handle) == 0; } // Generate a compiler warning if the return value is ignored [[nodiscard]] bool Open(const char* path, const char* mode) { if (!handle) { // No compiler warning because return value is used if (!Close()) { return false; } } handle = ::fopen(path, mode); return handle != nullptr; } }; File file{}; // Compiler warning: return value ignored file.Open("/path/to/file", "r"); // Compiler warning: unused variable bool success = file.Open("/path/to/file", "r"); // No compiler warning: suppress the unused variable [[maybe_unused]] bool success = file.Open("/path/to/file", "r"); // No compiler warning because return value is used if (!file.Open("/path/to/file", "r")) { DebugLog("Failed to open file"); }
To use more than one attribute at a time, add commas like when declaring multiple variables at a time:
// Both [[deprecated("why")]] and [[nodiscard]] [[deprecated("Wasn't very good. Use Hash2() instead."), nodiscard]] uint32_t Hash(const char* bytes, std::size_t size) { uint32_t hash = 0; for (std::size_t i = 0; i < size; ++i) { hash += bytes[i]; } return hash; }
For attributes in namespaces, such as provided by compilers, we use the familiar scope resolution operator (::
) to refer to them:
// Use the "nodebug" attribute in the "gnu" namespace // Do not generate debugging information for this function [[gnu::nodebug]] float Madd(float a, float b, float c) { return a * b + c; }
When using multiple attributes in namespaces, there’s a shortcut since C++17 that avoids duplicating the namespace name:
// Both [[gnu::returns_nonnull]] and [[gnu::nodebug]] [[using gnu: returns_nonnull, nodebug]] void* Allocate(std::size_t size) { if (size < 4096) { return SmallAlloc(size); } return BigAlloc(size); }
Attributes can appear in a great many places in C++: variables, functions, names, blocks, return values, and so forth. If adding the attribute makes logical sense, it’s probably allowed.
Conclusion
C++ structured bindings are a different take on C#’s deconstructing. We don’t need to write any code at all to create structured bindings for structs and arrays. For tuple-like types, we have to write quite a bit more than a Deconstruct
method in C#. Thankfully, that’s rarely needed due to the presence of the std::tuple
class template in the Standard Library which is obviously tuple-like and therefore supports deconstructing.
C++ attributes are one of the rare areas of the language that’s actually less powerful than its C# counterpart. It fulfills compile-time purposes such as by controlling warnings and optimization, but it doesn’t support any run-time use cases due to the lack of reflection in the language. Third-party libraries (example) are required to add on run-time reflection if needed, but they’re not integrated into the core language. This may change in C++23 or another future version as there has been much work on integrating compile-time reflection into the language.
#1 by typoman on March 20th, 2021 ·
typo: “a Destructure method in C#” -> “a Deconstruct method in C#”. (mb in a cpl o places)
#2 by jackson on March 20th, 2021 ·
Thanks for letting me know! I’ve updated the article to fix the terminology.
#3 by DJ on April 24th, 2021 ·
Typo in
Awesome series btw
#4 by jackson on April 24th, 2021 ·
Thanks for pointing this out. I’ve updated the article with a fix. I’m glad you’re enjoying the series!