Less is more, especially in coding. Photo Prateek Katyal from Unsplash.com
Reading time: 12 min
In this concise post, we will explore how the strategic implementation of function overloading and templates can effectively minimize the complexity of code. However, it’s crucial to note that while these techniques offer significant benefits, excessive reliance on them may introduce a different form of complexity that needs to be carefully managed.
Disclaimer: I used the lossly-compressed-wisdom-of-the-crowds ChatGPT to write these examples. Afterwards, I cleaned it up and asked peers for proofread until satisfaction.
Different versions of the same program “Add”
The example we are looking at is an “Add” operator with a quirk : If a
and b
are numbers, add(a,b)
returns the addition to a
(2+3=5). But, to spice it up, if a
and b
are strings, it returns the concatenation with a "&"
inserted between (add(“laurel”,”hardy”)=”laurel&hardy”).
Fortran and C++, explicit version
Here follows the Fortran implementation using a very explicit redirection using logical if-then-else
statements.
module AddModule
implicit none
interface add
module procedure addIntegers, addReals, addStrings
end interface add
contains
subroutine addIntegers(a, b, c)
implicit none
integer, intent(in) :: a, b
integer, intent(out) :: c
c = a + b
end subroutine addIntegers
subroutine addReals(a, b, c)
implicit none
real, intent(in) :: a, b
real, intent(out) :: c
c = a + b
end subroutine addReals
subroutine addStrings(a, b, c)
implicit none
character(len=*), intent(in) :: a
character(len=*), intent(in) :: b
character(len=len(a)+len(b)+1), intent(out) :: c
c = trim(a) // "&" // trim(b)
end subroutine addStrings
end module AddModule
program AddOperatorExample
use AddModule
implicit none
! Declare variables
integer :: intA, intB, intC
real :: realA, realB, realC
character(len=11) :: charA, charB
character(len=100) :: charC
charC=" " ! avoid random initialization at the end of output string
! Perform operations based on types
intA = 5
intB = 3
if (kind(intA) == kind(0) .and. kind(intB) == kind(0)) then
call addIntegers(intA, intB, intC)
else
print *, "Error: Incompatible types for addition/concatenation"
endif
realA = 2.5
realB = 1.8
if (kind(realA) == kind(0.0) .and. kind(realB) == kind(0.0)) then
call addReals(realA, realB, realC)
else
print *, "Error: Incompatible types for addition/concatenation"
endif
charA = 'Hello, '
charB = 'World! '
if (kind(charA) == kind('') .and. kind(charB) == kind('')) then
call addStrings(charA, charB, charC)
else
print *, "Error: Incompatible types for addition/concatenation"
endif
! Display results
print *, "Integer addition: ", intC
print *, "Real addition: ", realC
print *,"String concatenation: ", trim(charC)
end program AddOperatorExample
The C++ version is the following, with the same if
statements.
#include <stdio.h>
#include <string.h>
void addIntegers(int a, int b, int* c) {
*c = a + b;
}
void addReals(float a, float b, float* c) {
*c = a + b;
}
void addStrings(const char* a, const char* b, char* c) {
strcpy(c, a);
strcat(c, "&");
strcat(c, b);
}
int main() {
int intA, intB, intC;
float realA, realB, realC;
char charA[12], charB[12];
char charC[101];
// Perform operations based on types
intA = 5;
intB = 3;
if (sizeof(int) == sizeof(intA) && sizeof(int) == sizeof(intB)) {
addIntegers(intA, intB, &intC);
} else {
printf("Error: Incompatible types for addition/concatenation\n");
return 1;
}
realA = 2.5;
realB = 1.8;
if (sizeof(float) == sizeof(realA) && sizeof(float) == sizeof(realB)) {
addReals(realA, realB, &realC);
} else {
printf("Error: Incompatible types for addition/concatenation\n");
return 1;
}
strcpy(charA, "Hello, ");
strcpy(charB, "World! ");
if (sizeof(char) == sizeof(charA[0]) && sizeof(char) == sizeof(charB[0])) {
addStrings(charA, charB, charC);
} else {
printf("Error: Incompatible types for addition/concatenation\n");
return 1;
}
// Display results
printf("Integer addition: %d\n", intC);
printf("Real addition: %f\n", realC);
printf("String concatenation: %s\n", charC);
return 0;
}
Both version includes an explicit busines logic inside the main program calling our functionality add
, with a prior knowledge of the functions to redirect.
Extending the functionality to other situations (e.g. “adding” booleans) require a change in the caller program, because a new element of the API must be explicitly used.
Let’s see what can be done to reduce the size and complexity of the code.
Fortran and C++ with function overloading
The principle of function overloading is a language feature that allows multiple functions with the same name to be defined, but with different parameter lists or types. The specific function to be executed is determined by the arguments passed to the function when it is called.
The key points of function overloading are:
- Same Name: Functions with different functionalities can have the same name.
- Different Parameters: Overloaded functions have different parameter lists or types, which can include differences in the number of parameters, parameter types, or parameter order.
- Compiler Resolution: The compiler determines the most appropriate function to execute based on the arguments provided during the function call. It matches the arguments to the function parameters by considering the types, constness, and other qualifiers.
- Compile-time Polymorphism: The selection of the appropriate function is resolved at compile-time, based on the types of the arguments used during the function call.
- Increased Flexibility: Function overloading allows for code reuse and provides a way to define more intuitive and expressive function names based on the specific context or usage.
By allowing multiple functions with the same name but different parameter lists or types, function overloading enables programmers to write code that is more concise, readable, and flexible, as well as providing a more natural way to work with different data types or handle different scenarios within the same function name.
Here is the fortran version of function overloading:
module AddModule
implicit none
interface add
module procedure addIntegers, addReals, addStrings
end interface add
contains
subroutine addIntegers(a, b, c)
implicit none
integer, intent(in) :: a, b
integer, intent(out) :: c
c = a + b
end subroutine addIntegers
subroutine addReals(a, b, c)
implicit none
real, intent(in) :: a, b
real, intent(out) :: c
c = a + b
end subroutine addReals
subroutine addStrings(a, b, c)
implicit none
character(len=*), intent(in) :: a
character(len=*), intent(in) :: b
character(len=len(a)+len(b)+1), intent(out) :: c
c = trim(a) // "&" // trim(b)
end subroutine addStrings
end module AddModule
program AddOperatorExample
use AddModule
implicit none
! Declare variables
integer :: intA, intB, intC
real :: realA, realB, realC
character(len=11) :: charA, charB
character(len=100) :: charC
charC = ' ' ! avoid random initialization at the end of output string
! Perform operations based on types
intA = 5
intB = 3
call add(intA, intB, intC)
realA = 2.5
realB = 1.8
call add(realA, realB, realC)
charA = 'Hello, '
charB = 'World! '
call add(charA, charB, charC)
! Display results
print *, "Integer addition: ", intC
print *, "Real addition: ", realC
print *, "String concatenation: ", trim(charC)
end program AddOperatorExample
Now, For the C++ version with function overloading:
#include <iostream>
#include <string>
// Function overloading for adding integers
int add(int a, int b) {
return a + b;
}
// Function overloading for adding floats
float add(float a, float b) {
return a + b;
}
// Function overloading for concatenating strings
std::string add(const std::string& a, const std::string& b) {
return a + "&" + b;
}
int main() {
// Declare variables
int intA = 5, intB = 3;
float realA = 2.5, realB = 1.8;
std::string charA = "Hello, ", charB = "World!";
// Perform operations based on types
int intC = add(intA, intB);
float realC = add(realA, realB);
std::string charC = add(charA, charB);
// Display results
std::cout << "Integer addition: " << intC << std::endl;
std::cout << "Real addition: " << realC << std::endl;
std::cout << "String concatenation: " << charC << std::endl;
return 0;
}
In both cases, the program defines multiple functions named add with different parameter types. The specific function to be called is determined by the argument types passed during the function call, thanks to function overloading. This allows the program to handle different data types and perform the appropriate addition or concatenation operations based on the provided arguments.
Extending the functionality to other situations (e.g. “adding” booleans) require NO change in the caller programs. The different functionalities are “hidden” behind the same API element ‘add()’
Of course, the program lost a bit of tracability: multiple contexts have now the same name. This can make debugging a bit harder. Moreover, newcomers can have a harder time to figure out where they must work on.
Using function overloading in programming carries risks related to technical debt, including:
- Ambiguity: Overloaded functions can lead to confusion and unexpected behavior.
- Complexity: Managing a large number of overloaded functions increases code complexity.
- Maintenance Burden: Inadequate documentation and naming conventions can cause confusion and hinder modifications.
- Coupling and Dependency: Excessive function overloading can create tight coupling and make code harder to maintain.
To mitigate these risks, use clear naming, thorough documentation, and regular code reviews.
We can go a bit further with templates:
C++ version using a template
Advantages of using template metaprogramming over function overloading are the following:
- Code Reusability: Templates allow you to write generic code that can be used with multiple types without the need to define separate functions for each type. This promotes code reusability and reduces code duplication.
- Flexibility: Templates provide greater flexibility in handling different types. With function overloading, you need to explicitly define each function for each specific type, whereas templates automatically generate the appropriate code based on the type used during the function call.
- Improved Readability: Templates allow you to write more concise and readable code since you don’t need to define multiple functions with similar functionality but different types. The code is more compact and easier to understand.
- Compile-time Polymorphism: Templates support compile-time polymorphism, meaning that the selection of the appropriate function implementation is resolved at compile-time based on the types used during the function call. This can lead to better performance compared to run-time dispatching used in function overloading.
- Type Safety: Templates enforce type safety by performing compile-time type checking. The compiler verifies that the types used with the template are compatible, which helps catch type-related errors early in the development process.
- Generic Algorithms: Templates are especially useful when writing generic algorithms that need to work with various data types. Templates enable you to create algorithms that operate on generic types without sacrificing performance or type safety.
Overall, templates provide a more flexible and powerful mechanism for writing generic code that can handle multiple types, leading to improved code reuse, readability, and maintainability. Templates are particularly valuable in scenarios where you need to write generic algorithms or work with different types in a generic and efficient manner.
Here is a version of the program with C++ templates:
#include <iostream>
#include <string>
// Template function for adding two values
template <typename T>
T add(T a, T b) {
return a + b;
}
// Specialization for concatenating strings
template <>
std::string add<std::string>(std::string a, std::string b) {
return a + "&" + b;
}
int main() {
// Declare variables
int intA = 5, intB = 3;
float realA = 2.5, realB = 1.8;
std::string charA = "Hello, ", charB = "World!";
// Perform operations based on types
int intC = add(intA, intB);
float realC = add(realA, realB);
std::string charC = add(charA, charB);
// Display results
std::cout << "Integer addition: " << intC << std::endl;
std::cout << "Real addition: " << realC << std::endl;
std::cout << "String concatenation: " << charC << std::endl;
return 0;
}
In this case the exception for strings is easier to spot. It is a “special case”, very close to the human way of thinking.
However we lost some readability. For instance, it became impossible to read the list of types supported by the template. This implicitation can become a burden in the long term for newcomers, or if a part of the code is left unread for a long time.
When working with templates in programming, there are several potential dangers in terms of technical debt:
- Code Duplication: Templated code can lead to code duplication if similar functionality needs to be implemented for different types or scenarios. This duplication can make the codebase harder to maintain and increase the likelihood of introducing bugs.
- Longer Compilation Times: Templates can significantly increase compilation times, especially when used extensively or with complex template hierarchies. This can slow down the development process and hinder iteration speed.
- Debugging Complexity: Debugging templated code can be more challenging compared to non-templated code. Template errors and issues may manifest themselves in complex error messages that are difficult to interpret and trace back to the original source of the problem.
- Limited Readability: Templated code can be difficult to read and understand, particularly for developers unfamiliar with the template implementation.
- Maintenance Complexity: Templates can introduce complexity in maintaining and modifying code, requiring specialized knowledge and careful handling.
- Dependency Issues: Templated code can have dependencies that are not immediately apparent, making it harder to manage and update.
- Tooling and Library Limitations: Some tools, IDEs, and libraries may have limitations or difficulties handling templated code correctly. This can impact the development experience and may require workarounds or custom solutions.
- Steep Learning Curve: Templates can have a steep learning curve, especially for developers who are new to template metaprogramming or who have limited experience with advanced template techniques. Understanding and effectively using templates may require additional time and effort.
To mitigate these risks, it is important to use templates judiciously, maintain clear documentation, and conduct thorough testing and code reviews. Additionally, providing adequate training and knowledge sharing among team members can help alleviate some of the challenges associated with templated programming.
**Side note: There are also templates support options in Fortran such as Fortran Templates Library, but neither me not GPT3 are proficient enough to provide the code snippet.
And what about Julia’s Multiple dispatch?
Julia is a high-level, dynamic programming language designed for numerical and scientific computing. It combines the ease and expressiveness of dynamic languages like Python with the performance of traditional statically-typed languages like Fortran and C++. Julia excels in providing a user-friendly environment for rapid prototyping, while also delivering high-performance computations through its just-in-time (JIT) compilation and sophisticated type system.
Julia is currently less used than Fortran or C++ in high-performance computing (HPC) due to factors such as the presence of legacy codebases in Fortran and C++, a well-established ecosystem and specialized libraries for HPC in those languages, the fine-grained control over performance optimizations offered by Fortran and C++, and the strong adoption of Fortran and C++ within the HPC community.
Multiple dispatch is a key feature of the Julia programming language that allows functions to be specialized based on the types of multiple arguments. In Julia, a function can have multiple definitions, each corresponding to a different combination of argument types. When the function is called, the appropriate specialized version is dynamically dispatched based on the actual types of the arguments
Here is a solution to the same problem in Julia using multiple dispatch
# Function for adding two values
function add(a::T, b::T) where {T}
return a + b
end
# Specialization for concatenating strings
function add(a::String, b::String)
return a * "&" * b
end
# Declare variables
intA = 5
intB = 3
realA = 2.5
realB = 1.8
charA = "Hello, "
charB = "World!"
# Perform operations based on types
intC = add(intA, intB)
realC = add(realA, realB)
charC = add(charA, charB)
# Display results
println("Integer addition: ", intC)
println("Real addition: ", realC)
println("String concatenation: ", charC)
Multiple dispatch in Julia dynamically selects functions based on the types of all arguments, allowing for dynamic polymorphism and multidimensional dispatch. In contrast, function overloading in Fortran and templates in C++ rely on static dispatch and static code generation, respectively, resulting in static polymorphism and limited dispatch flexibility. Julia’s multiple dispatch offers dynamic and extensible dispatch mechanisms, while Fortran’s function overloading and C++ templates provide static polymorphism and static code generation, respectively.
Comparing the Different versions
Let’s now compare the three different versions in terms of :
- lines of code, which is the amount of text to process when reading the code
- cyclomatic complexity, i.e. the number of decision plus one in the case of consecutive if-then-else statements
- indentation complexity, which is the sum of all indentations, in other words the amount of lines put into a case-specific context (the deeped the context the higher the score)
language | version | LOC | cycl. compl. | Indent. compl. |
---|---|---|---|---|
Fortran | explicit | 58 | 4 | 77 |
C++ | explicit | 47 | 4 | 48 |
Fortran | func. overload. | 56 | 1 | 59 |
C++ | func. overload. | 23 | 1 | 16 |
C++ | template | 22 | 1 | 15 |
We clearly see the improvement of the code using function overloading , then a slight improvement with templates. As fortran is usually separating variable declarations and assignation on separate lines, figures are higher than C.(func. overload.) but this is due essentially to the coding habits in fortran community.
Takeaways
The use of function overloading, which is supported in both Fortran and C++, has the potential to reduce the complexity of your interfaces. Taking a step further, transitioning to templates offers even more benefits by enabling the compiler to perform implicit actions at a lower runtime cost.
However, it’s important to be aware that relying on implicit behaviors instead of explicit ones comes with its own drawbacks. Just like in object-oriented programming, adopting a new template approach should be confined to a specific, well-defined context with a single responsibility, and should be strongly justified.
Here’s a helpful tip: Before introducing a new intelligent component into your code, such as an object or a template, consider seeking the support of a “champion.” This individual, who is not you, can provide an endorsement statement in the comments or docstring below your own description, explaining why the new element is beneficial and necessary.