Ch17.2: Prefer a Named Function: concat, Not +
1. The Claim
When an operator and a function can express the same thing, write the
function. Not because operators are slow — they inline perfectly —
but because punctuation is a promise that the operation is the operation everybody
already knows. For two numbers, + is that operation. For two strings, two
vectors, two files, or two trees, it is not: nobody can read
a + b and know whether it concatenated, merged, unioned, allocated, copied
ten megabytes, or failed.
The canonical case is sequence concatenation, which C++ inherited as
operator+ from the 1998 standard library. It is a good case because it is so
familiar that it looks harmless, and because it is measurable.
2. The Measurement
We build the same 4,000-byte value three ways — with
operator+, with a concat function that returns a new value, and
with an append member that mutates in place. text counts the
bytes its copy constructor copies; that counter is the test instrument, not something you
would ship.
#include <fast_io.h>
#include <fast_io_dsal/string.h>
#include <fast_io_dsal/string_view.h>
#include <cstddef>
struct text
{
::fast_io::string chars{};
static inline ::std::size_t copied{}; /* a test instrument, not a member you ship */
text() = default;
explicit text(::fast_io::string_view vw) : chars{vw} {}
text(text const &other) : chars{other.chars}
{
copied += chars.size();
}
text(text &&other) noexcept : chars{::std::move(other.chars)} {}
text &operator=(text &&other) noexcept
{
chars = ::std::move(other.chars);
return *this;
}
void append(text const &other)
{
chars.append(other.chars);
}
};
inline text operator+(text const &a, text const &b)
{
text r{a}; /* copies every byte of a */
r.append(b);
return r;
}
inline text concat(text const &a, text const &b)
{
return a + b; /* a named function that still copies - the name is not magic */
}
int main()
{
try
{
text const word{::fast_io::string_view{"abcdefgh"}};
text with_plus{};
text::copied = 0;
for (int i{}; i != 500; ++i)
{
with_plus = with_plus + word;
}
::fast_io::io::println("a + b size=", with_plus.chars.size(), " copied=", text::copied);
text with_concat{};
text::copied = 0;
for (int i{}; i != 500; ++i)
{
with_concat = concat(with_concat, word);
}
::fast_io::io::println("concat size=", with_concat.chars.size(), " copied=", text::copied);
text with_append{};
text::copied = 0;
for (int i{}; i != 500; ++i)
{
with_append.append(word);
}
::fast_io::io::println("append size=", with_append.chars.size(), " copied=", text::copied);
auto const joined{::fast_io::concat_fast_io("concatenated: ", 1, " + ", 2, " = ", 3)};
::fast_io::io::println(joined);
}
catch throws (::std::error e)
{
::fast_io::io::perrln("error: ", e);
}
}
a + b size=4000 copied=998000
concat size=4000 copied=998000
append size=4000 copied=0
concatenated: 1 + 2 = 3
Read the three lines carefully, because they say something that the usual "operators are fine" and "operators are evil" arguments both miss.
a + b and concat cost exactly the same.
998,000 bytes copied to produce 4,000 bytes of output. Renaming the operator to a function
bought nothing in performance — the operands still say "return a new
value", and returning a new value means copying the accumulated string on every one of the
500 iterations. That is the O(n²) shape, and it is the shape that
a = a + b in a loop has in every language that defines + this
way.
append costs zero. Not "less": zero. It extends the buffer
that already exists, so nothing that was already copied is copied again. To get that
behaviour out of + you would have to write a += b, which is a
different glyph with a different meaning, and which for a type that models "a value"
rather than "a container" is often not provided at all.
So the real lesson of the measurement is not "name it concat". It is:
the operator's spelling forces a copy semantics you did not choose, and
the name is what makes the choice visible. concat(a, b) tells the reader a
new value appears; a.append(b) tells the reader a grew and
nothing else happened. a + b tells the reader nothing at all, and one of the
two costs is 998,000 bytes.
3. What fast_io's Own concat Shows
The last two lines of the program use ::fast_io::concat_fast_io, and it is
worth pausing on, because it is the shape a named function allows and an operator refuses:
auto const joined{::fast_io::concat_fast_io("concatenated: ", 1, " + ", 2, " = ", 3)};
Six arguments of three different types, formatted by fast_io's own
print machinery (Ch15.11),
into one buffer, with one allocation, and throws iff any argument's
formatting can throw. Try expressing that with an operator: + is binary, so
you need five of them, each one allocating or at least each one being a separate
function-call node, and each one a chance for a reader to wonder whether the result is
numeric.
This is why ::fast_io::print and ::fast_io::scan are
functions with names and not operator<< and
operator>> (Ch17.8):
a library whose business is converting values to text cannot afford punctuation that
hides direction, encoding, or failure.
4. ::fast_io::string Deliberately Has No operator+
::std::string has one. It is the reason the habit exists: two decades of
a + b on strings taught a generation that concatenation is punctuation. The
same library also has append, which is the operation most call sites actually
wanted, and which does no copying.
::fast_io::string has no operator+ and no
operator+=. Not an oversight and not a missing feature — a decision,
and this page is the argument for it. Write the expression that used to be
a + b, and the compiler tells you so:
#include <fast_io.h>
#include <fast_io_dsal/string.h>
#include <fast_io_dsal/string_view.h>
int main()
{
::fast_io::string a{::fast_io::string_view{"hello "}};
::fast_io::string b{::fast_io::string_view{"world"}};
auto c{a + b};
::fast_io::io::println(c);
}
plus.cc: In function ‘int main()’:
plus.cc:9:18: error: no match for ‘operator+’ (operand types are ‘fast_io::string’ {aka ‘fast_io::containers::basic_string<char, fast_io::generic_allocator_adapter<fast_io::c_malloc_allocator> >’} and ‘fast_io::string’ {aka ‘fast_io::containers::basic_string<char, fast_io::generic_allocator_adapter<fast_io::c_malloc_allocator> >’})
9 | auto c{a + b};
| ~ ^ ~
| | |
| | basic_string<[...],[...]>
| basic_string<[...],[...]>
• there is 1 candidate
• candidate 1: ‘constexpr fast_io::posix_timespec64 fast_io::operator+(posix_timespec64, posix_timespec64)’
• no known conversion for argument 1 from ‘fast_io::string’ {aka ‘fast_io::containers::basic_string<char, fast_io::generic_allocator_adapter<fast_io::c_malloc_allocator> >’} to ‘fast_io::posix_timespec64’
The one candidate the compiler can even find in the whole header set is
operator+ for timestamps (include/fast_io_core_impl/iso/iso8601.h,
line 108), which is an operator that earns its glyph: a point in time plus a duration, no
allocation, no failure, one obvious answer. There is no candidate for strings, because
there is no operator+ for strings. The repetition of the source line under the
candidate is elided above.
What the library offers instead is the set of operations a call site actually has to choose between:
#include <fast_io.h>
#include <fast_io_dsal/string.h>
#include <fast_io_dsal/string_view.h>
int main()
{
try
{
::fast_io::string a{::fast_io::string_view{"hello "}};
::fast_io::string b{::fast_io::string_view{"world"}};
::fast_io::string c{a}; /* the copy that "+" would have made, made on purpose */
c.append(b); /* in place: nothing that was already there is copied again */
::fast_io::io::println("c ", c);
a.append(::fast_io::string_view{"there"});
::fast_io::io::println("a ", a);
}
catch throws (::std::error e)
{
::fast_io::io::perrln("error: ", e);
}
}
c hello world
a hello there
append has four overloads in include/fast_io_dsal/impl/string.h
— a string_view, another basic_string, a
[begin, end) pair of pointers, and a pointer with a length — and all of
them return void. Appending cannot produce a value, because there is no new
value: there is the string you already had, grown. When you do want a new value, that is
::fast_io::concat_fast_io from §3, which is also a function and also says
so in its name.
The point is not that ::std::string is wrong and fast_io is right. The point
is that the spelling is a design decision with a measurable cost, and that leaving the
expensive spelling out of the interface is how a library makes you take the decision
consciously instead of by habit. Here the cost is 998,000 copied bytes against zero.
5. Four Questions Before You Write Punctuation
| Question | If the answer is "yes" |
|---|---|
| Can it fail? | do not use an operator. A single glyph hides either an error
return or a herbception, and the reader of a + b will not look for
either. Use a name and a throws contract. |
| Can it allocate or copy a lot? | do not use an operator. Cost that cannot be seen is cost that
is not accounted for. concat at least warns you that a new value
appears; + does not. |
| Is the meaning obvious to someone who has never seen your type? | then the operator is probably fine — this is the arithmetic case (Ch17.3). |
| Would a name be longer than a comment explaining the operator? | then the operator is cheaper for everybody, including the next reader. |
Apply the test to familiar operators: ::std::map's
operator[] inserts a default value when the key is missing, so
m[k] can fail (allocation) and can change the container — which is why
Ch17.7 prefers
find. operator<< writes to a stream and reports failure by
setting a stream state nobody checks — which is why
Ch17.8 removes it.
6. Names to Reach For
| Instead of | Write | Because the name says |
|---|---|---|
a + b on two strings |
concat(a, b) |
a new sequence, made of both, allocated |
a = a + b |
a.append(b) |
in place, amortised, no copy of a |
a + b + c + d |
concat(a, b, c, d) |
one operation with four inputs, one allocation |
a | b on sets or flags |
set_union(a, b), with_flag(f) |
whether duplicates survive, and which side wins |
a / b on paths |
join(a, b) |
joining, not dividing; and separators are a policy |
a * b on matrices |
a.multiply(b) or matmul(a, b) |
which product, which order, and that it allocates |
The standard library eventually agrees with this page. C++26's range adapter for exactly
this operation is ::std::views::concat — a named function — and
not an operator+ between two ranges. When two ranges are concatenated
lazily, there is no new container and no allocation, and calling that
+ would have implied both.
7. When You Need Both
Sometimes an operator is worth having on top of the named function, for readability in mathematical code. Then define the operator as a one-line alias, and put the real work in the function:
template <typename... Args>
[[nodiscard]] inline ::fast_io::string concat(Args const &...args)
throws(!noexcept(::fast_io::concat_fast_io(args...)))
{
return ::fast_io::concat_fast_io(args...);
}
That inversion is the important part. If the operator is the primitive, callers will use it, and the named function becomes a wrapper nobody reads. If the function is the primitive, the operator is a documented convenience — and you can grep for how many places actually chose the expensive spelling.
8. Rules of Thumb
- Prefer a named function. The operator must earn its glyph.
- No operator that can fail, allocate, or block. Those are the three things a reader must be able to see.
- If you provide an operator, define it in terms of the named function, not the other way round.
- One operator is one operation with one cost. If
a + bsometimes copies and sometimes does not, it is two operations wearing one glyph. - Never make an operator's meaning depend on a template parameter's value in a way that changes whether it allocates or throws. Overload resolution is not a documentation channel.