Free Ebook cover Polyglot Performance Patterns: Writing Fast, Safe Code Across Python, Ruby, Java, and C

Polyglot Performance Patterns: Writing Fast, Safe Code Across Python, Ruby, Java, and C

New course

17 pages

Efficient String Processing and Text Transformations

Capítulo 9

Estimated reading time: 0 minutes

+ Exercise

Why String Processing Becomes a Performance Hotspot

Text work often looks “small” in code but dominates runtime because it combines repeated scanning, branching, allocation, and encoding/decoding. Efficient string processing is mostly about controlling two things: how many times you traverse the bytes/characters, and how many new string objects you create. Across Python, Ruby, Java, and C, the same high-level transformation (split, replace, normalize, join) can be implemented in ways that differ by orders of magnitude depending on whether you stream, batch, or repeatedly rebuild strings.

This chapter focuses on practical transformation patterns: building output without quadratic concatenation, minimizing intermediate substrings, choosing the right abstraction (bytes vs characters), and structuring pipelines so each pass does real work. It also emphasizes correctness: Unicode handling, normalization, and safe boundary conditions.

Core Principles for Fast, Safe Text Transformations

1) Avoid Quadratic Concatenation

Repeatedly appending to an immutable string in a loop can lead to O(n^2) behavior because each append may copy the entire accumulated content. The common fix is to accumulate pieces and join once, or use a builder type designed for incremental appends.

  • Python: collect into a list and ''.join(parts), or use io.StringIO for streaming writes.
  • Ruby: use String with << (mutable) carefully, or collect and join when pieces are many; consider StringIO for streaming.
  • Java: use StringBuilder (single-thread) or StringBuffer (synchronized) when needed.
  • C: precompute output size when possible; otherwise use a growable buffer strategy (capacity doubling) and append bytes.

2) Minimize Intermediate Strings

Many “convenient” APIs allocate: split creates arrays and substrings; chained replace creates a new string each time; regex engines often allocate match objects. Prefer single-pass transforms that write directly to an output buffer, or use APIs that expose indices/slices without copying (where available).

3) Choose the Right Unit: Bytes vs Characters

Some transformations are byte-oriented (ASCII-only protocol tokens, delimiters like \n, trimming spaces in a restricted charset). Others are character-oriented (case folding, Unicode normalization, grapheme-aware slicing). A fast implementation starts by deciding whether the input is guaranteed ASCII/UTF-8 and whether operations must be Unicode-correct.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

  • Python: bytes operations can be much faster and more predictable than str if you do not need Unicode semantics.
  • Ruby: strings carry an encoding; operations can be byte-based or character-based depending on methods and encoding validity.
  • Java: String is UTF-16; operations like codePointAt are needed for full Unicode code point handling.
  • C: you must define encoding and stick to it; UTF-8 processing requires explicit decoding if you need character semantics.

4) Make Each Pass Count

Multiple passes over large text add up. If you trim, then replace, then normalize, then filter, you may traverse the full input several times and allocate at each step. Prefer fusing operations: one scan, multiple decisions, one output write.

Pattern: Single-Pass Filter/Map Transform

A common transformation is: read input, keep some characters, map others, and produce output. Examples: remove control characters, collapse whitespace, convert separators, escape HTML/JSON, or normalize line endings. The efficient pattern is a single scan that appends to a builder/buffer.

Step-by-step design

  • Step 1: Define the transformation rules precisely (what to keep, what to replace, what to drop).
  • Step 2: Decide whether rules are byte-based (ASCII) or Unicode-aware.
  • Step 3: Choose an output strategy: builder/buffer with amortized growth.
  • Step 4: Implement a single loop that reads input once and writes output once.
  • Step 5: Add fast paths (e.g., if no changes needed, return original) when the language makes that cheap and safe.

Python example: collapse whitespace and trim

def collapse_ws(s: str) -> str:
    # Collapses any run of whitespace to a single space and trims ends.
    out = []
    in_ws = False
    for ch in s:
        if ch.isspace():
            in_ws = True
        else:
            if in_ws and out:
                out.append(' ')
            out.append(ch)
            in_ws = False
    return ''.join(out)

This avoids split() (which allocates a list of tokens) and avoids regex. It also avoids repeated concatenation. If you need ASCII-only whitespace rules for speed, replace isspace() with a small lookup (e.g., ch in ' \t\n\r\f\v') or operate on bytes.

Ruby example: collapse whitespace and trim

def collapse_ws(s)
  out = +""  # mutable copy
  in_ws = false
  s.each_char do |ch|
    if ch.match?(/\s/)
      in_ws = true
    else
      out << ' ' if in_ws && !out.empty?
      out << ch
      in_ws = false
    end
  end
  out
end

For performance, avoid per-character regex checks in hot paths. If whitespace definition can be ASCII, use a small conditional like ch == ' ' || ch == "\t" .... Ruby’s << mutates the string, which is typically efficient, but be careful not to share and mutate a string that other code expects to remain unchanged.

Java example: collapse whitespace and trim

static String collapseWs(String s) {
    StringBuilder sb = new StringBuilder(s.length());
    boolean inWs = false;
    for (int i = 0; i < s.length(); i++) {
        char ch = s.charAt(i);
        if (Character.isWhitespace(ch)) {
            inWs = true;
        } else {
            if (inWs && sb.length() > 0) sb.append(' ');
            sb.append(ch);
            inWs = false;
        }
    }
    return sb.toString();
}

If you need full Unicode correctness for whitespace beyond BMP or want to treat surrogate pairs carefully, iterate by code point using codePointAt and Character.isWhitespace(int), and append with appendCodePoint.

C example: collapse ASCII whitespace

#include <ctype.h>
#include <stdlib.h>
#include <string.h>

char* collapse_ws_ascii(const char* s) {
    size_t n = strlen(s);
    char* out = (char*)malloc(n + 1); // output cannot exceed input length
    if (!out) return NULL;

    size_t j = 0;
    int in_ws = 0;
    for (size_t i = 0; i < n; i++) {
        unsigned char c = (unsigned char)s[i];
        if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v') {
            in_ws = 1;
        } else {
            if (in_ws && j > 0) out[j++] = ' ';
            out[j++] = (char)c;
            in_ws = 0;
        }
    }
    out[j] = '\0';
    return out;
}

This uses a single allocation sized to the input length, which is safe because collapsing whitespace never increases size. For transforms that can expand output (escaping), use a growable buffer.

Pattern: Escape/Unescape Without Excess Allocation

Escaping (JSON, CSV, HTML, shell) often expands output. Efficient escaping is about (1) detecting whether escaping is needed and (2) writing to a builder only when necessary. A useful trick is a two-phase approach: scan for the first character that requires escaping; if none, return the original string; otherwise, copy the prefix and then escape the rest.

Python example: minimal JSON string escaping (subset)

def json_escape_min(s: str) -> str:
    # Escapes backslash, quote, and control characters \n \r \t.
    # Not a full JSON implementation; intended to show the pattern.
    specials = {'\\': '\\\\', '"': '\\"', '\n': '\\n', '\r': '\\r', '\t': '\\t'}

    # Find first special
    for i, ch in enumerate(s):
        if ch in specials:
            break
    else:
        return s

    out = [s[:i]]
    for ch in s[i:]:
        out.append(specials.get(ch, ch))
    return ''.join(out)

This avoids building output when no escaping is needed and avoids repeated concatenation. For heavy-duty JSON, prefer a well-tested library, but the performance pattern (fast path + builder) remains useful in custom protocols.

Java example: escape with fast path

static String escapeQuotesAndBackslashes(String s) {
    int n = s.length();
    int i = 0;
    while (i < n) {
        char ch = s.charAt(i);
        if (ch == '"' || ch == '\\') break;
        i++;
    }
    if (i == n) return s;

    StringBuilder sb = new StringBuilder(n + 8);
    sb.append(s, 0, i);
    for (; i < n; i++) {
        char ch = s.charAt(i);
        if (ch == '"' || ch == '\\') sb.append('\\');
        sb.append(ch);
    }
    return sb.toString();
}

Note the use of sb.append(s, 0, i) to copy a prefix efficiently. The capacity hint reduces resizing.

C example: growable buffer for escaping

#include <stdlib.h>
#include <string.h>

typedef struct {
    char* data;
    size_t len;
    size_t cap;
} buf_t;

static int buf_grow(buf_t* b, size_t need) {
    if (b->len + need <= b->cap) return 1;
    size_t newcap = b->cap ? b->cap : 64;
    while (newcap < b->len + need) newcap *= 2;
    char* p = (char*)realloc(b->data, newcap);
    if (!p) return 0;
    b->data = p;
    b->cap = newcap;
    return 1;
}

char* escape_quotes_backslashes(const char* s) {
    buf_t b = {0};
    for (const unsigned char* p = (const unsigned char*)s; *p; p++) {
        unsigned char c = *p;
        if (c == '"' || c == '\\') {
            if (!buf_grow(&b, 2)) { free(b.data); return NULL; }
            b.data[b.len++] = '\\';
            b.data[b.len++] = (char)c;
        } else {
            if (!buf_grow(&b, 1)) { free(b.data); return NULL; }
            b.data[b.len++] = (char)c;
        }
    }
    if (!buf_grow(&b, 1)) { free(b.data); return NULL; }
    b.data[b.len] = '\0';
    return b.data;
}

This is the canonical “append with amortized growth” pattern. It is safe if you check allocation failures and ensure NUL termination.

Pattern: Efficient Tokenization Without Over-Splitting

Tokenization is frequently implemented as split followed by processing tokens. That is easy but can allocate many substrings and an array/list of tokens. When you only need to scan tokens sequentially (e.g., parse key-value pairs, count fields, validate structure), prefer an iterator-style tokenizer that yields spans/indices.

Python: iterate over delimiter positions

def iter_fields(s: str, sep: str = ','):
    start = 0
    while True:
        idx = s.find(sep, start)
        if idx == -1:
            yield s[start:]
            return
        yield s[start:idx]
        start = idx + 1

This still slices (allocates) each field. If you want to avoid substring allocation, yield indices instead and slice only when needed, or operate on memoryview with bytes input.

Java: index-based scanning

interface FieldConsumer { void accept(int start, int end); }

static void scanFields(String s, char sep, FieldConsumer c) {
    int start = 0;
    int n = s.length();
    for (int i = 0; i < n; i++) {
        if (s.charAt(i) == sep) {
            c.accept(start, i);
            start = i + 1;
        }
    }
    c.accept(start, n);
}

This avoids creating substrings entirely; the consumer can parse numbers directly from the original string using indices. This is a powerful pattern when you need speed and want to control allocations.

Ruby: scanning with indices

def scan_fields(s, sep = ',')
  start = 0
  i = 0
  while i < s.bytesize
    if s.getbyte(i) == sep.ord
      yield start, i
      start = i + 1
    end
    i += 1
  end
  yield start, s.bytesize
end

This is byte-oriented and assumes single-byte separator in the current encoding. If the input can contain multibyte characters and you need character indices, use each_char with a different approach, but expect higher overhead.

C: token spans

#include <stddef.h>

typedef void (*span_consumer)(const char* base, size_t start, size_t end, void* ctx);

void scan_fields_c(const char* s, char sep, span_consumer c, void* ctx) {
    size_t start = 0;
    for (size_t i = 0; s[i] != '\0'; i++) {
        if (s[i] == sep) {
            c(s, start, i, ctx);
            start = i + 1;
        }
    }
    size_t end = start;
    while (s[end] != '\0') end++;
    c(s, start, end, ctx);
}

Span-based processing is a foundation for fast text pipelines in C: you can parse integers, compare tokens, and validate formats without copying.

Unicode Correctness: Case Folding, Normalization, and Boundaries

Text transformations often break when they assume “one character equals one byte” or “uppercase/lowercase is reversible.” Efficient code must still be correct for the domain. Decide explicitly what you support: ASCII-only identifiers, UTF-8 user input, or full Unicode semantics.

Case conversion: lowercasing vs case folding

Lowercasing is locale-sensitive in some environments; case folding is intended for caseless matching. For identifiers, you may want a stable, locale-independent transform.

  • Python: s.casefold() is stronger than lower() for caseless matching.
  • Ruby: case operations depend on encoding; for full Unicode case folding you may need specialized support.
  • Java: toLowerCase(Locale.ROOT) avoids locale surprises (e.g., Turkish dotted/dotless i).
  • C: tolower is locale-dependent and byte-based; it is not Unicode-aware.
// Java: locale-stable lowercasing for identifiers
String normalized = input.toLowerCase(java.util.Locale.ROOT);

Normalization (NFC/NFD) and performance

Unicode normalization can be expensive. Only normalize if you need canonical equivalence (e.g., user-visible comparisons, security-sensitive identifier matching). If you do normalize, do it once at boundaries (ingress) rather than repeatedly in inner loops. In Java, java.text.Normalizer can normalize strings; in Python, unicodedata.normalize does the same. In Ruby and C, normalization typically requires external libraries; if you can constrain input to NFC UTF-8, document and validate that constraint early.

Indexing and slicing safety

In UTF-16 (Java) and UTF-8 (common in C, Ruby, Python internal varies), slicing by “character index” can split a surrogate pair or multibyte sequence if done incorrectly. For transformations that must preserve user-perceived characters, operate on code points (Java) or on Python str (code points) while remembering that grapheme clusters (like emoji + modifiers) can still be multiple code points. If you only need delimiter-based parsing on ASCII separators, prefer byte scanning and treat the rest as opaque.

Regex: When to Use It and How to Keep It Fast

Regular expressions are powerful but can be slow if they trigger backtracking, allocate many match objects, or are recompiled repeatedly. Efficient usage is less about “never use regex” and more about using it where it replaces complex logic, while controlling compilation and match strategy.

Practical rules

  • Compile once, reuse many times (Python: module-level re.compile; Ruby: literal regex is already compiled; Java: Pattern.compile and reuse).
  • Avoid patterns prone to catastrophic backtracking; prefer possessive quantifiers or atomic groups in Java when needed.
  • Prefer simple scanning for single-character delimiters or fixed substrings; regex is overkill there.
  • When replacing many different literals, consider a single pass with a small state machine instead of chaining replace.
# Python: compile once
import re
WS_RE = re.compile(r"\s+")

def collapse_ws_regex(s: str) -> str:
    return WS_RE.sub(" ", s).strip()

This is concise, but it does multiple logical operations (sub + strip) and may allocate more than the single-pass approach. Choose based on throughput needs and complexity.

Chained Replacements vs Multi-Replace in One Pass

Chaining replacements like s.replace(a,b).replace(c,d)... creates a new string each time. If you have many replacements, consider a single-pass approach using a lookup table or a trie-like matcher for multi-character tokens.

Python: translate for single-character mapping

When mapping individual characters (e.g., normalize separators), str.translate is implemented in C and can be very fast.

TABLE = str.maketrans({
    '\t': ' ',
    '\n': ' ',
    '\r': ' ',
    '_': '-',
})

def normalize_separators(s: str) -> str:
    return s.translate(TABLE)

Ruby: tr for single-character mapping

def normalize_separators(s)
  s.tr("\t\n\r_", "   -")
end

tr is optimized for character-by-character translation. It is not a general substring replacement tool, but for this class of problems it is ideal.

Java: manual mapping in one pass

static String mapChars(String s) {
    StringBuilder sb = new StringBuilder(s.length());
    for (int i = 0; i < s.length(); i++) {
        char ch = s.charAt(i);
        switch (ch) {
            case '\t':
            case '\n':
            case '\r': sb.append(' '); break;
            case '_': sb.append('-'); break;
            default: sb.append(ch);
        }
    }
    return sb.toString();
}

This fuses multiple replacements into one pass with predictable performance.

Streaming Transformations: Process Text Without Holding It All

Some workloads are dominated by I/O and large inputs (logs, CSV, JSON lines). Efficient string processing then means streaming: transform line-by-line or chunk-by-chunk, and avoid building huge intermediate strings. Even when you ultimately need a full output, streaming can reduce peak memory and improve cache behavior.

Python: streaming with file iteration and incremental writing

import sys

def transform_stream(fin, fout):
    for line in fin:
        # Example: normalize line endings and collapse whitespace
        line = line.rstrip('\n')
        fout.write(collapse_ws(line))
        fout.write('\n')

# transform_stream(sys.stdin, sys.stdout)

Use sys.stdin.buffer and bytes-based processing when input is ASCII/UTF-8 and transformations are byte-safe.

Ruby: streaming with each_line

def transform_stream(fin, fout)
  fin.each_line do |line|
    line = line.delete_suffix("\n")
    fout << collapse_ws(line) << "\n"
  end
end

Java: buffered streaming

static void transform(java.io.BufferedReader r, java.io.BufferedWriter w) throws java.io.IOException {
    String line;
    while ((line = r.readLine()) != null) {
        w.write(collapseWs(line));
        w.newLine();
    }
}

readLine() strips line terminators; if you need to preserve exact endings, read raw chars into a buffer and scan manually.

C: chunked reading and boundary handling

Chunked processing in C is fast but requires careful boundary handling when tokens can cross chunk boundaries. If your transform is line-based, keep a carryover buffer for the partial line at the end of each chunk, append the next chunk, and process complete lines. For delimiter-based tokenization, keep the partial token similarly.

// Sketch: keep a carry buffer for partial lines (details omitted)
// read(fd, buf, sizeof buf); append to carry; process complete lines; keep remainder

Practical Checklist for Choosing an Implementation

  • Is the transform ASCII-only or Unicode-sensitive? Decide first; it changes the algorithm and the APIs.
  • Can you do it in one pass? If you currently chain operations, consider fusing them.
  • Can you avoid building output when no changes are needed? Add a fast path scan.
  • Are you accidentally allocating many substrings (split) when you only need sequential access? Switch to index/span scanning.
  • Does output size have a known bound? Preallocate (Java StringBuilder(capacity), C malloc with bound) or use amortized growth.
  • Are you using regex in a hot path? Compile once; simplify patterns; consider manual scanning for fixed delimiters.
  • Are you slicing safely with respect to encoding? In Java, be careful with surrogate pairs; in C/Ruby byte scanning, ensure you are not breaking UTF-8 unless you treat it as opaque bytes.

Now answer the exercise about the content:

Which approach best reduces both runtime and allocations when transforming a large string with multiple simple rules (such as trimming, collapsing whitespace, and mapping separators)?

You are right! Congratulations, now go to the next page

You missed! Try again.

Single-pass transforms minimize repeated traversals and avoid creating many intermediate strings. Using a builder or buffer prevents quadratic concatenation, and a fast path can skip output building when no changes are required.

Next chapter

Caching Patterns and Memory-Conscious Data Structures

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.