TRPL Rantai Dev Docs Part II Chapter 14 ...
TRPL Rantai Dev Docs Part II Chapter 14 ...
Book Part II arguments, unspecified numbers of arguments, and default arguments. The section on 14.13. Array Arguments
overloaded functions explains automatic overload resolution, the impact of return types, scope 14.14. List Arguments
Chapter 8 considerations, resolution strategies for multiple arguments, and techniques for manual 14.15. Unspecified Number of
overload resolution. Additionally, the chapter highlights the importance of preconditions and Arguments
Chapter 9 postconditions, pointers to functions, and the use of macros, including conditional compilation, 14.16. Default Arguments
Chapter 10 predefined macros, and pragmas. Practical advice is interspersed throughout, aimed at 14.17. Overloaded Functions
Chapter 11 optimizing function use and ensuring robust and efficient code. 14.18. Automatic Overload
Resolution
Chapter 12
Chapter 13 14.1. Why Functions? 14.19. Overloading and
Return Type
Chapter 14 In Rust, functions are key to creating clear, maintainable, and efficient code. They help in breaking 14.20. Overloading and
down complex problems into smaller, manageable pieces, enhancing both readability and Scope
Chapter 15 14.21. Resolution for Multiple
maintainability.
Chapter 16 Arguments
Consider a scenario where we need to perform a series of calculations. If we write all the logic in a 14.22. Manual Overload
Bolt Part III - Abstraction
Mechanism
single function, it might stretch to hundreds or even thousands of lines. Such lengthy functions can be
difficult to understand, maintain, and debug. The primary role of functions is to split these complex
Resolution
14.23. Pre- and
Book Part III tasks into smaller, more manageable parts, each with a meaningful name. This practice not only makes
the code more understandable but also easier to maintain.
Postconditions
14.24. Pointer to Function
Code Part IV Common Libraries
For example, imagine we are implementing a system to process and analyze data. Instead of writing all
14.25. Macros
14.26. Conditional
Book Part IV the data processing logic in one long function, we can break it down into smaller functions. Each
function performs a specific task, such as parsing data, filtering records, or computing statistics. By
Compilation
14.27. Predefined Macros
done Closing Remark giving these functions descriptive names, such as parse_data , filter_records , and
compute_statistics , we make the code more intuitive.
14.28. Pragmas
14.29. Advices
Rust's standard library offers functions like find and sort that serve as building blocks for more 14.30. Further Learning with
complex operations. We can leverage these built-in functions to handle common tasks and then GenAI
combine them to achieve more sophisticated results. This modular approach allows us to construct
complex operations from simple, well-defined functions.
When functions become excessively long, the likelihood of errors increases. Each additional line of
code introduces more potential for mistakes. By keeping functions short and focused on a single task,
we minimize the chance of errors. Short functions also force us to name and document their purpose
and dependencies, leading to more self-explanatory code.
In Rust, a good practice is to keep functions small enough to fit on a single screen—typically around
40 lines or fewer. However, for optimal clarity, aiming for functions that are around 7 lines is even
better. While function calls do have some overhead, it's generally negligible for most use cases. For
performance-critical functions, such as those accessed frequently, Rust's compiler can inline them to
reduce this overhead.
In addition to improving clarity, using functions helps avoid error-prone constructs like goto and
overly complex loops. Instead, Rust encourages the use of iterators and other straightforward
methods. For instance, nested loops can be replaced with iterators that handle complex operations
more safely and elegantly.
In summary, functions are essential for structuring code in Rust. By breaking down tasks into smaller
functions, we create a clear, maintainable codebase. This approach not only makes our code easier to
understand and debug but also helps in managing complexity and improving overall performance.
Argument passing semantics mirror those of copy initialization. Type checking and implicit type
conversions are applied as needed. For instance:
This rigorous checking and conversion are essential for maintaining code safety and accuracy.
Function declarations often include argument names for clarity, although these names are not
mandatory for the function to compile if it's just a declaration. A return type of () signifies that the
function does not return a value.
A function's type comprises its return type and its argument types. For methods within structs or
enums, the struct or enum's name becomes part of the function type. For example:
fn f(i: i32, info: &Info) -> f64; // type: fn(i32, &Info) -> f64
fn String::index(&self, idx: usize) -> &char; // type: fn(&String, usize) -> &char
This setup ensures functions are well-defined and utilized properly, fostering the creation of robust
and efficient code.
14.3. Parts of a Function Declaration
A function declaration not only specifies the name, arguments, and return type but can also include
various specifiers and modifiers. Here are the components:
Name of the function: This is mandatory.
Argument list: This may be empty (); it is also mandatory.
Return type: This can be void and may be indicated as a prefix or suffix (using auto ); it is
required.
In addition, function declarations can include:
inline : Suggests that the function should be inlined for efficiency.
const : Indicates the function cannot modify the object it is called on.
#[no_mangle] : Prevents the compiler from changing the function name during compilation.
Furthermore, functions can also be marked with attributes that specify their behavior, such as
indicating that the function will not return normally, often used for entry points in certain environments.
These elements collectively provide a comprehensive way to declare functions, ensuring their
purpose, usage, and behavior are clearly communicated and understood.
The definition and all declarations of a function must maintain consistent types. Note that to ensure
compatibility, the const qualifier at the highest level of an argument type is ignored. For example,
these two declarations are considered the same:
fn example(x: i32);
fn example(x: i32);
Whether example() is defined with or without the const modifier does not change its type, and the
actual argument can be modified within the function based on its implementation.
Here's how example() can be defined:
1 fn example(x: i32) {
2 // the value of x can be modified here
3 }
Alternatively:
1 fn example(x: i32) {
2 // the value of x remains constant here
3 }
Argument names in function declarations are not part of the function type and can vary across
different declarations. For instance:
In declarations that are not definitions, naming arguments is optional and primarily used for
documentation. Conversely, if an argument is unused in a function definition, it can be left unnamed:
The prefix auto in the second example indicates that the return type follows the argument list,
marked by -> . This syntax is particularly useful in function templates where the return type depends
on the arguments. For instance:
This suffix return type syntax is similar to that used in lambda expressions, although they are not
identical. Functions that do not return a value are specified with a return type of void .
For non-void functions, a return value must be provided. Conversely, it is an error to return a value
from a void function. The return value is specified using a return statement:
A function that calls itself is considered recursive. Multiple return statements can be used within a
function:
The semantics of returning a value are the same as those of copy initialization. The return expression
is checked against the return type, and necessary type conversions are performed.
Each function call creates new copies of its arguments and local variables. Therefore, returning a
pointer or reference to a local non-static variable is problematic because the variable's memory will be
reused after the function returns:
Compilers typically warn about such issues. Although there are no void values, a call to a void function
can be used as the return value of another void function:
1 fn g(p: &i32);
2 fn h(p: &i32) {
3 return g(p); // equivalent to g(p); return;
4 }
This form of return is useful in template functions where the return type is a template parameter. A
function can exit in several ways:
Executing a return statement.
Reaching the end of the function body in void functions and main(), indicating successful
completion.
Throwing an uncaught exception.
Terminating due to an uncaught exception in a noexcept function.
Invoking a system function that does not return (e.g., exit()).
Functions that do not return normally can be marked with [[noreturn]] .
The inline specifier is a hint for the compiler to replace the function call with the function code itself,
which can optimize calls like fac(6) into a constant value such as 720. However, the extent to which
this inlining occurs depends on the compiler's optimization capabilities. Some compilers might
produce the constant 720, others might compute 6 * fac(5) recursively, and others might not inline
the function at all. For assured compile-time evaluation, declare the function as constexpr and
ensure all functions it calls are also constexpr .
To make inlining possible, the function definition must be available in the same scope as its
declaration. The inline specifier does not change the function's semantics; an inline function still has a
unique address, and so do any static variables within it.
If an inline function is defined in multiple translation units (for example, by including it in a header file
used in different source files), the definition must be identical in each translation unit to ensure
consistent behavior.
Using constexpr in a function definition means that the function can be used in constant expressions
with constant arguments. For an object, it means its initializer must be evaluated at compile time. For
example:
1 fn f(n: i32) {
2 let f5 = fac(5); // Might be evaluated at compile time
3 let fn_var = fac(n); // Evaluated at runtime since n is a variable
4 const F6: i32 = fac(6); // Must be evaluated at compile time
5 // const Fnn: i32 = fac(n); // Error: can't guarantee compile-time evaluation for n
6 let a: [u8; fac(4) as usize]; // OK: array bounds must be constants and fac() is constexpr
7 // let a2: [u8; fac(n) as usize]; // Error: array bounds must be constants, n is a variable
8 }
To be evaluated at compile time, a function must be simple: it should consist of a single return
statement, without loops or local variables, and it must not have side effects, meaning it should be a
pure function. For example:
The rules for a constexpr constructor are different, allowing only simple member initialization.
A constexpr function supports recursion and conditional expressions, enabling a broad range of
operations. However, overusing constexpr for complex tasks can complicate debugging and
increase compile times. It is best to reserve constexpr functions for simpler tasks they are intended
for.
Using literal types, constexpr functions can work with user-defined types. Similar to inline functions,
constexpr functions follow the one-definition rule (ODR), requiring identical definitions in different
translation units. Think of constexpr functions as a more restricted form of inline functions.
Here, LOW and HIGH can be considered configuration parameters that are known at compile time, but
not at design time. The function f(x, y, z) computes a value based on implementation specifics.
This example illustrates how conditional evaluation in constexpr functions can handle compile-time
constants while permitting run-time calculations when needed.
1 #[noreturn]
2 fn exit(code: i32) -> ! {
3 // implementation that never returns
4 }
Knowing that a function does not return helps in understanding the code and can assist in optimizing
it. However, if a function marked with #[noreturn] does return, the behavior is undefined.
1 fn f(mut a: i32) {
2 while a > 0 {
3 static mut N: i32 = 0; // Initialized once
4 let x: i32 = 0; // Initialized on each call of f()
5
6 unsafe {
7 println!("n == {}, x == {}", N, x);
8 N += 1;
9 }
10 a -= 1;
11 }
12 }
13
14 fn main() {
15 f(3);
16 }
1 n == 0, x == 0
2 n == 1, x == 0
3 n == 2, x == 0
The use of a static local variable enables a function to retain information between calls without
needing a global variable that could be accessed or altered by other functions. The initialization of a
static local variable does not cause a data race unless the function containing it is entered recursively
or a deadlock occurs. Rust handles this by protecting the initialization of a local static variable with
constructs like std::sync::Once . However, recursively initializing a static local variable leads to
undefined behavior.
Static local variables help avoid dependencies among nonlocal variables. If you need a local function,
consider using a closure or a function object instead. In Rust, the scope of a label spans the entire
function, regardless of the nested scope where it might be located.
In this example, the original slice passed to the find function within g remains unmodified since
slices are passed by reference in Rust.
Rust has particular rules for passing arrays and allows for unchecked arguments through the use of
unsafe blocks. Default arguments can be handled using function overloading or optional parameters
with Option . Initializer lists are supported via macros or custom initializers, and argument passing in
generic functions is handled in a type-safe manner.
This approach ensures that arguments are passed efficiently and safely, adhering to Rust’s principles
of ownership and borrowing.
If we invoke this function with two integers, the first integer remains unchanged outside the function,
while the second integer is incremented.
1 fn g() {
2 let mut i = 1;
3 let mut j = 1;
4 f(i, &mut j);
5 }
Here, i stays 1 after the function call, whereas j becomes 2 . Using functions that modify call-by-
reference arguments can make programs harder to read and should generally be avoided unless
necessary. However, passing large objects by reference can be more efficient than passing by value.
In such scenarios, declaring the parameter as a const reference ensures the function does not
modify the object.
1 fn f(arg: &Large) {
2 // arg cannot be modified
3 }
When a reference parameter is not marked as const , it implies an intention to modify the variable.
Similarly, declaring pointer parameters as const indicates the function will not alter the object being
pointed to.
Using const increases code clarity and reduces potential errors, especially in larger programs. The
rules for reference initialization allow literals, constants, and arguments requiring conversion to be
passed as const T& but not as non-const T& . This ensures safe and efficient passing of arguments
without unintended temporaries.
Passing arguments by rvalue references enables functions to modify temporary objects or objects
about to be destroyed, which is useful for implementing move semantics.
1 fn f(v: Vec<i32>) {
2 // takes ownership of v
3 }
4
5 fn g(vi: &mut Vec<i32>, cvi: &Vec<i32>) {
6 f(vi.clone());
7 f(cvi.clone());
8 f(vec![1, 2, 3, 4]);
9 }
In this example, the function f can accept vectors and modify them, making it suitable for move
semantics. Generally, rvalue references are used for defining move constructors and move
assignments.
When deciding how to pass arguments, consider these guidelines:
1 Use pass-by-value for small objects.
2 Use pass-by-const-reference for large objects that don't need modification.
3 Return results directly instead of modifying arguments.
4 Use rvalue references for move semantics and forwarding.
5 Use pointers if "no object" is a valid option (represented by Option ).
6 Use pass-by-reference only when necessary.
Following these guidelines ensures efficient and clear argument passing, maintaining the principles of
ownership and borrowing.
This means that an argument of type T[] will be converted to T* when passed to a function, allowing
modifications to array elements within the function. Unlike other types, arrays are passed by pointer
rather than by value.
A parameter of array type is equivalent to a parameter of pointer type. For instance:
Both declarations are equivalent and represent the same function. The names of the arguments do not
affect the function's type. The rules for passing multidimensional arrays are similar.
The size of an array is not inherently available to the called function, which can lead to errors. One
solution is to pass the size of the array as an additional parameter:
1 fn compute1(vec: &[i32]) {
2 let vec_size = vec.len();
3 // computation
4 }
However, it is often better to pass a reference to a container like a vector or an array for more safety
and flexibility. For example:
The number of elements is part of the reference-to-array type, making it less flexible than pointers or
containers. References to arrays are especially useful in templates where the number of elements can
be deduced:
This method generates as many function definitions as there are calls with distinct array types. For
multidimensional arrays, using arrays of pointers can often avoid special treatment:
let days: [&str; 7] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
Generally, using vectors and similar types is a better alternative to low-level arrays and pointers,
providing safer and more readable code.
1 fn f1<T>(list: &[T]) {}
2 struct S {
3 a: i32,
4 s: String,
5 }
6 fn f2(s: S) {}
7 fn f3<T, const N: usize>(arr: &[T; N]) {}
8 fn f4(n: i32) {}
9
10 fn g() {
11 f1(&[1, 2, 3, 4]); // T is i32 and the list has 4 elements
12 f2(S { a: 1, s: "MKS".to_string() }); // f2(S { a: 1, s: "MKS".to_string() })
13 f3(&[1, 2, 3, 4]); // T is i32 and N is 4
14 f4(1); // f4(i32::from(1))
15 }
In cases where there might be ambiguity, a parameter with initializer_list takes precedence.
For example:
1 fn f<T>(list: &[T]) {}
2 struct S {
3 a: i32,
4 s: String,
5 }
6 fn f(s: S) {}
7 fn f<T, const N: usize>(arr: &[T; N]) {}
8 fn f(n: i32) {}
9
10 fn g() {
11 f(&[1, 2, 3, 4]); // T is i32 and the list has 4 elements
12 f(S { a: 1, s: "MKS".to_string() }); // calls f(S)
13 f(&[1]); // T is i32 and the list has 1 element
14 }
This declaration specifies that a call to printf must have at least one argument (a format string), but
may include additional arguments. Examples of its usage include:
1 printf("Hello, world!\n");
2 printf("My name is %s %s\n", first_name, second_name);
3 printf("%d + %d = %d\n", 2, 3, 5);
Functions using unspecified arguments must rely on additional information (like a format string) to
interpret the argument list correctly. However, this approach often bypasses the compiler's ability to
check argument types and counts, leading to potential errors. For example:
Although invalid, this code may not be flagged by the compiler, resulting in unpredictable behavior.
In scenarios where argument types and numbers can't be entirely specified, a well-designed program
might only need a few such functions. Alternatives like overloaded functions, default arguments,
initializer_list arguments, and variadic templates should be used whenever possible to
maintain type safety and clarity.
For instance, the traditional printf function from the C library:
To handle variadic arguments, you might use a combination of va_list , va_start , va_arg , and
va_end macros from . Alternatively, a safer and more modern approach involves
std::initializer_list :
You could then call this function with a list of string arguments:
Integrating these concepts in a modern programming context helps maintain type safety and
readability while managing varying numbers of arguments effectively. This aligns with best practices in
software development, ensuring that your programs are robust and maintainable.
1 struct Complex {
2 re: f64,
3 im: f64,
4 }
5
6 impl Complex {
7 fn new(r: f64, i: f64) -> Complex {
8 Complex { re: r, im: i }
9 }
10
11 fn from_real(r: f64) -> Complex {
12 Complex { re: r, im: 0.0 }
13 }
14
15 fn default() -> Complex {
16 Complex { re: 0.0, im: 0.0 }
17 }
18 }
While the actions of these constructors are straightforward, having multiple functions performing
similar tasks can lead to redundancy. This redundancy is more apparent when constructors involve
complex logic. To reduce repetition, one constructor can call another:
1 impl Complex {
2 fn new(r: f64, i: f64) -> Complex {
3 Complex { re: r, im: i }
4 }
5
6 fn from_real(r: f64) -> Complex {
7 Complex::new(r, 0.0)
8 }
9
10 fn default() -> Complex {
11 Complex::new(0.0, 0.0)
12 }
13 }
This consolidation allows for easier implementation of additional functionalities, such as debugging or
logging, in a single place. Further simplification can be achieved with default arguments:
1 impl Complex {
2 fn new(r: f64 = 0.0, i: f64 = 0.0) -> Complex {
3 Complex { re: r, im: i }
4 }
5 }
With default arguments, if fewer arguments are provided, default values are automatically used. This
method clarifies that fewer arguments can be supplied, and defaults will be used as needed, making
the constructor's intent explicit and reducing redundancy.
A default argument is type-checked when the function is declared and evaluated when the function is
called. For example:
1 struct X {
2 def_arg: i32,
3 }
4
5 impl X {
6 fn new() -> X {
7 X { def_arg: 7 }
8 }
9
10 fn f(&self, arg: i32 = self.def_arg) {
11 // Function implementation
12 }
13 }
14
15 fn main() {
16 let mut a = X::new();
17 a.f(); // Uses default argument 7
18 a.def_arg = 9;
19 a.f(); // Uses updated default argument 9
20 }
However, changing default arguments can introduce subtle dependencies and should generally be
avoided. Default arguments can only be provided for trailing parameters. For example:
Reusing or altering a default argument in subsequent declarations in the same scope is not allowed:
Hiding a declaration with a nested scope can lead to errors and should be handled cautiously.
By utilizing default arguments appropriately, you can simplify function declarations, reduce
redundancy, and make your code more maintainable and understandable.
1 fn print(x: i32) {
2 println!("{}", x);
3 }
4
5 fn print(s: &str) {
6 println!("{}", s);
7 }
In the compiler's view, overloaded functions share only their name; they may perform entirely different
tasks. The language does not enforce any similarity between them, leaving it up to the programmer.
Using overloaded function names can make code more intuitive, especially for commonly used
operations like print , sqrt , and open .
This approach is essential when the function name holds significant meaning, such as with operators
like + , * , and << , or with constructors in generic programming. Rust's traits and generics provide a
structured way to implement overloaded functions, enabling the use of the same function name with
different types safely and efficiently.
1 fn print(x: f64) {
2 println!("{}", x);
3 }
4
5 fn print(x: i64) {
6 println!("{}", x);
7 }
8
9 fn f() {
10 print(1i64); // Calls print(i64)
11 print(1.0); // Calls print(f64)
12 // print(1); // Error: ambiguous, could match print(i64) or print(f64)
13 }
Match using standard conversions (e.g., i32 to f64 , f64 to i32 , pointer conversions).
Match using user-defined conversions.
Match using the ellipsis ( ... ) in a function declaration.
If there are two matches at the highest level of criteria, the call is considered ambiguous and results in
a compile-time error. These detailed resolution rules primarily handle the complexity of numeric type
conversions.
For instance:
1 fn print(x: i32) {
2 println!("{}", x);
3 }
4
5 fn print(x: &str) {
6 println!("{}", x);
7 }
8
9 fn print(x: f64) {
10 println!("{}", x);
11 }
12
13 fn print(x: i64) {
14 println!("{}", x);
15 }
16
17 fn print(x: char) {
18 println!("{}", x);
19 }
20
21 fn h(c: char, i: i32, s: i16, f: f32) {
22 print(c); // Calls print(char)
23 print(i); // Calls print(i32)
24 print(s); // Promotes s to i32 and calls print(i32)
25 print(f); // Promotes f to f64 and calls print(f64)
26 print('a'); // Calls print(char)
27 print(49); // Calls print(i32)
28 print(0); // Calls print(i32)
29 print("a"); // Calls print(&str)
30 }
The call to print(0) selects print(i32) because 0 is an integer literal. Similarly, print('a')
calls print(char) since 'a' is a character. These rules prioritize safe promotions over potentially
unsafe conversions.
Overload resolution in Rust is independent of the order in which functions are declared. Function
templates are resolved by applying overload resolution rules after specializing the templates based on
provided arguments. Special rules also exist for overloading with initializer lists and rvalue references.
Although overloading relies on a complex set of rules, which can sometimes lead to unexpected
function calls, it simplifies the programmer's job by allowing the same function name to be used for
different types. Without overloading, we would need multiple function names for similar operations on
different types, leading to tedious and error-prone code. For example, without overloading, one might
need:
1 fn print_int(x: i32) {
2 println!("{}", x);
3 }
4
5 fn print_char(x: char) {
6 println!("{}", x);
7 }
8
9 fn print_str(x: &str) {
10 println!("{}", x);
11 }
12
13 fn g(i: i32, c: char, p: &str, d: f64) {
14 print_int(i); // OK
15 print_char(c); // OK
16 print_str(p); // OK
17 print_int(c as i32); // OK, but may print unexpected number
18 print_char(i as char); // OK, but may narrow unexpectedly
19 // print_str(i); // Error
20 print_int(d as i32); // OK, but narrowing conversion
21 }
Without overloading, the programmer has to remember multiple function names and use them
correctly, which can be cumbersome and prone to errors. Overloading allows the same function name
to be used for different types, increasing the chances that unsuitable arguments will be caught by the
compiler and reducing the likelihood of type-related errors.
If the return type were considered during overload resolution, it would no longer be possible to look at
a function call in isolation to determine which function is being invoked. This would complicate the
resolution process, requiring the context of each call to identify the correct function. By excluding
return types from overload resolution, the language ensures that each function call can be resolved
based solely on its arguments, maintaining clarity and simplicity.
1 fn f(x: i32) {
2 // implementation for i32
3 }
4
5 fn g() {
6 fn f(x: f64) {
7 // implementation for f64
8 }
9 f(1); // calls f(f64)
10 }
Here, although f(i32) would be a better match for f(1) , only f(f64) is in scope. Local
declarations can be adjusted to achieve the desired behavior. Intentional hiding can be useful, but
unintentional hiding can lead to unexpected results.
For base and derived classes, different scopes prevent overloading between base and derived class
functions by default:
1 struct Base {
2 fn f(&self, x: i32) {
3 // implementation for Base
4 }
5 }
6
7 struct Derived : Base {
8 fn f(&self, x: f64) {
9 // implementation for Derived
10 }
11 }
12
13 fn g(d: &Derived) {
14 d.f(1); // calls Derived::f(f64)
15 }
When overloading across class or namespace scopes is necessary, use declarations or directives can
be employed. Additionally, argument-dependent lookup can facilitate overloading across namespaces.
This ensures that overloading remains controlled and predictable, improving code readability and
maintainability.
When the compiler selects the best match among overloaded functions with multiple arguments, it
finds the optimal match for each argument. A function is chosen if it is the best match for at least one
argument and an equal or better match for all other arguments. If no such function exists, the call is
considered ambiguous:
1 fn g() {
2 let d = pow(2.0, 2); // error: pow(i32::from(2.0), 2) or pow(2.0, f64::from(2))?
3 }
In this case, the call is ambiguous because 2.0 is the best match for the first argument of pow(f64,
f64) and 2 is the best match for the second argument of pow(i32, i32) .
1 fn f1(ch: char) {
2 // implementation for char
3 }
4
5 fn f1(num: i64) {
6 // implementation for i64
7 }
8
9 fn f2(ptr: &mut char) {
10 // implementation for char pointer
11 }
12
13 fn f2(ptr: &mut i32) {
14 // implementation for int pointer
15 }
16
17 fn k(i: i32) {
18 f1(i); // ambiguous: f1(char) or f1(i64)?
19 f2(0 as *mut i32); // ambiguous: f2(&mut char) or f2(&mut i32)?
20 }
To avoid these issues, consider the entire set of overloaded functions to ensure they make sense
together. Often, adding a specific version can resolve ambiguities. For example, adding:
1 fn f1(n: i32) {
2 f1(n as i64);
3 }
This resolves ambiguities like f1(i) in favor of the larger type i64 .
Explicit type conversion can also address specific calls:
However, this approach is often just a temporary fix and doesn't solve the core issue. Similar calls
might appear and need to be handled.
While beginners might find ambiguity errors frustrating, experienced programmers see these errors as
helpful indicators of potential design flaws.
Documenting preconditions and postconditions like this is beneficial. It helps the function’s
implementer, users, and testers understand the function's requirements and guarantees. For instance,
values like 0 and -12 are invalid arguments. Moreover, if very large values are passed, the result might
overflow, violating the postconditions.
Consider calling area(i32::MAX, 2) :
Should the caller avoid such calls? Ideally, yes, but mistakes happen.
Should the implementer handle these cases? If so, how should errors be managed?
Various approaches exist. It’s easy for callers to overlook preconditions, and it’s challenging for
implementers to check all preconditions efficiently. While reliance on the caller is preferred, a
mechanism to ensure correctness is necessary. Some pre- and postconditions are easy to verify (e.g.,
len is positive), while others, like verifying "the return value is the area of a rectangle with sides len
and wid ," are more complex and semantic.
Writing out pre- and postconditions can reveal subtle issues in a function. This practice not only aids in
design and documentation but also helps in identifying potential problems early.
For functions that depend solely on their arguments, preconditions apply only to those arguments.
However, for functions relying on non-local values (e.g., member functions dependent on an object's
state), these values must be considered as implicit arguments. Similarly, postconditions for side-
effect-free functions ensure the value is correctly computed. If a function modifies non-local objects,
these effects must also be documented.
Function developers have several options:
Ensure every input results in a valid output, eliminating preconditions.
Assume the caller ensures preconditions are met.
Check preconditions and throw an exception if they fail.
Check preconditions and terminate the program if they fail.
If a postcondition fails, it indicates either an unchecked precondition or a programming error.
In this example, efct is a function pointer that points to the error function and can be used to call
error .
Function pointers must match the complete function type, including argument types and return type,
exactly. Consider the following example:
1 fn f1(s: String) {}
2 fn f2(s: String) -> i32 { 0 }
3 fn f3(p: &i32) {}
4
5 let mut pf: fn(String);
6
7 pf = f1; // OK
8 pf = f2; // error: mismatched return type
9 pf = f3; // error: mismatched argument type
Casting function pointers to different types is possible but should be done with caution as it can lead
to undefined behavior. For instance:
This example highlights the risks of casting function pointers and underscores the importance of
careful type management.
Function pointers are useful for parameterizing algorithms, especially when the algorithm needs to be
provided with different operations. For example, a sorting function might accept a comparison function
as an argument.
To sort a collection of User structs, you could define comparison functions and pass them to the sort
function:
1 struct User {
2 name: &'static str,
3 id: &'static str,
4 dept: i32,
5 }
6
7 fn cmp_name(a: &User, b: &User) -> std::cmp::Ordering {
8 a.name.cmp(b.name)
9 }
10
11 fn cmp_dept(a: &User, b: &User) -> std::cmp::Ordering {
12 a.dept.cmp(&b.dept)
13 }
14
15 fn main() {
16 let mut users = vec![
17 User { name: "Ritchie D.M.", id: "dmr", dept: 11271 },
18 User { name: "Sethi R.", id: "ravi", dept: 11272 },
19 User { name: "Szymanski T.G.", id: "tgs", dept: 11273 },
20 User { name: "Schryer N.L.", id: "nls", dept: 11274 },
21 User { name: "Schryer N.L.", id: "nls", dept: 11275 },
22 User { name: "Kernighan B.W.", id: "bwk", dept: 11276 },
23 ];
24
25 users.sort_by(cmp_name);
26 println!("Sorted by name:");
27 for user in &users {
28 println!("{} {} {}", user.name, user.id, user.dept);
29 }
30
31 users.sort_by(cmp_dept);
32 println!("\nSorted by department:");
33 for user in &users {
34 println!("{} {} {}", user.name, user.id, user.dept);
35 }
36 }
In this example, the vector of User structs is sorted first by name and then by department using
function pointers.
Pointers to functions provide a flexible way to parameterize algorithms and manage different
operations in a type-safe manner. However, it's crucial to ensure type compatibility to avoid errors and
undefined behavior.
14.25. Macros
Macros play a crucial role in C but are less prevalent in more modern languages like Rust. The key
guideline for using macros is to avoid them unless absolutely necessary. Most macros indicate a
limitation in the programming language, the program, or the programmer. Since macros manipulate
code before the compiler processes it, they can complicate tools like debuggers, cross-referencing,
and profilers. When macros are essential, it's important to read the reference manual for your
implementation of the Rust preprocessor and avoid overly clever solutions. Conventionally, macros are
named with capital letters to signal their presence.
In Rust, macros should primarily be used for conditional compilation and include guards. Here’s an
example of a simple macro definition:
1 macro_rules! NAME {
2 () => {
3 rest_of_line
4 };
5 }
1 macro_rules! MAC {
2 ($x:expr, $y:expr) => {
3 println!("argument1: {}, argument2: {}", $x, $y);
4 };
5 }
Macro names cannot be overloaded, and the macro preprocessor cannot handle recursive calls:
1 macro_rules! PRINT {
2 ($a:expr, $b:expr) => {
3 println!("{} {}", $a, $b);
4 };
5 ($a:expr, $b:expr, $c:expr) => {
6 println!("{} {} {}", $a, $b, $c);
7 };
8 }
9
10 macro_rules! FAC {
11 ($n:expr) => {
12 if $n > 1 {
13 $n * FAC!($n - 1)
14 } else {
15 1
16 }
17 };
18 }
Macros manipulate token streams and have a limited understanding of Rust syntax and types. Errors in
macros are detected when expanded, not when defined, leading to obscure error messages. Here are
some plausible macros:
1 macro_rules! CASE {
2 () => {
3 break; case
4 };
5 }
6 macro_rules! FOREVER {
7 () => {
8 loop {}
9 };
10 }
1 macro_rules! PI {
2 () => {
3 3.141593
4 };
5 }
6 macro_rules! BEGIN {
7 () => {
8 {
9 };
10 }
11 macro_rules! END {
12 () => {
13 }
14 };
15 }
1 macro_rules! SQUARE {
2 ($a:expr) => {
3 $a * $a
4 };
5 }
6 macro_rules! INCR {
7 ($xx:expr) => {
8 $xx += 1
9 };
10 }
1 macro_rules! MIN {
2 ($a:expr, $b:expr) => {
3 if $a < $b {
4 $a
5 } else {
6 $b
7 }
8 };
9 }
1 let mut x = 1;
2 let mut y = 10;
3 let z = MIN!(x += 1, y += 1); // x becomes 3; y becomes 11
When defining macros, it is often necessary to create new names. A string can be created by
concatenating two strings using the concat_idents! macro:
1 macro_rules! NAME2 {
2 ($a:ident, $b:ident) => {
3 concat_idents!($a, $b)
4 };
5 }
1 macro_rules! printx {
2 ($x:ident) => {
3 println!("{} = {}", stringify!($x), $x);
4 };
5 }
6 let a = 7;
7 let str = "asdf";
8 fn f() {
9 printx!(a); // prints "a = 7"
10 printx!(str); // prints "str = asdf"
11 }
1 #[macro_export]
2 macro_rules! EMPTY {
3 () => {
4 println!("empty");
5 };
6 }
7 EMPTY!(); // prints "empty"
8 EMPTY; // error: macro replacement list missing
An empty macro argument list is often error-prone or malicious. Macros can even be variadic:
1 macro_rules! err_print {
2 ($($arg:tt)*) => {
3 eprintln!("error: {}", format_args!($($arg)*));
4 };
5 }
6 err_print!("The answer is {}", 42); // prints "error: The answer is 42"
In summary, while macros can be powerful, their use in Rust should be minimal and well-considered,
favoring more robust and clear alternatives whenever possible.
1 fn f(a: i32
2 #ifdef ARG_TWO
3 , b: i32
4 #endif
5 ) -> i32 {
6 // function body
7 }
This approach can confuse tools that rely on consistent programming practices.
While most uses of #ifdef are more straightforward, they must be used judiciously. The #ifdef and
its complement #ifndef can be harmless if applied with restraint. Macros for conditional compilation
should be carefully named to avoid conflicts with regular identifiers. For instance:
1 struct CallInfo {
2 arg_one: Node,
3 arg_two: Node,
4 // ...
5 }
#define ARG_TWO x
Unfortunately, many essential headers include numerous risky and unnecessary macros.
A more robust approach to conditional compilation involves using attributes like #[cfg] and #
[cfg_attr] , which integrate into the language more seamlessly and avoid the pitfalls of macros. This
method ensures cleaner and safer code management.
line!() : Expands to the current line number within the source file.
column!() : Expands to the current column number within the source file.
These macros are useful for providing contextual information for debugging and logging. For instance,
the following line of code prints the current line number and file name:
Conditional compilation is handled using the #[cfg] attribute, enabling or disabling code based on
specific configuration options:
1 #[cfg(debug_assertions)]
2 fn main() {
3 println!("Debug mode is enabled");
4 }
5
6 #[cfg(not(debug_assertions))]
7 fn main() {
8 println!("Release mode is enabled");
9 }
Additionally, custom configuration options can be defined using the --cfg flag in rustc or within
Cargo.toml . By using the cfg! macro, you can conditionally execute code blocks based on the
target operating system or other compile-time conditions:
1 if cfg!(target_os = "windows") {
2 println!("Running on Windows");
3 } else {
4 println!("Running on a non-Windows OS");
5 }
These predefined macros and conditional compilation features enhance flexibility and robustness,
making it easier to manage code based on the compilation environment.
14.28. Pragmas
Platform-specific and non-standard features can be managed using attributes, which are similar to
pragmas in other languages. Attributes provide a standardized way to apply configuration options,
hints, or compiler directives to the code. For instance:
#![feature(custom_attribute)]
Attributes can be applied at various levels, such as modules, functions, and items. While it is generally
best to avoid using non-standard features if possible, attributes provide a powerful tool for enabling
specific functionality. Here are a few examples of attributes:
#[allow(dead_code)] : Suppresses warnings for unused code.
14.29. Advices
In Rust, organizing your code into well-defined, clearly named functions is crucial for enhancing
readability and maintainability. Functions serve as a means to break down complex tasks into smaller,
more manageable pieces, each focusing on a single, coherent task. This modular approach not only
makes the code easier to understand but also simplifies its maintenance.
When declaring functions, you specify the function’s name, parameters, and return type. This
declaration acts as a blueprint, providing an overview of what the function does and what it requires. In
Rust, functions should be designed to handle specific tasks and remain succinct. Keeping functions
short helps maintain clarity, making them easier to understand and debug.
Function definitions in Rust provide the actual implementation of the function. It is essential that these
definitions are straightforward and efficient, aligning with the function's declared purpose. Avoid
returning pointers or references to local variables. Instead, Rust’s ownership and borrowing rules
ensure that returned values are either owned or have appropriately scoped references, preventing
issues like dangling references.
For functions that require compile-time evaluation, you should use const fn . This feature allows
functions to be evaluated during compilation, which can enhance performance by reducing runtime
computations. If a function is designed to never return normally, such as one that loops indefinitely or
exits the process, you should use Rust’s ! type to denote that the function does not return.
When passing arguments to functions, it is advisable to pass small objects by value for efficiency,
while larger objects should be passed by reference. Rust’s ownership system ensures that references
are used safely, adhering to borrowing rules to avoid mutable aliasing and data races. For complex
types, using &T or &mut T allows you to manage immutability and mutation effectively.
Design functions to return results directly rather than modifying objects through parameters. This
approach aligns with Rust’s ownership and borrowing features, allowing for safe and clear
management of data through move semantics and references. Avoid using raw pointers where feasible
and rely on Rust’s type system to ensure safe and efficient data handling.
In Rust, macros can be powerful but should be used sparingly. Functions, traits, and closures are
generally preferred for most tasks due to their clarity and maintainability. When macros are necessary,
use unique and descriptive names to reduce potential confusion and maintain transparency in your
code.
For functions involving complex types or multiple arguments, use slices or vectors rather than variadic
arguments. This approach improves type safety and clarity. Rust does not support traditional function
overloading, so to handle similar functionalities with varying inputs, consider using descriptive function
names or enums. Similarly, document preconditions and postconditions clearly to enhance the
correctness and readability of your functions.
By adhering to these practices, Rust programmers can create well-structured and efficient code.
Leveraging Rust’s features for ownership, borrowing, and compile-time evaluation ensures that code
remains safe, maintainable, and performant.
navigate_beforeSelect
Chapter 13
Operations
Chapter 15
Exception Handling
navigate_next