From 7f3358ffc5d4d0cb511a7b76c5f3e25d3b864dd3 Mon Sep 17 00:00:00 2001 From: Bruno BELANYI Date: Fri, 1 Oct 2021 17:20:14 +0200 Subject: [PATCH] posts: add article about magic conversions in C++ --- content/posts/magic-conversions-in-c++.md | 171 ++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 content/posts/magic-conversions-in-c++.md diff --git a/content/posts/magic-conversions-in-c++.md b/content/posts/magic-conversions-in-c++.md new file mode 100644 index 0000000..9db1ca3 --- /dev/null +++ b/content/posts/magic-conversions-in-c++.md @@ -0,0 +1,171 @@ +--- +title: "Magic Conversions in C++" +date: 2021-10-01T14:46:14+02:00 +draft: false # I don't care for draft mode, git has branches for that +description: "How to get the compiler to infer the correct conversion" +tags: + - c++ + - design-pattern +categories: + - programming +series: +favorite: false +disable_feed: false +--- + +One feature that I like a lot in [Rust][rust-lang] is return type polymorphism, +best exemplified with the following snippet of code: + +```rust +use std::collections::HashSet; + +fn main() { + let vec: Vec<_> = (0..10).filter(|a| a % 2 == 0).collect(); + let set: HashSet<_> = (0..10).filter(|a| a % 2 == 0).collect(); + println!("vec: {:?}", vec); + println!("set: {:?}", set); +} +``` + +We have the same expression (`(0..10).filter(|a| a % 2 == 0).collect()`) that +results in two totally different types of values (a `Vec` and a `HashSet`)! + +This is because Rust allows you to write a function which is generic in its +*return type*, which is a super-power that C++ does not have. But is there a way +to emulate this behaviour with some clever code? + +[rust-lang]: https://rust-lang.org/ + + +## The problem + +For the purposes of this article, the problem that I am trying to solve will be +the following: + +```c++ +void takes_small_array(std::array arr); +void takes_big_array(std::array arr); + +// How to define a `to_array` function so that the following work? +void test(std::string_view s) { + takes_small_array(to_array(s)); + takes_big_array(to_array(s)); +} +``` + +## First attempt + +If we try to solve this in a way similar to Rust, we hit a problem in what the +language allows us to write: + +```c++ +std::array to_array(std::string_view s) { + std::array ret; + std::copy(s.begin(), s.end(), ret.begin()); + return ret; +} + +std::array to_array(std::string_view s) { + std::array ret; + std::copy(s.begin(), s.end(), ret.begin()); + return ret; +} +``` + +The compiler complains with the following error: + +```none +ambiguating new declaration of 'std::array to_array(std::string_view)' +note: old declaration 'std::array to_array(std::string_view)' +``` + +That is because C++ does **not** allow you to write an overload set based on +*return type only*. + +## Using templates + +For our second try, we want to use *non-type template parameters* to solve the +issue. We write the following: + +```c++ +template +std::array to_array(std::string_view s) { + std::array ret; + std::copy(s.begin(), s.end(), ret.begin()); + return ret; +} +``` + +The compiler does not complain when we write this! We have also solved two minor +issues with the previous try: the size of the arrays are not hard-coded, and we +kept the code DRY. + +However we have some trouble trying to use those functions as stated in the +beginning of the problem, with the following error message: + +```none +error: no matching function for call to 'to_array(std::string_view&)' + | takes_small_array(to_array(s)); +note: candidate: 'template std::array to_array(std::string_view)' + | std::array to_array(std::string_view s) { +note: template argument deduction/substitution failed: +note: couldn't deduce template parameter 'N' +``` + +The compiler cannot deduce the size of the array we want to use! We could solve +the issue by explicitly giving a size when calling the function +(`to_array<32>(s)`) however this is unsatisfactory: we are not solving the +problem as stated initially, which could for example lead to needless churning +if we change the signature of `takes_small_array` to instead use +`std::array`). + +Thankfully there is a way to use the compiler to our advantage, and have it +deduce it for us, but it involves some trickery. + +## The solution + +We want to write a function that resolves the previous two issues we +experienced: + +* We cannot have the size of the array deduced at the time of the call, but only +once the returned value is *consumed* -- which is too late for the compiler. +* We cannot overload on the return type, which means we must return a single +type from the function. + +The goal is to delay *when* the deduction of the array's size is happening, +which can be done by using a *templated conversion operator*. + +So the solution to our problem is to do the following: + +```c++ +class ToArray { + std::string_view s_; + +public: + ToArray(std::string_view s) : s_(s) {} + + template + operator std::array() const { + std::array ret; + std::copy(s.begin(), s.end(), ret.begin()); + return ret; + } +} + +ToArray to_array(std::string_view s) { + return ToArray{s}; +} +``` + +The following steps happen when trying to call `takes_small_array(to_array(s))`: + +* `to_array(s)` returns a `ToArray` value. +* the `ToArray` value is not an `array`, but has an implicit +conversion operator, which the compiler invokes. +* `takes_small_array` is called with the converted `array` value. + +We now have a "magic" function which can convert a `string_view` to an +`std::array` of characters of any size. We could further improve this by +ensuring that the array is terminated with a `'\0'`, throwing an exception when +the array is too small for the given string, etc... This is left as an exercise +to the reader.