Steve Krug published the famous book "Don't Make Me Think" in 2000 and gathered well-known ideas about Web usability. Authors of programming languages often lower the priority of simplicity or have to implement far-from-ideal solutions due to historical restrictions.
How to measure complexity?
Maintaining a product requires more reading code than writing. Hence, the code legibility is more important than its writing speed.
One way to measure code complexity is to count how many terms a developer should hold in memory to get a result.
Another one is how much time it requires to understand the notion or to revise it.
Data structures and operations
In short, programming is all about operations on data. To allocate memory for a 32-bit integer in statically typed languages, we can declare its type and assign a name to the memory block.
// C, C++, Java, C#
int x;
// Ada
X : Integer;
// TypeScript
let x: number;
// Rust
let x: i32;
// Go
var x int32
There is a way to infer a type by a compiler or even calculate the memory size and data structure at runtime. This approach is used in JavaScript, Python, and Ruby.
// JavaScript
let x;
// Python
x
The untyped version requires additional time for a developer to figure out the argument structures. Consider the function declared in Python:
def display(person, age, payment)
Is person
an object, is age
a number, is payment
represented by a boolean, number, or money?
Compare the same function written in C# that immediately gives answers about the structures:
void Display(string person, int age, decimal payment)
A statically typed language usage also has a vital benefit over a dynamic language - more error checks at compilation time. It is better to find errors at the earliest stage than make them to be discovered by users.
Assignment operator
Most modern programming languages implement the assignment operator as the equal =
sign.
int x;
x = 1;
A fresh programming student trying to understand these statements may ask: "Is it possible to write y = a*x^2 + b*x +c
in code similar to the Math notation?" Although students have been taught for many years that =
sign represents equation in Math, the answer is "no" for most of the programming languages.
In C, C++, Java, C#, PHP, Python, Ruby, Go, and Rust, the equal sign =
means an assignment. Pascal and Ada languages use the :=
sign, which at least is not a common Math symbol in schools (in Math, it is used as the equal by definition sign, in addition to âĄ
).
But what does the assignment actually mean? The assignment operator in modern languages has two ideas behind it.
Value assignment
The first one is called value assignment
and usually uses deep copy
of memory blocks.
// C, C++, Java, C#
int x = 1;
int y = x; // Deep copy of x to y
x = 2; // Change x
y; // y has the old value 1
Reference assignment
The second one is reference assignment
, which always uses the shared block of memory between variables.
// C#
int[] array1 = { 1, 2, 3 }; // Array of 3 integers
int[] array2 = array1; // Reference assignment
array1[0] = 9; // Change the first element in array1
array1; // { 9, 2, 3 }
array2; // { 9, 2, 3 }
Changes in array1
immediately affects array2
as both variables point to the same memory location.
Java and C# oblige developers to remember what assignment behavior is associated with a structure. Moreover, C++ and C# allow to override the =
operator per object, and Java can achieve the same result by overriding equals
method. It is only possible to be sure about the result of the equality statement in these languages once the type implementation is reviewed.
As we just saw, C# uses a memory reference approach for array assignment to reduce memory allocation. Go language, instead, uses deep copy for arrays.
// Go
var array1 [3]int // Array of length 3
array1 = [3]int{ 1, 2, 3 } // Fill the array with numbers
var array2 [3]int // Array of length 3
array2 = array1 // Deep copy from array1 to array2
array1[0] = 9 // Change the first element in array1
array1; // { 9, 2, 3 }
array2; // { 1, 2, 3 }
A tiny declaration change by removing array size in brackets (var array1 []int
and var array2 []int
) leads to array slicing
in Go. The result will be the opposite and the same as with C# reference array assignment.
Go language forces programmers to pay attention to a number in square brackets.
Ruby authors consider all data structures as objects. Even primitive types, like integers, are objects, so everything is linked via a memory reference by default. The result for arrays will be similar to C# and Go slices. Some might expect the same behavior for Ruby integers, but instead, it will be a usual deep copy:
// Ruby
a = 1
b = a # Deep copy instead of referencing
a = 3
puts a # Holds 3
puts b # Holds the original value 1
Passing an argument
Language authors made three options to path an argument to a function.
The first one is a pass by reference
so that you can reassign a new value to the outer argument inside the function.
The second is called pass by value
, and there are two options. One is to copy a reference to the memory address, you will not be able to reassign the value for an outer argument but will be able to modify the internals of the shared object. Another one is to make a deep copy of an argument so the changes inside the function will not affect the changes of a passed argument.
Passing an argument does not fully correlate with the variable assignment =
behavior. Languages presented new symbols to differentiate the operation: ref
keyword. &
and *
signs. Many programmers find the latest two very confusing, for example:
if(*(struct foo**)deque_get(deq, i) != NULL &&
(*(struct foo**)deque_get(deq, i))->foo >= 1) {
}
Even for experienced engineers, it isn't easy to read.
One may argue that large statements with many terms are relevant only to old languages, like C. Consider the number of terms for a property declaration in modern C#:
[ReadOnly(true)]
internal required override string Prop { get; init; }
Method calls
Modern frameworks widely use function arguments. Here is a common approach to start a Web server in Go:
http.ListenAndServe(":3000", nil)
By reading this code you may guess that the first argument is a port number, but what about the second? Only after inspecting the function you can find out that it is a special controller, and if you pass nil
a default one will be used.
A common JavaScript example:
let obj = {a: { b: { c: 1 }}}
JSON.stringify(obj, null, 2)
Can a programmer know what null
represents there and 2
without looking at the specification? (Answer: null is passed as an empty replacer, 2 is indentation size)
You might create a function similar to the next one, which also raises questions about the arguments:
func("John", true, null, 1)
Some IDEs, like JetBrains family, immediately show the names of the arguments, but programmers like to use other IDEs as well.
Certain languages allow to rewrite the code with named arguments and even rearrange them:
func(id: 1, name:"John", approved: true, email: null)
That approach is optional and many developers do not use it. The better solution for readability would be to oblige structural usage in all function calls, for example:
func({
id: 1,
name: "John",
approved: true,
email: null
})
File search and jumps
Modern software development and modern frameworks compel to create complex project structures.
Try to count how many times you need to jump between files to understand the logic implementation. Are those files far away from each other and in different packages? Do you need to jump to a file to figure out some data structure, or can it be read immediately? Is it possible to declare types in the same file at the top location to simplify the reading?
If you do not write much code it is possible to view live programming streams and check how many context switches happens during file jumps.
Complexity
I can provide many more examples, like breaking the reading flow in Go with =
and :=
operators, hidden importance of =
sign and hashCode
overrides in Java and C#, JavaScript call
, apply
and bind
function calls, etc. The idea stays the same - how many terms a person needs to find, hold in memory to get the result, and how much time it requires to revise a term.
Conclusion
Popular programming languages made or applied revolutionary changes at some time. C language authors drastically simplified code writing and application support in comparison to programming in assembler. The default compilation result in C is still called a.out
or assembly output
. Java simplified working with memory via garbage collection. C# simplified Java statements and introduced Language Integrated Query. Go appeared as a simple alternative to C++ for network and console applications. Rust raised the bar of application stability and security. JavaScript, Python, and Ruby have low learning barriers.
Language authors are restricted by previous versions and styles. Nevertheless, it will be great to see new language versions or new programming languages, reducing reading complexity as a major factor.
Top comments (0)