Since 2019, after moving away from C++ competitions to focus on computer engineering development, I entered a phase of evolving into full-stack development. In my previous About Page, I could list dozens of languages I had mastered like a menu. You might think, wow, that's cool, but in fact, mastering multiple languages isn't that difficult. It's not about being exceptionally talented; you just need to grasp the tricks. In March 2024, I wrote my first Rust program, and in April, I released my first program entirely in Rust AH-dark/qiandaobot-rs on my GitHub. By September, I had already started using Rust to develop large distributed web business systems. Thus, I could transfer my previous experiences in Go, PHP, and C++ to Rust.
Thinking Patterns#
"Migration" is a common concept, but most people don't know how to achieve it. In my view, migration involves retaining similarities and replacing differences. For example, both Go and Rust use composition rather than inheritance, which is a similarity; while in Rust, we use trait
, which is different from Go's interface
and Java and PHP's abstract classes. Therefore, we can draw on Go's design ideas about composition in Rust, while familiarizing ourselves with the use of trait
through numerous examples.
Of course, keywords and superficial designs are quite simple; you can refer to documentation, books, or some learning resources (like Rustlings) to learn. However, for some design patterns, programming paradigms, and language philosophies, you need to experience them through practice. For instance, Rust's Ownership largely forces me to shift from an object-oriented design mindset to a functional programming mindset. Let's take a simple example:
import java.util.concurrent.CompletableFuture;
public class UserService {
private UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User getUserById(int id) {
return userRepository.getUserById(id);
}
public void listenUser(User user) {
CompletableFuture.runAsync(() -> {
this.userRepository.createUserListener(user);
}).exceptionally(ex -> {
System.err.println("An error occurred while processing user: " + ex.getMessage());
return null;
});
}
}
This is a common Java code where we inject UserRepository
through the constructor. UserService
provides a getUserById
method to retrieve user information; it also provides a listenUser
method to listen for user information. The latter is non-blocking because it uses CompletableFuture
to wrap the blocking createUserListener
method. For Go, although it doesn't provide a class concept, we can implement something similar:
type UserService struct {
userRepository UserRepository
}
func NewUserService(userRepository UserRepository) *UserService {
return &UserService{userRepository: userRepository}
}
func (svc *UserService) GetUserById(id int) User {
return svc.userRepository.GetUserById(id)
}
func (svc *UserService) ListenUser(user User) {
go func() {
svc.userRepository.CreateUserListener(user)
}()
}
This is a Go code where we inject UserRepository
through the constructor. UserService
provides a GetUserById
method to retrieve user information; it also provides a ListenUser
method to listen for user information. The latter uses the go
keyword to start a new goroutine to execute the CreateUserListener
method. Now that you're familiar with this design pattern, you can naturally write the Rust code:
struct UserService {
user_repository: UserRepository,
}
impl UserService {
fn new(user_repository: UserRepository) -> Self {
Self { user_repository }
}
fn get_user_by_id(&self, id: i32) -> User {
self.user_repository.get_user_by_id(id)
}
fn listen_user(&self, user: User) {
tokio::spawn(async move {
self.user_repository.create_user_listener(user).await;
});
}
}
We inject UserRepository
through the new
function. UserService
provides a get_user_by_id
method to retrieve user information; it also provides a listen_user
method to listen for user information. The latter uses tokio::spawn
to start a new asynchronous task to execute the create_user_listener
method. You think you've accomplished an excellent migration.
However, the problem is that it won't compile because the asynchronous call in listen_user
uses self
, and Rust cannot guarantee that self
's lifetime will always exceed the asynchronous task's lifetime. At this point, you need to shift your design paradigm. For example, we can change Self
to a reference-counted type, i.e., Arc<Self>
, which ensures that self
won't be destroyed before the asynchronous task.
But you might face the next problem: if you need to modify self
, you will need to use Mutex
to ensure thread safety, i.e., Arc<Mutex<Self>>
. However, Rust does not allow you to use Arc<Mutex<Self>>
in functions within the impl
block; it only supports wrapping some built-in types, so you will need to use additional functions to implement it.
This is just a simple example; I've encountered many similar issues. For instance, Go implements tracing based on context.Context
, while Rust requires manipulating a global variable within a thread or using the tracing::instrument
macro. Also, Python implements IM software based on an asynchronous model, but TypeScript requires a synchronous event model.
These are all different design patterns and thinking methods that you need to experience through practice. After reviewing enough cases and documentation, think for yourself, find solutions, and understand those solutions.
Exploration & Practice#
Learning without thinking leads to confusion, and thinking without learning leads to laziness. Practice is important; you can write some simple programs to familiarize yourself with the basic syntax of a language, but that alone is not enough to master a language. More is needed: projects that gradually increase in complexity, from simple to complex, from small to large. For example, when I learned Rust, I started with a simple Telegram Bot, then moved to a more complex Bot, then created a distributed cronjob system1, and finally developed a large distributed web business system. Such practice familiarized me with Rust's ecosystem, design patterns, and programming paradigms.
You might think that such projects are too large for you to complete. But in reality, you don't need to complete the entire project. You only need to finish a part of the project, such as a module, a feature, or a characteristic, and use this module to enhance your understanding of a specific part. As for the majority of the project's CRUD content, just let it go; your future ideas will have greater value, so don't waste time on these introductory works.
My habit is to use some new tech stacks in each project, combined with what I know, to implement a new feature. For example, in Universal Payment, I chose familiar sea-orm and actix-web, but I tried new openidconnect, volo, and lapin, allowing me to learn new things based on what I was already familiar with. Compared to completely unfamiliar projects, such projects make it easier for you to learn rather than feel overwhelmed, and they also allow you to get up to speed with new tech stacks more quickly.
The same applies to migration; for example, from Java to Kotlin, you can gradually refactor parts of the system. I introduced Rust into a Go-based Telegram Bot microservice system, starting with a simple module and gradually migrating the entire system. This migration approach helped me become more familiar with Rust's ecosystem, design patterns, and programming paradigms because your experiences can correspond to each other. You know how you thought in Go and how you think in Rust, and this comparison helps you understand the new language through your original experience.
Compared to merely talking about it, exploration and practice are more important. You can learn the basic syntax of a language through reading documentation, books, and blogs, but only through practice can you truly master a language. In practice, you will encounter various problems that will deepen your understanding of a language and help you use it more proficiently. Of course, exploration and practice require you to genuinely love computers and the language, regardless of patience and perseverance; you need to truly enjoy the process rather than just completing a task.
Open Source Community#
The open-source community is a great place to learn how others do things, helping you avoid reinventing the wheel. I widely publish Issues and PRs in the community, which has introduced me to many new things, such as the extensive use of generics in the openidconnect
library and the definitions of serde serializers and deserializers, as well as methods for protecting internal objects using Rust's powerful permission system. Through communication with the community and collaboration with others, you can quickly grasp new technologies and design patterns while correcting your mistakes.
Including Go's Fx has greatly inspired my understanding of the language. Fx is a dependency injection framework that helped me understand how dependency injection is implemented in Go, and it also taught me how to ensure cleanliness and maintainability during initialization for large applications. You won't find Fx in textbooks, and even a large part of the community may not use it, but encountering it during exploration has been immensely beneficial.
Conclusion#
In conclusion, mastering a language requires passion, exploration, and a shift in thinking. As someone who has lived for only 17 years but has studied programming for over 10 years, I believe this is the most important thing. Computer Science is a vast field, and you cannot master everything, but you can enjoy the fun of exploration, debugging, and discussion within it. I hope this article helps you better master a new programming language.
Footnotes#
-
AH-dark/distributed-scheduler is a distributed cronjob system I implemented in Rust, and the aforementioned class lifecycle issue was encountered in this project. ↩