User Stories Language Is Not Only a Tool but Also a Way of Thinking

User Story: Language is Not Only a Tool, But Also a Way of Thinking #

Hello, I am Pedro, a very ordinary worker, and a modest coder.

You might have seen me in the course comment section, and perhaps even discussed problems with me. Today, taking the opportunity of sharing this user story, I can chat a bit more with you.

I’ve simply organized some of my thoughts since falling into the programming world, mainly discussing thoughts, language, and tools. In the end, I will also share my views on Rust. Of course, the following views are very “subjective”; what matters is not the opinion itself but the process of obtaining it.

Starting with Thought #

From the moment we engage with programming, we begin dealing with programming languages, as many people’s approach to learning programming is essentially the process of becoming familiar with a programming language.

In this process, many people feel out of their element—their code often fails to run, let alone the tasks of design and abstraction. The fundamental reason for this phenomenon is that the code reflects computational thinking, and the difference between human thinking and computational thinking is substantial, and many initially cannot accept the major impact that the discrepancy in thinking brings.

So, what exactly is computational thinking?

Computational thinking is all-encompassing, reflected in every aspect. Let me simply summarize it from a personal perspective:

  • Top-Down Approach: The essence of computational thinking is a top-down approach, while the human brain is more accustomed to a bottom-up approach. Computers decompose large, complex problems into smaller ones through top-down thinking, solving each small problem one by one, thereby eventually resolving the large issue.
  • Multidimensional, Multitasking: The human brain is linear, tends to view problems one dimensionally, and we struggle to deal with and think about multiple issues simultaneously. Computers are different; they can have multiple CPU cores and, while retaining context, can concurrently run hundreds to thousands of tasks.
  • Global Perspective: Human attention and brain capacity are limited, whereas a computer’s capacity is nearly infinite. When humans think about problems, they are limited by their locality and start acting on local solutions. In contrast, computers can make decisions based on vast amounts of data leading to nearly optimal solutions.
  • Cooperation: Computers themselves are pieces of extremely fine-engineered art. They are complex and delicate, with each part specializing in what it does best—like separating calculation and storage functions. The efficient functioning of a computer is a result of each part’s coordination. Humans are better at individualistic tasks, and only through extensive training can they harness the power of the group.
  • Fast Iteration: Human evolution and growth are slow processes; even today, many people’s ways of thinking are still stuck in the last century. Computers, however, have followed Moore’s Law since entering the information age, doubling their capabilities every 18 months. A decade-old cell phone may even struggle to run WeChat properly today.
  • Compromise: Throughout lengthy social evolution, humans have overly emphasized right and wrong and pursued absolute fairness. Ironically, computers, which are composed of binaries, do not make black-and-white decisions. Whether it’s hardware itself or the software running on it, every part is a result of balancing performance, cost, usability, and other factors.
  • And so on…

When these modes of thought are directly reflected in code, for instance, top-down thinking is manifested in programming languages through recursion, divide and conquer; multidimensionality and multitasking are embodied in branches, jumps, and contexts; and iteration, cooperation, and compromise are visible everywhere in programming.

These are exactly the points the human brain is not adept at, so many people cannot get started with programming in a short time. To master programming proficiently, one must recognize the differences between human and computer thinking and reinforce training in computational thinking. This training process is unlikely to be brief, thus getting started with programming requires significant time and effort.

Language #

However, the training and evaluation of thought need a carrier, just as assessing your English level considers your ability to use English to listen, speak, read, and write. So how do we express our computational thinking?

Humans can express thoughts through body movement, facial expressions, voice, text, etc. In the long history of humanity, the first three carriers were difficult to preserve and disseminate until the rise of audio and video began to address the issue slowly.

Text, particularly written language after the advent of language, has become one of the main ways for humanity to continue and develop civilization. Even today, we can still converse with the great minds before us through text. Of course, a prerequisite for conversation is that you must understand these texts.

And the prerequisite for understanding is that we use the same or similar language.

Coming back to computers, modern computers also have a universal language, which is what we usually refer to as binary machine language, or professionally speaking, the instruction set. Binary is the soul of computers, but humans find it difficult to understand, remember, and apply it. Therefore, in order to assist humans in operating computers, the previous generation of programmers abstracted the machine language for the first time and invented assembly language.

With the rapid development of hardware and software, program codes have become longer, and applications have grown increasingly bulky. The abstraction level of assembly language can no longer meet the demands of engineers for quick and efficient work. Just as similar developments in history, once they find that the abstracted language can no longer meet the work demands, engineers tend to abstract another layer on the basis of the original one. Among them, the prominent C language has laid the cornerstone of today’s computer systems.

Since then, countless programming languages have taken the stage in the world of computing. They are like stars in the sky, attracting numerous programming enthusiasts, such as the middle-aged Java and the new-generation Julia. Although the most correct way to learn about computers is not to start with languages, the best and easiest path to a sense of accomplishment in learning programming is indeed to start with languages. Therefore, the importance of programming languages is self-evident; they are the gateway to our journey into the world of programming.

C language is an imperative programming language, which is one type of programming paradigm; when writing code with C, we are more concerned with how to describe the running of the program, telling the computer how to execute it through the programming language.

For example, using C language to filter out numbers greater than 100 in an array. The corresponding code is as follows:

int main() {
  int arr[5] = { 100, 105, 110, 99, 0 };
  for (int i = 0; i < 5; ++i) {
    if (arr[i] > 100) {
      // do something
    }
  }
  return 0;
}

In this example, the code writer needs to use arrays, loops, conditional branching, etc., to instruct the computer on how to filter numbers. The process of writing code is often equivalent to the execution process of a computer.

In contrast, in another language such as Javascript, the code to filter out numbers greater than 100 might look something like this:

let arr = [ 100, 105, 110, 99, 0 ]
let result = arr.filter(n => n > 100)

Compared to C, Javascript has made a more advanced abstraction. Code writers do not need to concern themselves with array capacity, array traversal, and just need to throw the numbers into the container and add a filter function in the appropriate place. This coding method is known as declarative programming.

It is apparent that declarative programming tends to express what should be done when solving a problem, rather than how it should be done. This higher level of abstraction not only provides developers with a better experience but also allows more non-professionals to enter the field of programming.

However, there is no inherent advantage or disadvantage between imperative programming and declarative programming. The main difference lies in the level of abstraction of their language characteristics in relation to the computer instruction set.

The abstraction level of imperative programming languages is lower, meaning that the syntax structure of such languages can be directly implemented by corresponding machine instructions, suitable for performance-sensitive scenarios. Declarative programming languages have a higher level of abstraction. These languages are more oriented toward describing program logic in a narrative way, and developers do not need to concern themselves with the implementation details of the language at the machine instruction level, which is suitable for fast business iteration.

However, language is not static. Programming languages have been evolving, and their evolution speed definitely exceeds that of natural languages.

At the level of abstraction, programming languages have always been situated on the three layers of machine code -> assembly -> high-level language. As for the vast number of developers, our focus has always been on the high-level language layer, so high-level programming languages have also gradually become the narrowly defined programming languages (indeed a good thing; everyone should focus on what they do best without worrying too much about the troubles brought by differences in instruction architectures and instruction sets).

Speaking of this, have you noticed a pattern: The lower the abstraction level of the programming language, the closer it is to computational thinking, while the higher the abstraction level, the closer it is to human thinking.

Yes. The modern plethora of programming languages often involve trade-offs between human and computer thinking. Those language designers seem to be playing a game in this silent battlefield, opposing but also borrowing from each other. However, despite the competition, looking at the trends of human natural languages, it’s almost impossible for one to overwhelmingly dominate. Just like today, there are various human languages such as Mandarin and English coexisting. Even though Esperanto was invented in 1887, it seems we have never seen anyone speak it.

Given that there are so many high-level programming languages, for those of us who often struggle with choices, how should we decide?

Tools #

When it comes to choosing languages, you probably often hear this phrase: language is a tool. For a long time, I also cautioned myself that the merit of a language does not matter. It is merely a tool, and my job is to use this tool well. Language is the carrier of thoughts; as long as there are thoughts, any language can express them.

But as I engaged with more and more programming languages and gained a deeper understanding of code, instructions, and abstraction, I overturned this notion and realized the narrowness of the statement that “language is just a tool.”

A programming language is obviously not just a tool; it also restricts our thinking to a certain extent.

For example, those using Java or C# can effortlessly consider object design and encapsulation, because Java and C# use classes as basic organizational units. Whether you consciously do this or not, you have already done so. However, for C and JavaScript users, many seem inclined to use functions for encapsulation.

Leaving aside the quality of the language itself, this is a kind of thinking inertia, which precisely corroborates what I mentioned above: language restricts our thought process to an extent. In fact, if we start from human languages, the thinking patterns of a person speaking Chinese and English are quite different. Even a person speaking their local dialect and standard Mandarin may seem like two separate individuals to others.

Rust #

If thought is the starting point, then programming language not only expresses thought but also restricts our thought process to an extent. Discussing this brings us to today’s protagonist—the Rust programming language.

What is Rust?

Rust is a highly abstract, performance- and safety-oriented modern advanced programming language. The main reasons I study and advocate for it are threefold:

  • Highly abstract with strong expressiveness, supporting imperative, declarative, metaprogramming, generics, and other programming paradigms;
  • Powerful engineering capability, emphasizing safety and performance;
  • Excellent low-level capabilities, naturally suitable for kernels, databases, networking.

Rust greatly caters to human thinking, making high-level abstractions of the instruction set, and these abstractions allow us to write code from a perspective closer to human thought. Rust is responsible for translating our thoughts into computer language while ensuring excellent performance and safety. In simple terms, it perfectly balances the inherent philosophy and utility of a language.

Take the earlier example of “selecting numbers greater than 100 from an array” and apply it to Rust; the code would look like this:

let arr = vec![ 100, 105, 110, 99, 0 ]
let result = arr.iter().filter(|n| *n > 100).collect();

You might wonder if such concise code comes with a performance cost. Rust’s answer is no; it can even be faster than C.

Let’s look at the implementation ideas/key points of three small examples to feel Rust’s language expressiveness, engineering capability, and low-level ability.

Simple Coroutines #

Rust can seamlessly integrate with C and assembly code, allowing us to interact with lower-layer hardware to implement coroutines.

The implementation is clear. First, define the coroutine context:

#[derive(Debug, Default)]
#[repr(C)]
struct Context {
   rsp: u64, // stack pointer register
   r15: u64,
   r14: u64,
   r13: u64,
   r12: u64,
   rbx: u64,
   rbp: u64,
}
#[naked]
unsafe fn ctx_switch() {
   // Note the use of hexadecimal
   llvm_asm!(
       "
       mov     %rsp, 0x00(%rdi)   // move the value of rsp into memory location pointed by rdi + 0x00
       // ... (other register moves)
       "
   );
}

The structure Context stores the runtime context information (register data) of the coroutine. Through the function ctx_switch, the current coroutine can yield CPU rights, and the next coroutine takes over the CPU and enters the execution flow.

Then we define the coroutine Routine:

#[derive(Debug)]
struct Routine {
   id: usize,
   stack: Vec<u8>,
   state: State,
   ctx: Context,
}

Routine has its own unique id, stack, state, and context. Routine creates a ready coroutine through the spawn function, and the yield function yields the CPU execution rights:

pub fn spawn(&mut self, f: fn()) {
     // Find an available one
     // let avaliable = ....
     let sz = avaliable.stack.len();
     unsafe {
         let stack_bottom = avaliable.stack.as_mut_ptr().offset(sz as isize); // The top of the stack is at the higher address
         // ... (initializing the context and stack frame)
     }
     avaliable.state = State::Ready;
 }

pub fn r#yield(&mut self) -> bool {
     // Find a ready one, then let it run
     let mut pos = self.current;
     //.....
     self.routines[pos].state = State::Running;
     let old_pos = self.current;
     self.current = pos;
     unsafe {
         let old: *mut Context = &mut self.routines[old_pos].ctx;
         let new: *const Context = &self.routines[pos].ctx;
         // ... (loading pointers into registers)
         ctx_switch();
     }
     self.routines.len() > 0
 }

The runtime result is as follows:

1 STARTING
routine: 1 counter: 0
2 STARTING
routine: 2 counter: 0
// ... (output of subsequent switched coroutines)
1 FINISHED

For specific code implementations, refer to green-threads-explained-in-200-lines-of-rust.

Simple Kernel #

An operating system kernel is an enormous engineering project, but when it comes to writing a simple kernel that outputs “Hello World,” Rust can complete this task quickly. You can experience it yourself.

First, add the necessary tools:

rustup component add llvm-tools-preview
cargo install bootimage

Then edit the main.rs file to output “Hello World”:

#![no_std]
#![no_main]
use core::panic::PanicInfo;
static HELLO: &[u8] = b"Hello World!";
#[no_mangle]
pub extern "C" fn _start() -> ! {
   let vga_buffer = 0xb8000 as *mut u8;
   for (i, &byte) in HELLO.iter().enumerate() {
       unsafe {
           *vga_buffer.offset(i as isize * 2) = byte;
           *vga_buffer.offset(i as isize * 2 + 1) = 0xb;
       }
   }
   loop{}
}
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
   loop {}
}

Then compile, package, and run:

cargo bootimage
cargo run

The runtime result looks like this: Hello World on VGA display.

For specific code implementations, refer to Writing an OS in Rust.

Simple Network Protocol Stack #

Like operating systems, the network protocol stack is also a complex engineering system. However, with the help of Rust and its complete ecosystem, we can quickly complete a compact HTTP protocol stack.

First, at the data link layer, we define the MacAddress structure:

#[derive(Debug)]
pub struct MacAddress([u8; 6]);

impl MacAddress {
 pub fn new() -> MacAddress {
     let mut octets: [u8; 6] = [0; 6];
     rand::thread_rng().fill_bytes(&mut octets); // Generate randomly
     octets[0] |= 0b_0000_0010; // Ensure unicast and locally administered
     octets[1] &= 0b_1111_1110; // Reset the second least significant bit
     MacAddress { 0: octets }
 }
}

MacAddress is used to represent the physical address of a network card, and the new function generates random physical addresses through random numbers here.

Then implement the DNS domain name resolution function, obtaining the MAC address from the IP address as follows:

pub fn resolve(
   dns_server_address: &str,
   domain_name: &str,
  ) -> Result<Option<std::net::IpAddr>, Box<dyn Error>> {
   // ... (DNS request and response processing)
   let response = Message::from_vec(&response_buffer).map_err(DnsError::Decoding)?;
   for answer in response.answers() {
       if answer.record_type() == RecordType::A {
           let resource = answer.rdata();
           let server_ip = resource.to_ip_addr().expect("invalid IP address received");

           return Ok(Some(server_ip));
       }
   }
   Ok(None)
}

Next, implement the HTTP protocol’s GET method:

pub fn get(
   tap: TapInterface,
   mac: EthernetAddress,
   addr: IpAddr,
   url: Url,
) -> Result<(), UpstreamError> {
   // ... (Networking setup and HTTP request handling)
   let http_header = format!(
       "GET {} HTTP/1.0\r\nHost: {}\r\nConnection: close\r\n\r\n",
       url.path(),
       domain_name,