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