Skip to content

Strings as Lists of Characters

in_ Clausal, strings are treated as lists of single-character strings at the logic level. You can use list predicates, pattern matching, and DCGs on strings directly — no conversion needed.


Why?

in_ logic programming, sequences are the universal data structure. Lists hold elements; strings hold characters. The operations you want on both are the same: split, join, reverse, search, filter, iterate. Maintaining two parallel sets of predicates — one for lists, one for strings — doubles the API surface and forces users to constantly ask "am I working with a string or a list right now?"

Clausal eliminates this by treating a string as a list of its characters wherever a list is expected. Under the hood, strings are still Python str objects (fast, compact, interoperable with Python libraries). But at the logic level, "hello" and ['h', 'e', 'l', 'l', 'o'] are interchangeable.


Unification

A string unifies with a list of single-character strings:

Test("string = char list") <- ("abc" is ["a", "b", "c"])  # nv
Test("with vars") <- ("abc" is [X, Y, Z], X == "a", Y == "b", Z == "c")  # nv
Test("partial") <- ("hello" is ["h", "e", X, Y, "o"], X == "l", Y == "l")  # nv

String-to-string unification is unchanged — "abc" = "abc" succeeds by equality (no element-wise comparison needed).

The empty string unifies with the empty list:

Test("empty") <- ("" is [])  # nv

Pattern Matching

Multi-star list patterns work on strings. Star variables bind to substrings (not character lists), preserving the str type:

starts_with([*PREFIX, *_], PREFIX),
ends_with([*_, *SUFFIX], SUFFIX),
contains([*_, X, *_], X),

Test("prefix") <- starts_with("hello", "hel")  # nv
Test("suffix") <- ends_with("hello", "lo")  # nv
Test("contains l") <- contains("hello", "l")  # nv

This is the same pattern syntax used for lists — no special string patterns needed. when matching a string, *Prefix binds to a substring; when matching a list, it binds to a sublist.


List Predicates on Strings

All list predicates accept strings. when every input is a string and the result is a character sequence, the result is returned as a string:

append

Test("concat") <- append("hel", "lo", "hello")  # nv
Test("prefix match") <- (append("hel", X, "hello"), X == "lo")  # nv
Test("suffix match") <- (append(X, "lo", "hello"), X == "hel")  # nv

length

Test("length") <- length("hello", 5)  # nv

in_ (Member)

Test("member") <- in_("e", "hello")  # nv
Test("enumerate") <- (
    findall(C, in_(C, "abc"), CHARS),
    CHARS == ["a", "b", "c"]
)  # nv

reverse

Test("reverse") <- reverse("hello", "olleh")  # nv

take, drop, split_at

Test("take") <- take(3, "hello", "hel")  # nv
Test("drop") <- drop(3, "hello", "lo")  # nv
Test("split") <- split_at(3, "hello", "hel", "lo")  # nv

list_item

Test("index") <- (list_item(1, "hello", C), C == "e")  # nv

DCGs on Strings

Definite Clause Grammars parse strings directly:

digit >> ([D], {char_type(D, digit)})
digits >> (digit)
digits >> (digit, digits)

Test("parse digits") <- phrase(digits, "123")
Test("partial parse") <- (
    phrase(digits, "12ab", Rest),
    Rest == ['a', 'b']
)

No atom_chars conversion is needed. Pass a string to phrase/2 or phrase/3 and the DCG consumes its characters as list elements.

Character-Level Grammars

Because strings are character lists, you can write character-level grammars naturally:

letter >> ([C], {char_type(C, alpha)})
space >> ([' '])
word >> (letter)
word >> (letter, word)
words >> (word)
words >> (word, space, words)

Test("parse words") <- phrase(words, "hello world")

Type Checking

Three predicates test sequence types (see Type Checking for the full set):

Predicate Strings Lists Purpose
is_list/1 Succeeds Succeeds Polymorphic: is this a list-shaped value (list or char-sequence str)?
is_str/1 Succeeds Fails Exact type test: is this a Python str?
is_chars/1 Succeeds Succeeds union test: is this a character sequence?

is_list/1 is polymorphic over list and str (audit 2026-05-25, F080) so it agrees with every list-flavoured builtin — append, length, reverse, member, maplist, take, drop, etc. — all of which accept a str as a character sequence. Use is_str/1 when you specifically need to distinguish a str from a list.

Test("is_chars string") <- is_chars("hello")  # nv
Test("is_chars list") <- is_chars([1, 2, 3])  # nv
# Audit 2026-05-25 (F080): is_list/1 is now polymorphic over list
# and str — a str is a character sequence, so is_list("hello")
# agrees with maplist/length/append/reverse/member/take/drop/etc.
Test("is_list string succeeds") <- is_list("hello")  # nv
Test("is_str list fails") <- (not is_str([1, 2, 3]))  # nv

String-Specific Predicates

The traditional string predicates (atom_chars/2, atom_concat/3, sub_atom/5, atom_length/2, etc.) still work. They are useful for:

  • Explicit conversion: atom_chars("hello", Chars) gives you a plain list when you specifically need one
  • Code-point operations: atom_codes/2, char_code/2 relate characters to integer code points
  • Character classification: char_type/2 tests character types (alpha, digit, etc.)
  • Case conversion: upcase_atom/2, downcase_atom/2
  • ISO Prolog compatibility

For concatenation, splitting, length, and membership, prefer the list predicates (append/3, length/2, in_/2) — they work uniformly on both strings and lists.


How It Works

Clausal keeps Python str as the internal representation of strings. This preserves performance (string comparison, hashing, and concatenation are fast) and Python interoperability (strings passed to Python functions remain str).

The logic layer adds string-as-list behaviour in four places:

  1. Unification: when a string meets a list, the string is treated as a list of its characters. "abc" unifies with ['a', 'b', 'c'] element-wise. String-vs-string remains fast equality.

  2. Pattern matching: Multi-star patterns ([*A, 'l', *B]) accept strings as match targets. Star variables bind to substrings (e.g. A = "he", B = "lo"), preserving the str type throughout — no character-list conversion happens.

  3. Builtins: List predicates accept strings wherever they accept lists. when the result should be a string (all inputs were strings, result is a char sequence), a string is returned.

  4. Head patterns: Compiled clause head patterns like [H, *T] work on strings. H binds to a single character, T binds to the remaining substring (str, not a list).

This is a Liskov-style subtyping approach: a string can be used anywhere a list of characters is expected, with no loss of functionality.


Code-point vs grapheme semantics

Clausal's strings-as-lists contract operates at code-point granularity, not grapheme granularity. This means:

  • A multi-codepoint emoji like "👍🏽" (thumbs-up + skin-tone modifier) has len("👍🏽") == 2 and unifies with ["👍", "🏽"], not ["👍🏽"].
  • A base character followed by a combining mark — "é" written as decomposed form (base e + combining acute U+0301) — has length 2 and unifies with ["e", "́"]. The same character in precomposed form ("é", U+00E9) has length 1 and unifies with ["é"]. NFC and NFD representations of the same grapheme do not unify with each other.
  • Lone surrogate halves are processed as individual code points (Python permits malformed Unicode at the surrogate level).
  • List elements that are multi-character strings (e.g. ["ab", "c"]) are rejected by the per-element check; only 1-character list elements participate in str↔list unification.

This rule applies uniformly across:

  • C-level unify (str ↔ list).
  • SegList and SegString walks and unification.
  • Head and body multi-star patterns over string targets.
  • phrase/2,3 and DCG terminals.
  • char_type, char_code, atom_chars, atom_codes.
  • All polymorphic list builtins (append, length, member, etc.).

If your application needs grapheme-aware processing (e.g. cursor movement in a text editor), use the standard Python library unicodedata or the third-party regex/grapheme packages before handing the string to Clausal — Clausal sees code points.


Comparison with Prolog

in_ Prolog systems like Scryer Prolog, strings are lists of characters — the same data structure, with no distinction. This gives maximum uniformity at the cost of performance (no compact string representation) and foreign-function interop (every string is a linked list of character atoms).

Clausal takes a pragmatic middle path: strings behave as lists of characters at the logic level, but remain Python str objects internally. You get the logical uniformity of Prolog's approach with the performance and interop of Python's native strings.

This doc covers the chars model (a str is a list of one-character strings). Clausal also has the Prolog codes model for byte sequences: a Python bytes behaves as a list of integer codes in [0, 255]. See Bytes as Lists of Codes for byte-stream unification and binary-protocol DCGs.

Feature Traditional Prolog Clausal
String representation List of character atoms Python str
append/3 on strings Works (strings are lists) Works (strings behave as lists)
DCGs on strings Works Works
Pattern matching Works Works — star vars bind to substrings
Performance O(n) cons cells O(1) Python str operations
Python interop Requires conversion Native str

Examples

Palindrome check (works on both strings and lists)

palindrome(XS) <- reverse(XS, XS)

Test("list palindrome") <- palindrome([1, 2, 1])
Test("string palindrome") <- palindrome("racecar")
Test("not palindrome") <- not palindrome("hello")

Character frequency

Using findall to count matching characters:

char_count(Str, Char, Count) <- (
    findall(C, (in_(C, Str), C == Char), Matches),
    length(Matches, Count)
)

Test("count l") <- char_count("hello", 'l', 2)
Test("count z") <- char_count("hello", 'z', 0)

Simple tokenizer with DCGs

alpha >> ([C], {char_type(C, alpha)})
digit >> ([C], {char_type(C, digit)})

alphas >> (alpha)
alphas >> (alpha, alphas)

digits >> (digit)
digits >> (digit, digits)

token(word) >> (alphas)
token(number) >> (digits)

Test("word token") <- phrase(token(word), "hello")
Test("number token") <- phrase(token(number), "42")