6
\$\begingroup\$

I have written the below two trim functions for std::string and std::basic_string that are locale-aware.

Both trim all white space characters from the beginning and end of the string. However, they are supposed to not remove any white space that is in between the tokens.

The first overload is supposed to work with any specialization of std::basic_string and with a user-provided locale (the global locale is used if no locale is provided). The second one works only with std::string and is hard-coded for the "C" locale.

Here:

#include <string>
#include <locale>
#include <algorithm>
#include <ranges>
#include <iostream>


template <class CharT, class Traits, class Allocator>
void trim( std::basic_string<CharT, Traits, Allocator>& str, const std::locale& loc = std::locale { } )
{
    const auto is_white_space { [ &loc ]( const CharT c )
                                { return std::isspace( c, loc ); } };

    str.erase( std::ranges::find_if_not( str | std::views::reverse, is_white_space ).base( ),
               std::end( str ) );

    str.erase( std::begin( str ),
               std::ranges::find_if_not( str, is_white_space ) );
}

void trim( std::string& str ) noexcept
{
    trim( str, std::locale { "C" } );
}

int main( )
{
    {
        std::string msg { "  2 h \n 65.      \n   " };
        trim( msg );
        std::cout << msg << '\n';
    }

    {
        std::wstring msg { L"  2 h \n 65.      \n   " };
        trim( msg, std::locale { "en_US.UTF8" } );
        std::wcout << msg << '\n';
    }
}

In both calls to trim in the above program, the returned string should contain "2 h \n 65.". Only the front and back side white space is expected to be removed.

A few design decisions:

  1. The second overload is a convenience function that works for the commonly used std::string type and the commonly used C locale;
  2. I marked the second overload as noexcept since I could not find anything in it that can throw (std::isspace can throw but I think it doesn't throw when a char value and "C" locale are passed to it);
  3. I chose to use erase together with the ranges::find_if_not to remove the white space characters from the beginning and the end.

Is this approach fine? Can anything be improved?

\$\endgroup\$
5
  • \$\begingroup\$ The trim algorithm comes from the accepted answer in Can trim of a string be done inplace with C++20 ranges?. \$\endgroup\$
    – digito_evo
    Commented May 23 at 12:37
  • \$\begingroup\$ A practical, real-world implementation would need to support variable-width encodings, especially UTF-8. This algorithm does not work with those. \$\endgroup\$
    – Davislor
    Commented May 24 at 16:40
  • \$\begingroup\$ @Davislor Can I find an example of that somewhere? \$\endgroup\$
    – digito_evo
    Commented May 24 at 17:27
  • 1
    \$\begingroup\$ Almost everybody uses a third-party library, such as ICU. The major problem with doing it in the standard library is that it only has support to determine the character class of a wchar_t, which the Windows ABI defined as 16-bit, and then chose to make their compiler not conform with the language standard rather than break every Windows program. So, even though it’s possible to write a Standard C++ program that trims a Unicode string, it is not possible to write a portable one. In practice, though, you probably don’t need to worry about space characters outside the BMP. \$\endgroup\$
    – Davislor
    Commented May 24 at 17:42
  • 1
    \$\begingroup\$ I’ve worked before on a string class that stores everything in a canonical form (UTF-8 NFD) and provides iterators to each byte, codepoint or grapheme. This algorithm would work on that with minimal changes, given a function to test if a UCS-4 codepoint is a space. \$\endgroup\$
    – Davislor
    Commented May 24 at 17:48

3 Answers 3

7
\$\begingroup\$

It's surprising that std::string defaults to "C" locale, but all other string types default to using the global locale. That's likely to cause user surprise. I don't care which locale is default, but it should be consistent regardless of string type (or the functions should have different names).

Trimming a string is a good candidate for returning (and perhaps operating on) string view objects - adjusting their bounds can be more efficient than erase(), especially for larger strings.

I know the demo isn't actually a unit test, but if it were, I would be asking to see evidence that the function works correctly with some other cases:

  • empty string
  • whitespace-only string
  • strings starting and/or ending in non-whitespace
\$\endgroup\$
7
  • \$\begingroup\$ I can't use a string view in this case because I need to manipulate the string's content and store it in a larger struct later. \$\endgroup\$
    – digito_evo
    Commented May 23 at 16:37
  • \$\begingroup\$ That's context that wasn't evident in the question - thanks for clarification. \$\endgroup\$ Commented May 23 at 16:44
  • \$\begingroup\$ Regarding of using a consistent locale for both overloads, do you think using any locale (that means system locale) with the char type would be safe? I mean can std::isspace throw in such cases? \$\endgroup\$
    – digito_evo
    Commented May 23 at 16:47
  • 1
    \$\begingroup\$ I don't have anything to prove that it can't. But I think it would be a frustrating implementation to use if that happened. The alternative is to default to std::locale::classic() for both functions, and that also seems reasonable. \$\endgroup\$ Commented May 23 at 16:53
  • 1
    \$\begingroup\$ I was confident the code was correct for those, but always good for a test suite to be complete. \$\endgroup\$ Commented May 23 at 18:26
5
\$\begingroup\$

Make it more generic

Nothing in your code actually depends on str being a std::string. It would work on any container that has an erase() member function, and an element type that is supported by std::isspace().

If you don't modify str in-place, but rather return a view of the trimmed subrange as Toby suggested, it will work on even more types.

While trimming whitespace is often what you want, there are also cases where you want to trim away other characters. So even for std::strings, it makes sense to be able to pass in a function that is used as an alternative to std::isspace(). And with that, it would work on yet more containers.

A more generic trim function could look like:

template <std::ranges::input_range R,
          std::indirect_unary_predicate<std::ranges::iterator_t<R>> Pred>
constexpr std::ranges::borrowed_subrange_t<R>
trim(R&& r, Pred pred)
{
    auto begin = std::ranges::find_if_not(r, pred);
    auto end   = std::ranges::find_if_not(r | std::views::reverse, pred).base();
    return {begin, end};
}

The only drawback is that it would be nice that if you give it a string type as an input, you get a string or string type back. With the above code you'd have to write something like:

std::string msg{"  2 h \n 65.      \n   "};
auto trimmed_subrange{trim(msg)};
std::string_view trimmed_str(trimmed_subrange.begin(), trimmed_subrange.end());
std::cout << trimmed_str << '\n';

So making a specialization that does that for you automatically would be nice.

Note that the above code has a bug if all elements of the range match the predicate, fixing that is left as an exercise for the reader.

\$\endgroup\$
5
  • 1
    \$\begingroup\$ This is kind of what I thought before too. So would this work for a vector or even a list? \$\endgroup\$
    – digito_evo
    Commented May 23 at 19:21
  • 1
    \$\begingroup\$ I think the begin iterator should be named end and end should be named begin? Because the first call to find_if_not returns the new end of the range. \$\endgroup\$
    – digito_evo
    Commented May 23 at 19:23
  • 1
    \$\begingroup\$ @digito_evo Indeed, fixed that :) And yes, this should work for any range that std::ranges::find_if_not() works on. \$\endgroup\$
    – G. Sliepen
    Commented May 23 at 19:28
  • 1
    \$\begingroup\$ The issue that I have with this solution is that I need to trim in place and keep the trimmed version in the string. However, this generic function returns a view that I'm not sure I can safely turn into a string_view and assign back to the string. \$\endgroup\$
    – digito_evo
    Commented May 23 at 20:36
  • 1
    \$\begingroup\$ Indeed you cannot do that. However, the std::ranges::borrowed_subrange_t is basically just two iterators into the current string. You could first erase from the subrange's end() to the real string's end(), then erase from the start of the string to the subrange's begin(). Of course, that's some work, and if you really need to trim in place then you'd just write a function that does it for you, like you already wrote. I think my answer then is more for other readers who are interested in trimming strings but would benefit from a view. \$\endgroup\$
    – G. Sliepen
    Commented May 23 at 20:56
2
\$\begingroup\$

Based on the suggestions, I came up with the following solution that satisfies my needs and is not too complicated:

#include <string>
#include <concepts>
#include <algorithm>
#include <ranges>
#include <locale>
#include <iostream>


template <class CharT, class Traits, class Allocator>
void trim( std::basic_string<CharT, Traits, Allocator>& str, const std::predicate<const CharT> auto& pred )
{
    str.erase( std::ranges::find_if_not( str | std::views::reverse, pred ).base( ),
               std::end( str ) );

    str.erase( std::begin( str ),
               std::ranges::find_if_not( str, pred ) );
}

template <class CharT, class Traits, class Allocator>
void trim_white_space( std::basic_string<CharT, Traits, Allocator>& str, const std::locale& loc = std::locale::classic( ) )
{
    const auto is_white_space { [ &loc ]( const CharT c )
                                { return std::isspace( c, loc ); } };

    trim( str, is_white_space );
}

inline void trim_white_space( std::string& str ) noexcept
{
    trim_white_space( str, std::locale::classic( ) );
}

int main( )
{
    {
        std::string msg { "  2 h \n 65.  \t    \n   " };
        trim_white_space( msg );
        std::cout << msg << '\n';
    }

    {
        std::wstring msg { L"  2 h \n 65.      \n   " };
        trim_white_space( msg, std::locale { "en_US.UTF8" } );
        std::wcout << msg << '\n';
    }
}

This fixes the previous inconsistency of one function using the global locale and the other one using the C locale.

It is also more generic in the sense that the generic trim function now gets an arbitrary predicate that is then used to trim the front and back sides of the given string argument.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.