Bytes, Bounds, and Unicode Gremlins
By FreePare Team · Mon Aug 03 2026 · 12 min read
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
- The difference between C arrays and pointers.
- Array-to-pointer conversion at function boundaries.
- Bounds behavior in C, Java, and Python.
- Fixed-size arrays versus dynamic sequences.
- Shallow and deep copying.
- Slices, copies, and views.
- C null-terminated strings and string literals.
- Java UTF-16 code-unit semantics.
- Python Unicode code-point semantics.
- Grapheme clusters and user-perceived characters.
- Encoding and decoding boundaries.
- Unicode normalization.
- Safe C allocation-size calculations.
- Correct
reallochandling in C23. - Ownership, aliases, cleanup, and diagnostic tooling.
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; // ArrayIndexOutOfBoundsExceptionJava 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 # IndexErrorPython sequences also support negative indices:
values[-1] = 42 # Last elementThat 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:
- A pointer to storage.
- The number of initialized elements.
- The allocated capacity.
- A growth policy.
- An ownership and cleanup rule.
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]); // 99Copy 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]A 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:
- Copy or view?
- Mutable or read-only?
- Who owns the underlying storage?
- How long is the view valid?
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:
- Byte - storage unit used by an encoding such as UTF-8.
- Code unit - one unit in an encoding form, such as a 16-bit UTF-16 unit.
- Code point - a Unicode scalar value or code point number.
- Grapheme cluster - an approximation of one user-perceived character.
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 pointsThe 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:
- Encoding.
- Invalid-sequence behavior.
- Byte-order-mark policy where relevant.
- Newline policy.
- Normalization policy, if the domain needs one.
9. Equal-looking strings may have different code points
The character é can be represented as:
- U+00E9: precomposed
é. - U+0065 U+0301:
efollowed by a combining acute accent.
import unicodedata
first = "\u00E9"
second = "e\u0301"
print(first == second) # False
print(
unicodedata.normalize("NFC", first)
== unicodedata.normalize("NFC", second)
) # TrueJava 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:
- Is the returned pointer newly allocated?
- Which function frees it?
- Can it be null?
- Is it null-terminated?
- Is embedded null data possible?
- Where is the byte length?
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:
- Files.
- Sockets.
- Database connections.
- Native buffers.
- Locks.
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.cUseful tools include:
- AddressSanitizer for out-of-bounds access and use-after-free.
- UndefinedBehaviorSanitizer for many undefined operations.
- LeakSanitizer where supported.
- Valgrind on supported platforms.
- Static analyzers and compiler warnings.
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