Bytes, Bounds, and Unicode Gremlins

By FreePare Team · Mon Aug 03 2026 · 12 min read

Bytes, Bounds, and Unicode Gremlins

Arrays, strings, allocation, and ownership in C23, Java SE 25, and Python 3.14

Memory bugs enjoy costumes.

An out-of-bounds write may dress as a networking failure. A shared inner list may pretend to be a copy. A Unicode grapheme may arrive wearing seven code points and insist that it is one visible character.

The cure is not fear. It is a precise model of storage, bounds, copying, encoding, and ownership.

What this article covers

1. A C array is not a pointer

They are closely related in expressions, but they are different types.

#include <stdio.h>

int main(void) {
    int values[10] = {0};
    int *pointer = values;

    printf("%zu\n", sizeof values);  /* 10 * sizeof(int) */
    printf("%zu\n", sizeof pointer); /* Size of a pointer */
}

The array contains ten int objects. The pointer contains an address.

In many expressions, the array is converted to a pointer to its first element. Important exceptions include operands of sizeof, unary &, and some initialization contexts.

This function parameter:

void process(int values[10]);

is adjusted to a pointer parameter. The 10 does not make the function receive an array by value or enforce a minimum length.

Use an explicit length:

void double_values(int *values, size_t length) {
    for (size_t index = 0; index < length; ++index) {
        values[index] *= 2;
    }
}

The caller must supply a pointer and a truthful accessible length.

2. Bounds: invisible fence, exception, or negative indexing

C: no required check at all

int values[10] = {0};
values[10] = 42; /* Undefined behavior. */

The one-past pointer may be formed for iteration, but it may not be dereferenced. A compiler is not required to insert a bounds check.

Java: an exception the language requires

int[] values = new int[10];
values[10] = 42; // ArrayIndexOutOfBoundsException

Java arrays are objects with a length component. Access outside 0 through length - 1 throws.

The language requires the behavior, but it does not imply that every bounds check remains as a separate machine instruction. A JIT compiler may eliminate redundant checks when it proves an index is safe.

Python: an exception, and negative indexing

values = [0] * 10
values[10] = 42  # IndexError

Python sequences also support negative indices:

values[-1] = 42  # Last element

That is a documented feature, not wraparound for every negative number. values[-11] still raises IndexError for a ten-element list.

3. Fixed arrays and growing sequences

C arrays have a fixed number of elements. A manually managed dynamic array needs at least:

Java's ArrayList and Python's list provide dynamic sequence behavior. Their append operations are typically amortized \(O(1)\), meaning occasional resizing is paid for across many inexpensive appends.

Exact capacity growth is an implementation detail. Do not build application correctness around a particular CPython list-growth factor or a specific JDK ArrayList formula.

If a latency-sensitive operation cannot tolerate occasional resizing, reserve capacity where the API permits it or use a data structure with the required behavior.

4. Copying: the outer container may lie politely

Java arrays

Assignment copies the reference:

int[] original = {1, 2, 3};
int[] alias = original;

alias[0] = 99;
System.out.println(original[0]); // 99

Copy the elements:

int[] copy = java.util.Arrays.copyOf(original, original.length);

For an array of object references, this is a shallow copy. The new array contains copied references to the same objects.

Python lists

original = [[1], [2]]
shallow = original.copy()

shallow[0].append(99)
print(original)  # [[1, 99], [2]]

The outer list is new. Its inner lists are shared.

Use copy.deepcopy only when recursive copying matches the domain semantics. Deep copying arbitrary object graphs can be expensive and may duplicate objects that should remain shared.

C structs

Assigning a C struct copies its member values. Pointer members remain pointers to the same allocated objects:

typedef struct {
    char *name;
    size_t length;
} Label;

Label first = /* ... */;
Label second = first; /* second.name aliases first.name */

A true ownership-independent copy needs a separately allocated name and a documented destructor.

5. Slices can be copies or views

Python list slicing creates a new outer list:

values = [0, 1, 2, 3, 4]
section = values[1:4]

section[0] = 99
print(values)  # [0, 1, 2, 3, 4]

memoryview is different:

data = bytearray(b"hello")
view = memoryview(data)[1:4]
view[0] = ord("A")

print(data)  # bytearray(b'hAllo')

Java's Arrays.copyOfRange copies array elements. ByteBuffer.slice() can create a view sharing content with the original buffer.

C has no built-in slice object, but you can define one:

typedef struct {
    int *data;
    size_t length;
} IntSpan;

An IntSpan is normally a borrowed view. The owner must outlive the span, and indexing must stay below length.

The word "slice" is not enough. Every API should declare:

6. C strings are arrays with a sentinel

A C string is a sequence of characters ending with a null character.

char mutable_text[] = "hello";
const char *literal = "hello";

mutable_text[0] = 'H'; /* Defined. */
/* literal[0] = 'H'; */ /* Constraint violation through const. */

A string literal has array type in C, but attempting to modify it has undefined behavior. Using const char * prevents accidental modification through that pointer.

strlen counts bytes before the first null byte:

#include <stdio.h>
#include <string.h>

int main(void) {
    const char *text = "café"; /* Assuming a UTF-8 source/execution encoding. */
    printf("%zu\n", strlen(text));
}

Under UTF-8, this commonly prints 5, because é uses two bytes. The source and execution character-set assumptions must be declared; the C language does not make every char string UTF-8.

Also remember that binary data can contain zero bytes. strlen is not a binary-buffer length function.

7. Unicode has more than one useful unit

Which unit an API counts is a testing problem as much as a text problem: the edge-case matrix in The Slow Death of Assumptions lists the four inputs that separate them.

Consider these terms:

They answer different questions.

Java: UTF-16 code units

Java String APIs expose a sequence of UTF-16 code units:

public class UnicodeLab {
    public static void main(String[] args) {
        String gothicLetter = "\uD800\uDF48"; // U+10348

        System.out.println(gothicLetter.length()); // 2 code units
        System.out.println(
            gothicLetter.codePointCount(0, gothicLetter.length())
        ); // 1 code point
        System.out.printf(
            "U+%X%n",
            gothicLetter.codePointAt(0)
        ); // U+10348
    }
}

This describes String's language/API model. HotSpot may use Compact Strings internally for content representable in Latin-1. Physical storage is an implementation detail.

Do not split at an arbitrary char index when supplementary code points are possible.

Python: code points

Python str indexing operates in Unicode code points:

gothic_letter = "\U00010348"

print(len(gothic_letter))  # 1
print(gothic_letter[0])    # 𐍈

That still does not equal user-perceived characters:

family = "👨‍👩‍👧‍👦"
print(len(family))  # 7 code points

The displayed family is formed from multiple emoji code points connected by zero-width joiners.

Use a Unicode text-segmentation implementation when the product requirement is cursor movement, deletion, display width, or "characters visible to a user."

8. Encoding is an explicit boundary

Text becomes bytes through encoding. Bytes become text through decoding.

Java: the charset the method contract names

Files.readString(path) uses UTF-8. For another encoding, supply it:

String text = java.nio.file.Files.readString(
    path,
    java.nio.charset.StandardCharsets.UTF_16
);

Older or different APIs may use a default charset, so check the exact method contract rather than applying one rule to all Java I/O.

Python 3.14

For Python 3.14, text-mode open() without an encoding uses the locale-dependent default unless UTF-8 mode or another configuration changes it:

with open("data.txt", "r", encoding="utf-8") as file:
    text = file.read()

If the file format is defined as UTF-8, state it in code.

C: bytes, and a policy you supply

C byte-oriented I/O reads bytes. The program or a library must apply the expected encoding and error policy.

Every external text boundary should define:

9. Equal-looking strings may have different code points

The character é can be represented as:

import unicodedata

first = "\u00E9"
second = "e\u0301"

print(first == second)  # False
print(
    unicodedata.normalize("NFC", first)
    == unicodedata.normalize("NFC", second)
)  # True

Java provides java.text.Normalizer.

Normalization is a domain decision, not a universal instruction to transform every string. Identifiers, filenames, search text, and security-sensitive values may require different policies. Define the policy before hashing, comparing, storing, or displaying the data.

10. C allocation begins with checked arithmetic

The check exists because the multiplication can leave the type's range before malloc ever sees it. Integer Boundaries: C23 vs Java SE 25 vs Python 3.14 covers what C, Java and Python each do at that boundary.

This allocation can overflow before malloc sees the value:

Widget *items = malloc(count * sizeof *items);

Check the multiplication:

#include <stdint.h>
#include <stdlib.h>

Widget *allocate_widgets(size_t count) {
    if (count > SIZE_MAX / sizeof(Widget)) {
        return NULL;
    }

    return malloc(count * sizeof(Widget));
}

Some platforms provide reallocarray, which performs this check, but it is not an ISO C23 function.

calloc

calloc(count, size) allocates space and initializes all bits to zero. It does not portably promise that an all-bits-zero representation is every type's semantic zero value, especially for pointer or floating representations.

Do not describe calloc as universally safer or slower. Choose it when zeroed bytes are the intended initialization and measure performance when it matters.

11. realloc needs a temporary pointer

int *resize_ints(int *items, size_t count) {
    if (count > SIZE_MAX / sizeof *items) {
        return NULL;
    }

    int *resized = realloc(items, count * sizeof *items);
    if (resized == NULL) {
        return NULL; /* Original items remains valid for nonzero size. */
    }

    return resized;
}

There is still an API-design problem: returning NULL does not let the caller distinguish overflow from allocation failure, and the caller must retain the original pointer until success is known.

A stronger interface accepts a pointer-to-pointer and reports a status, or returns a result object containing status and pointer.

For C23, avoid realloc(pointer, 0). With a non-null pointer and zero size, the behavior is undefined. Handle zero explicitly:

if (new_count == 0) {
    free(items);
    items = NULL;
}

12. Ownership must be visible in the API

This C function needs documentation:

char *read_file(const char *path);

Questions include:

A clearer result might be:

typedef struct {
    unsigned char *data;
    size_t length;
} OwnedBuffer;

bool read_file(const char *path, OwnedBuffer *out);
void owned_buffer_destroy(OwnedBuffer *buffer);

Naming is not enforcement, but it gives reviewers something concrete to verify.

Nulling a pointer is local damage control

free(pointer);
pointer = NULL;

This prevents accidental reuse through that one pointer variable. It does not update aliases:

int *alias = pointer;
free(pointer);
pointer = NULL;

/* alias is still dangling. */

The real fix is a clear ownership model that limits aliases and defines when all borrowed views expire.

13. Managed memory still has resources

Java and Python prevent ordinary application code from manually freeing managed objects. They do not automatically close:

Use Java try-with-resources:

try (var reader = java.nio.file.Files.newBufferedReader(path)) {
    System.out.println(reader.readLine());
}

Use Python context managers:

with open("data.txt", encoding="utf-8") as file:
    print(file.readline())

Garbage collection manages memory reachability. It is not a deterministic cleanup schedule for external resources.

14. Let tools catch the gremlins

For C, compile test builds with warnings and sanitizers:

gcc -std=c2x -O1 -g \
    -Wall -Wextra -Wconversion -Wshadow \
    -fsanitize=address,undefined \
    memory_lab.c

Useful tools include:

Tools improve detection. They do not redefine undefined behavior or prove the absence of every memory bug.

15. A publication-quality experiment

A credible array or memory article should include:

1. Full source code.

2. Compiler/runtime version.

3. Target architecture.

4. Exact build command.

5. Expected output.

6. Sanitizer output for the intentionally broken version.

7. Corrected code.

8. An explanation tied to the language specification.

That turns "C is dangerous" into evidence a reader can reproduce.

Try it yourself

1. Compare sizeof(array) with sizeof(pointer) before and after passing an array to a function.

2. Trigger one bounds error in each language and record the different failure modes.

3. Create shallow and independent copies of a nested structure.

4. Count bytes, UTF-16 code units, code points, and grapheme clusters for the same string.

5. Run an intentional C use-after-free under AddressSanitizer, then repair the ownership design.

Exit test

You understand this topic when you can state, for any sequence or string:

1. Where its bounds come from.

2. Whether an operation returns a copy, alias, or view.

3. Who owns the underlying storage.

4. Which text unit an API counts.

5. Which encoding converts between text and bytes.

6. How allocation failure, invalid input, and cleanup are represented.

Final allocation

Memory becomes much less mysterious when every operation answers six questions:

How large? Which bounds? Copy or view? Who owns it? Which text unit? Which encoding?

If the API cannot answer them, the gremlin is already inside the machine. It is simply waiting for an interesting input.

Tags: c, memory, unicode, strings, arrays, pointers, programming, placements