Comparing Whole and Partial Strings

Two strings can be “the same” in several useful but incompatible ways. A protocol token may require exact character matching, a user-facing lookup may ignore case, and an index may need to distinguish the bytes used to store the text. This page explains how to choose a comparison rule for your data, apply it to whole strings or selected parts, and handle malformed text at an untrusted boundary.

The examples use the common UTF-8 String; the same model applies to the matching width-specific values, editors, and literals.

Decoded String Equality and Ordering

When you compare strings with ==, <, or the other comparison operators, you compare their decoded Unicode code points. This gives you a consistent character order across UTF-8, UTF-16, and UTF-32, which is useful for keys, configuration values, and protocol text. Uppercase and lowercase letters remain distinct, as do accented and unaccented characters. Comparison does not normalize text or apply the sorting rules of a particular language.

The compare() method provides the same comparison as a std::strong_ordering result when you need to distinguish less, equal, and greater explicitly.

For UTF-8 code, compare a String directly with another String, a StringEditor, or a "_el" literal.

The UTF-16 and UTF-32 variants follow the same pattern with values of the same width: U16String works with U16StringEditor or u"..."_el, while U32String works with U32StringEditor or U"..."_el.

text/String/WholeStringComparison.cpp

/// `String` compares whole strings by decoded Unicode code point.
/// Use the comparison operators for ordinary equality and ordering. Use `compare()`
/// when you need the `std::strong_ordering` result explicitly or want to pass a
/// character comparison function. The common UTF-8 aliases compare with other UTF-8
/// strings and `"_el"` literals; UTF-16 and UTF-32 variants follow the same same-width pattern.
void wholeStringComparison() {
    const auto tag = el::String{"lišejník"_el};
    const auto sameString = el::String{"lišejník"_el};
    const auto booleanFormat = el::BooleanFormat::yesNo();

    // Compare a string with another string and a literal.
    el::io::printLine("tag == sameString .............: "_el, booleanFormat, tag == sameString);
    el::io::printLine("tag == \"lišejník\"_el ..........: "_el, booleanFormat, tag == "lišejník"_el);
    el::io::printLine("tag != \"tuleň\"_el .............: "_el, booleanFormat, tag != "tuleň"_el);

    // Ordering uses decoded code points, so uppercase and lowercase letters differ.
    auto ordering = tag <=> "tuleň"_el;
    el::io::printLine("tag <=> \"tuleň\"_el ............: "_el, el::toString(ordering));
    ordering = tag.compare("LIŠEJNÍK"_el);
    el::io::printLine("tag.compare(\"LIŠEJNÍK\"_el) ....: "_el, el::toString(ordering));
    el::io::printLine("tag < \"tuleň\"_el ..............: "_el, booleanFormat, tag < "tuleň"_el);
    el::io::printLine("tag <= sameString .............: "_el, booleanFormat, tag <= sameString);
    el::io::printLine("tag >= \"arka\"_el ..............: "_el, booleanFormat, tag >= "arka"_el);

    // Width-specific strings use the same operations with values of the same width.
    const auto u8Habitat = el::U8String{u8"ledová kra"_el};
    const auto u16Habitat = el::U16String{u"ledová kra"_el};
    const auto u32Habitat = el::U32String{U"ledová kra"_el};

    el::io::printLine("u8Habitat == u8 literal .......: "_el, booleanFormat, u8Habitat == u8"ledová kra"_el);
    el::io::printLine("u16Habitat == u16 literal .....: "_el, booleanFormat, u16Habitat == u"ledová kra"_el);
    el::io::printLine("u32Habitat == u32 literal .....: "_el, booleanFormat, u32Habitat == U"ledová kra"_el);
}

Output

tag == sameString .............: yes
tag == "lišejník"_el ..........: yes
tag != "tuleň"_el .............: yes
tag <=> "tuleň"_el ............: less
tag.compare("LIŠEJNÍK"_el) ....: greater
tag < "tuleň"_el ..............: yes
tag <= sameString .............: yes
tag >= "arka"_el ..............: yes
u8Habitat == u8 literal .......: yes
u16Habitat == u16 literal .....: yes
u32Habitat == u32 literal .....: yes

When text arrives in different encodings, you can compare it through AnyString. Its compare() method decodes each string in its own encoding, so the UTF-8 and UTF-16 versions of ledová kra compare equal without creating converted strings. This is useful when your code accepts text from several sources but only needs to compare its characters.

Custom Character Comparison Rules

The comparison operators always perform exact code-point comparison.

When your application requires different matching rules, use compare() and provide a character comparison function.

Three comparison functions cover the most common use cases:

  • Char::compareCaseFolded() performs Unicode-aware case-insensitive comparison. Characters are compared using Unicode case-folding rules. This requires the Unicode database and therefore links the corresponding Unicode data into your application.

  • Char::compareAsciiFolded() folds only ASCII letters A-Z to a-z. It does not depend on Unicode database data and is often suitable for protocol tokens, file formats, and other ASCII-only text.

  • Char::compareIdentifier() is designed for identifier-like text. It folds ASCII case and treats the space character U+0020 as equal to the underscore U+005F.

Choose the comparison rule that matches the meaning of your data. Do not use folded comparison when exact identity is required, and do not use ASCII folding for international text unless ASCII-only behavior is explicitly desired. Pass the rule at the comparison point instead of silently changing every stored value; that keeps exceptional matching semantics visible and lets different fields use the rules their domains require.

text/String/ComparisonFunctions.cpp

/// `compare()` accepts character comparison functions for specialized matching rules.
/// Use `Char::compareCaseFolded` for Unicode-aware case-insensitive text, use
/// `Char::compareAsciiFolded` when ASCII-only folding is enough, and use
/// `Char::compareIdentifier` for identifier-like names where ASCII case and spaces
/// versus underscores should compare equally.
void comparisonFunctions() {
    const auto snowyOwl = el::String{"SOVA SNĚŽNÍ"_el};
    const auto snowyOwlLower = el::String{"sova sněžní"_el};
    const auto asciiLabel = el::String{"Polar Fox"_el};
    const auto asciiLabelLower = el::String{"polar fox"_el};
    const auto identifier = el::String{"Arctic Fox Trail"_el};
    const auto normalizedIdentifier = el::String{"arctic_fox_trail"_el};

    // Regular comparison is exact and uses decoded code points.
    el::io::printLine(
        "snowyOwl.compare(snowyOwlLower) .........................: "_el,
        el::toString(snowyOwl.compare(snowyOwlLower)));

    // Unicode case folding handles non-ASCII letters such as `Ě` and `ě`.
    el::io::printLine(
        "snowyOwl.compare(..., Char::compareCaseFolded) ..........: "_el,
        el::toString(snowyOwl.compare(snowyOwlLower, el::Char::compareCaseFolded)));

    // ASCII folding is small and fast, but only changes A-Z to a-z.
    el::io::printLine(
        "asciiLabel.compare(..., Char::compareAsciiFolded) .......: "_el,
        el::toString(asciiLabel.compare(asciiLabelLower, el::Char::compareAsciiFolded)));
    el::io::printLine(
        "snowyOwl.compare(..., Char::compareAsciiFolded) .........: "_el,
        el::toString(snowyOwl.compare(snowyOwlLower, el::Char::compareAsciiFolded)));

    // Identifier comparison is useful for normalized keys or configuration-style names.
    el::io::printLine(
        "identifier.compare(..., Char::compareIdentifier) ........: "_el,
        el::toString(identifier.compare(normalizedIdentifier, el::Char::compareIdentifier)));
}

Output

snowyOwl.compare(snowyOwlLower) .........................: less
snowyOwl.compare(..., Char::compareCaseFolded) ..........: equal
asciiLabel.compare(..., Char::compareAsciiFolded) .......: equal
snowyOwl.compare(..., Char::compareAsciiFolded) .........: less
identifier.compare(..., Char::compareIdentifier) ........: equal

Prefix, Suffix, Substring, and Occurrence Tests

Many string-related questions concern only part of a string.

Instead of manually slicing text and comparing the pieces, use the dedicated partial-string operations. These methods make the intent clearer and ensure that matching follows the same Unicode decoding rules as whole-string comparison.

The four core operations are:

Each function accepts the same optional character comparison function as compare(). This allows case-folded or identifier-style matching for prefix, suffix, containment, and counting operations.

An empty search string always produces a count of zero. This avoids accidental infinite matches in loops and counting logic.

When you know that a match must occur at the beginning or end of the text, prefer startsWith() or endsWith(). These operations only inspect the relevant edge of the string and avoid a full containment search.

text/String/PartialStringComparison.cpp

/// Use `startsWith()`, `endsWith()`, `contains()`, and `count()` to test parts of a string.
/// These methods use the same comparison rules as whole-string comparison and accept the
/// same optional character comparison function. Prefix and suffix tests are efficient
/// even for large strings because only the required edge of the string is inspected.
void partialStringComparison() {
    const auto observationLog =
        el::String{"Lachtan: LEDOVÁ KRA; lachtan: tiché moře; tuleň: ledová kra; mrož: severní útes"_el};
    const auto booleanFormat = el::BooleanFormat::yesNo();

    el::io::printLine("Observation Log:"_el);
    el::io::printLine(observationLog);
    el::io::printLine();

    // Case-sensitive partial tests are direct and predictable.
    el::io::printLine(
        "startsWith(\"Lachtan\"_el) ................: "_el, booleanFormat, observationLog.startsWith("Lachtan"_el));
    el::io::printLine(
        "endsWith(\"severní útes\"_el) .............: "_el, booleanFormat, observationLog.endsWith("severní útes"_el));
    el::io::printLine(
        "contains(\"tiché moře\"_el) ...............: "_el, booleanFormat, observationLog.contains("tiché moře"_el));
    el::io::printLine("count(\"ledová kra\"_el) ..................: "_el, observationLog.count("ledová kra"_el));

    // Pass a comparison function when matching should ignore case.
    el::io::printLine();
    el::io::printLine(
        "startsWith(\"lachtan\"_el, case-folded) ...: "_el,
        booleanFormat,
        observationLog.startsWith("lachtan"_el, el::Char::compareCaseFolded));
    el::io::printLine(
        "contains(\"LEDOVÁ KRA\"_el, case-folded) ..: "_el,
        booleanFormat,
        observationLog.contains("LEDOVÁ KRA"_el, el::Char::compareCaseFolded));
    el::io::printLine(
        "count(\"lachtan\"_el, case-folded) ........: "_el,
        observationLog.count("lachtan"_el, el::Char::compareCaseFolded));
    el::io::printLine(
        "count(\"ledová kra\"_el, case-folded) .....: "_el,
        observationLog.count("ledová kra"_el, el::Char::compareCaseFolded));
    el::io::printLine("count(\"\"_el) ............................: "_el, observationLog.count(""_el));
}

Output

Observation Log:
Lachtan: LEDOVÁ KRA; lachtan: tiché moře; tuleň: ledová kra; mrož: severní útes

startsWith("Lachtan"_el) ................: yes
endsWith("severní útes"_el) .............: yes
contains("tiché moře"_el) ...............: yes
count("ledová kra"_el) ..................: 1

startsWith("lachtan"_el, case-folded) ...: yes
contains("LEDOVÁ KRA"_el, case-folded) ..: yes
count("lachtan"_el, case-folded) ........: 2
count("ledová kra"_el, case-folded) .....: 2
count(""_el) ............................: 0

Comparing Stored Bytes

Some tasks depend on how text is stored. For example, an index of encoded values may need to keep distinct byte sequences as separate entries even when they decode to the same text. The compareBinary() method gives you a fast ordering for this purpose by comparing bytes directly without decoding. Like compare(), it returns std::strong_ordering and is available on all three string widths and their editors. The same-width string, editor, and literal combinations shown earlier also work for binary comparison.

Bytes are compared as unsigned values, with the first difference deciding the order. When one byte sequence is a prefix of the other, the shorter sequence comes first: abc precedes abcd. Only the content of the string or slice participates. An embedded NUL is part of that content and does not stop comparison; a storage terminator or unused editor capacity does not affect the result.

For valid UTF-8, byte order agrees with decoded code-point order. UTF-16 and UTF-32 store each code unit in several bytes, so their binary ordering depends on the machine’s native byte order and can differ from character ordering. If you need sorted output to be reproducible across machines or independent of the encoding, ordinary decoded comparison provides that guarantee.

For mixed encodings, AnyString::compareBinary() and AnyStringEditor::compareBinary() also distinguish the encoding itself. The UTF-8 and UTF-16 versions of ledová kra therefore compare unequal, although they compare equal with compare(). Nonempty strings are grouped by encoding in the order UTF-8, UTF-16, UTF-32, then ordered by their bytes within each group. Empty strings compare equal regardless of their encoding and sort before every nonempty string.

Validating Untrusted Text

Text received from a file or another application may contain malformed UTF-8, UTF-16, or UTF-32 sequences. Ordinary comparison and search can still inspect it: decoding substitutes the Unicode replacement character U+FFFD, available as Char::replacement(), for malformed input. This tolerant behavior is useful when you want to display or inspect damaged text.

The replacement also means that different malformed byte sequences can compare equal after decoding. Binary comparison preserves those byte differences, but it does not check whether either string is valid text. When an identifier or protocol value must contain valid Unicode, validation belongs before comparison, whichever comparison rule you choose.

For the common String alias and other U8String values, isEncodingValid() lets you reject malformed input at that boundary. The same method is available for UTF-16 and UTF-32 strings. Use encodingValidOrThrow() when validation failures should propagate as exceptions with an error position. Once the input has passed validation, you can apply the matching rule your application requires without accepting malformed text through replacement-character equality.

Complete demo sources

text/String/WholeStringComparison.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include <DemoCommon.hpp>
#include <erbsland/BooleanFormat.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>
#include <erbsland/ToString.hpp>
#include <erbsland/U16String.hpp>
#include <erbsland/U32String.hpp>
#include <erbsland/U8String.hpp>

namespace demo {

/// `String` compares whole strings by decoded Unicode code point.
/// Use the comparison operators for ordinary equality and ordering. Use `compare()`
/// when you need the `std::strong_ordering` result explicitly or want to pass a
/// character comparison function. The common UTF-8 aliases compare with other UTF-8
/// strings and `"_el"` literals; UTF-16 and UTF-32 variants follow the same same-width pattern.
void wholeStringComparison() {
    const auto tag = el::String{"lišejník"_el};
    const auto sameString = el::String{"lišejník"_el};
    const auto booleanFormat = el::BooleanFormat::yesNo();

    // Compare a string with another string and a literal.
    el::io::printLine("tag == sameString .............: "_el, booleanFormat, tag == sameString);
    el::io::printLine("tag == \"lišejník\"_el ..........: "_el, booleanFormat, tag == "lišejník"_el);
    el::io::printLine("tag != \"tuleň\"_el .............: "_el, booleanFormat, tag != "tuleň"_el);

    // Ordering uses decoded code points, so uppercase and lowercase letters differ.
    auto ordering = tag <=> "tuleň"_el;
    el::io::printLine("tag <=> \"tuleň\"_el ............: "_el, el::toString(ordering));
    ordering = tag.compare("LIŠEJNÍK"_el);
    el::io::printLine("tag.compare(\"LIŠEJNÍK\"_el) ....: "_el, el::toString(ordering));
    el::io::printLine("tag < \"tuleň\"_el ..............: "_el, booleanFormat, tag < "tuleň"_el);
    el::io::printLine("tag <= sameString .............: "_el, booleanFormat, tag <= sameString);
    el::io::printLine("tag >= \"arka\"_el ..............: "_el, booleanFormat, tag >= "arka"_el);

    // Width-specific strings use the same operations with values of the same width.
    const auto u8Habitat = el::U8String{u8"ledová kra"_el};
    const auto u16Habitat = el::U16String{u"ledová kra"_el};
    const auto u32Habitat = el::U32String{U"ledová kra"_el};

    el::io::printLine("u8Habitat == u8 literal .......: "_el, booleanFormat, u8Habitat == u8"ledová kra"_el);
    el::io::printLine("u16Habitat == u16 literal .....: "_el, booleanFormat, u16Habitat == u"ledová kra"_el);
    el::io::printLine("u32Habitat == u32 literal .....: "_el, booleanFormat, u32Habitat == U"ledová kra"_el);
}

}

text/String/ComparisonFunctions.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include <DemoCommon.hpp>
#include <erbsland/Char.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>
#include <erbsland/ToString.hpp>

namespace demo {

/// `compare()` accepts character comparison functions for specialized matching rules.
/// Use `Char::compareCaseFolded` for Unicode-aware case-insensitive text, use
/// `Char::compareAsciiFolded` when ASCII-only folding is enough, and use
/// `Char::compareIdentifier` for identifier-like names where ASCII case and spaces
/// versus underscores should compare equally.
void comparisonFunctions() {
    const auto snowyOwl = el::String{"SOVA SNĚŽNÍ"_el};
    const auto snowyOwlLower = el::String{"sova sněžní"_el};
    const auto asciiLabel = el::String{"Polar Fox"_el};
    const auto asciiLabelLower = el::String{"polar fox"_el};
    const auto identifier = el::String{"Arctic Fox Trail"_el};
    const auto normalizedIdentifier = el::String{"arctic_fox_trail"_el};

    // Regular comparison is exact and uses decoded code points.
    el::io::printLine(
        "snowyOwl.compare(snowyOwlLower) .........................: "_el,
        el::toString(snowyOwl.compare(snowyOwlLower)));

    // Unicode case folding handles non-ASCII letters such as `Ě` and `ě`.
    el::io::printLine(
        "snowyOwl.compare(..., Char::compareCaseFolded) ..........: "_el,
        el::toString(snowyOwl.compare(snowyOwlLower, el::Char::compareCaseFolded)));

    // ASCII folding is small and fast, but only changes A-Z to a-z.
    el::io::printLine(
        "asciiLabel.compare(..., Char::compareAsciiFolded) .......: "_el,
        el::toString(asciiLabel.compare(asciiLabelLower, el::Char::compareAsciiFolded)));
    el::io::printLine(
        "snowyOwl.compare(..., Char::compareAsciiFolded) .........: "_el,
        el::toString(snowyOwl.compare(snowyOwlLower, el::Char::compareAsciiFolded)));

    // Identifier comparison is useful for normalized keys or configuration-style names.
    el::io::printLine(
        "identifier.compare(..., Char::compareIdentifier) ........: "_el,
        el::toString(identifier.compare(normalizedIdentifier, el::Char::compareIdentifier)));
}

}

text/String/PartialStringComparison.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include <DemoCommon.hpp>
#include <erbsland/BooleanFormat.hpp>
#include <erbsland/Char.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>

namespace demo {

/// Use `startsWith()`, `endsWith()`, `contains()`, and `count()` to test parts of a string.
/// These methods use the same comparison rules as whole-string comparison and accept the
/// same optional character comparison function. Prefix and suffix tests are efficient
/// even for large strings because only the required edge of the string is inspected.
void partialStringComparison() {
    const auto observationLog =
        el::String{"Lachtan: LEDOVÁ KRA; lachtan: tiché moře; tuleň: ledová kra; mrož: severní útes"_el};
    const auto booleanFormat = el::BooleanFormat::yesNo();

    el::io::printLine("Observation Log:"_el);
    el::io::printLine(observationLog);
    el::io::printLine();

    // Case-sensitive partial tests are direct and predictable.
    el::io::printLine(
        "startsWith(\"Lachtan\"_el) ................: "_el, booleanFormat, observationLog.startsWith("Lachtan"_el));
    el::io::printLine(
        "endsWith(\"severní útes\"_el) .............: "_el, booleanFormat, observationLog.endsWith("severní útes"_el));
    el::io::printLine(
        "contains(\"tiché moře\"_el) ...............: "_el, booleanFormat, observationLog.contains("tiché moře"_el));
    el::io::printLine("count(\"ledová kra\"_el) ..................: "_el, observationLog.count("ledová kra"_el));

    // Pass a comparison function when matching should ignore case.
    el::io::printLine();
    el::io::printLine(
        "startsWith(\"lachtan\"_el, case-folded) ...: "_el,
        booleanFormat,
        observationLog.startsWith("lachtan"_el, el::Char::compareCaseFolded));
    el::io::printLine(
        "contains(\"LEDOVÁ KRA\"_el, case-folded) ..: "_el,
        booleanFormat,
        observationLog.contains("LEDOVÁ KRA"_el, el::Char::compareCaseFolded));
    el::io::printLine(
        "count(\"lachtan\"_el, case-folded) ........: "_el,
        observationLog.count("lachtan"_el, el::Char::compareCaseFolded));
    el::io::printLine(
        "count(\"ledová kra\"_el, case-folded) .....: "_el,
        observationLog.count("ledová kra"_el, el::Char::compareCaseFolded));
    el::io::printLine("count(\"\"_el) ............................: "_el, observationLog.count(""_el));
}

}