Type conversion is a fundamental operation in programming languages, but traditional approaches often lead to subtle bugs, security vulnerabilities, and unpredictable behavior. Functions like C's atoi()
or Go's strconv
package provide type conversion capabilities, but their behavior can be inconsistent, error-prone, and difficult to reason about.
ual's bring_<type>
operation represents a principled alternative that addresses these issues through a carefully designed approach to type conversion. This document examines the four key aspects of this system and how they eliminate common problems found in other languages.
In many languages, type conversions can happen implicitly:
// JavaScript
let value = "42" + 10; // Results in "4210" (string concatenation)
let anotherValue = "42" - 10; // Results in 32 (numeric subtraction)
Even with explicit conversion functions, the intention isn't always clear:
// C
int value = atoi(input_string); // What happens if input_string is invalid?
In ual, conversions must be explicitly requested through the appropriate bring_<type>
method:
-- ual
@string_stack: push("42")
@integer_stack: bring_string(string_stack.pop()) -- Explicitly convert string to integer
This explicit approach has several benefits:
- Clear programmer intent: The code explicitly states the intention to convert from one type to another
- Self-documenting code: Reading the code immediately reveals what type conversions are happening
- Consistent mental model: Developers always know exactly how type conversions occur
- Visibility in code reviews: Type conversions are easily identified during code review
ual's typed stacks make the source and destination types explicit, eliminating ambiguity about what conversion is being performed. The operation's name - bring_string
, bring_integer
, etc. - clearly communicates both the source type and the intended destination type.
Many languages use generic conversion functions with unpredictable behavior:
// C
// What happens with atoi("42hello")? Or atoi("")?
int val1 = atoi("42hello"); // Returns 42, silently ignoring "hello"
int val2 = atoi(""); // Returns 0, which could be valid input or error
// Go
// Many possible ways to convert, each with different behavior
i, err := strconv.Atoi("42")
i64, err := strconv.ParseInt("42", 10, 64)
f, err := strconv.ParseFloat("42.5", 64)
ual implements each conversion path with specific, well-defined logic tailored to the source and destination types:
-- ual
-- Integer to Float conversion - well-defined, preserves numeric value
@integer_stack: push(42)
@float_stack: bring_integer(integer_stack.pop()) -- Converts to 42.0
-- String to Integer conversion - well-defined parsing rules
@string_stack: push("42")
@integer_stack: bring_string(string_stack.pop()) -- Parses to integer 42
-- String to Float conversion - different parsing logic for floating-point
@string_stack: push("42.5")
@float_stack: bring_string(string_stack.pop()) -- Parses to float 42.5
Each conversion path in ual has:
- Specialized parsing/conversion logic: Type-specific rules for how conversions work
- Consistent behavior: Well-defined handling of edge cases
- Appropriate error generation: Type-specific error messages when conversion fails
- Optimization opportunities: Each conversion path can be implemented efficiently
By having distinct methods for each conversion type, ual ensures that the behavior is specifically designed for that particular conversion, rather than trying to handle all conversions through generic functions.
Many languages allow arbitrary type casting, opening the door to undefined behavior:
// C
float f = 3.14;
int* ptr = (int*)&f; // Reinterprets float bits as integer pointer - dangerous!
*ptr = 42; // Undefined behavior
Even in safer languages, the proliferation of conversion functions can lead to confusion:
# Python - many ways to convert, with different behaviors
int("42") # 42
float("42") # 42.0
int("42.5") # ValueError
int(float("42.5")) # 42 (truncates)
round(float("42.5")) # 42 (rounds)
ual only permits conversions that have been explicitly defined in the type system:
-- ual
-- These conversions are defined and have clear semantics
@string_stack: push("42")
@integer_stack: bring_string(string_stack.pop()) -- Defined conversion
@integer_stack: push(42)
@float_stack: bring_integer(integer_stack.pop()) -- Defined conversion
-- If a conversion isn't defined, it's not possible:
-- Hypothetical undefined conversion would be a compile-time error
-- @complex_number_stack: bring_string(string_stack.pop()) -- If not defined, not allowed
This controlled approach offers:
- Compile-time safety: Many invalid conversions are caught at compile time
- Well-defined semantics: Each permitted conversion has clear, documented behavior
- No undefined behavior: Impossible to perform arbitrary type reinterpretation
- Extensibility: New conversion paths can be added in a controlled manner
By restricting conversions to only those that are explicitly defined, ual prevents a whole class of bugs related to inappropriate or dangerous type conversions.
Traditional conversion approaches often require multiple steps, which can lead to inconsistent state if errors occur:
// C
char* input = get_user_input();
int value;
// Multiple steps that can fail at different points:
if (is_valid_number(input)) {
value = atoi(input);
// Use value...
} else {
// Handle error...
}
These multi-step processes create opportunities for bugs:
- Forgetting to check for errors
- Checking for errors incorrectly
- Leaving data structures in inconsistent states when errors occur
ual's bring_<type>
operation combines three operations (pop, convert, push) into a single atomic step:
-- ual
-- Single atomic operation
@float_stack: bring_string(string_stack.pop())
-- If the conversion succeeds, value is on float_stack
-- If the conversion fails, nothing is on float_stack and value is no longer on string_stack
The atomicity guarantee provides:
- Consistent stack state: Either the operation fully succeeds or fully fails
- No partial completion: Impossible to have "half-converted" values
- Clean error handling: Conversion errors can be handled uniformly
- Interrupt safety: Even if interrupted, stacks remain in a consistent state
- Thread safety: In multi-threaded contexts, atomic operations prevent race conditions
This atomic behavior is particularly important in embedded systems, where maintaining system consistency is critical and resources for error recovery may be limited.
ual's approach to type conversion effectively eliminates several classes of bugs that plague other languages:
Problem in other languages:
// JavaScript
"5" + 10 // "510" - string concatenation
"5" - 10 // -5 - numeric subtraction
ual's solution: All conversions must be explicit, preventing unexpected coercion behavior.
Problem in other languages:
// C
void* generic_data = get_data();
int* data_as_int = (int*)generic_data; // Is this actually an int?
ual's solution: Types are attached to stacks, not individual values, and conversions are explicit operations that validate type compatibility.
Problem in other languages:
// C
float f = 3.14;
int i = *(int*)&f; // Reinterprets bit pattern - undefined behavior
ual's solution: Only defined conversions with well-specified behavior are permitted, preventing undefined behavior.
Problem in other languages:
// C
double d = 1234567890.123;
int i = (int)d; // Silently truncates to 1234567890, losing precision
ual's solution: Conversions that might lose information are explicit, making the potential for data loss visible.
Problem in other languages:
// Go
i, err := strconv.Atoi(input)
// Easy to forget to check 'err'
ual's solution:
The atomic nature of bring_<type>
operations makes error handling more consistent, as the operation either fully succeeds or fully fails.
Let's compare a typical input parsing scenario across different approaches:
char* input = get_user_input();
int value;
char* endptr;
// Multiple steps, error-prone
value = strtol(input, &endptr, 10);
if (*endptr != '\0') {
// Conversion error - didn't consume whole string
handle_error();
} else if (errno == ERANGE) {
// Range error
handle_range_error();
} else {
// Success - use value
process_value(value);
}
input := getUserInput()
value, err := strconv.Atoi(input)
if err != nil {
// Handle error
handleError(err)
} else {
// Success
processValue(value)
}
@string_stack: push(get_user_input())
-- Try conversion with atomicity guarantees
success, err = pcall(function()
@integer_stack: bring_string(string_stack.pop())
end)
if success then
process_value(integer_stack.pop())
else
handle_error(err)
end
-- Or more elegantly with .consider pattern:
function parse_input()
@string_stack: push(get_user_input())
result = {}
pcall(function()
@integer_stack: bring_string(string_stack.pop())
result.Ok = integer_stack.pop()
end, function(err)
result.Err = err
end)
return result
end
-- Usage:
parse_input().consider {
if_ok process_value(_1)
if_err handle_error(_1)
}
The ual approach offers better guarantees about the state of the system after conversion attempts, as well as a more consistent error handling pattern.
ual's bring_<type>
system represents a principled approach to type conversion that addresses many of the shortcomings found in traditional programming languages. By requiring explicit conversion intent, implementing type-specific conversion logic, controlling available conversion paths, and ensuring atomicity, ual eliminates a whole class of bugs related to type manipulation.
This approach is particularly valuable in embedded systems programming, where reliability and predictability are paramount concerns. However, the lessons from this design could benefit programming languages across domains, as type conversion issues are universal problems in software development.
The system demonstrates how careful language design can eliminate entire categories of errors by making the right choices easy and the wrong choices impossible. By prioritizing explicit intent and well-defined behavior over convenience and implicit conversions, ual creates a more robust foundation for reliable software development.