|
|
|
10:47 |
|
|
transcript
|
2:27 |
Welcome to Up and Running with Rust. My name is Christopher, and I will be your guide. Rust is a compiled systems programming language that is rapidly gaining in popularity. Let's just get this out of the way to start with. You're visiting Talk Python, so why is someone pitching you on Rust? Well, Rust is becoming the de facto language to use with Python when you need some added speed. Many popular Python tools and libraries are now written in Rust. Tools and extensions that formerly would have been done in C are frequently now being coded in Rust. Ultimately, this course is about how to build a Python library using Rust. As I'm sure you can guess, that first means learning some Rust. The last step in this course will be the integration of the two languages. Everything up to that will be a crash course in Rust programming. So just what's going to get covered? Well, obviously, some syntax, how to write Rust code. Next, you'll learn about Cargo. It's sort of a uv-like tool for building and managing your project. The packages that Cargo installs are called crates, and Rust's equivalent of PyPI is crates.io. You'll see how to use all of these things for your project. Because Rust is a compiled program, memory management starts to become more important. If you've written compiled languages before, this will be more of that. If you're coming from Python or other scripting languages as your first language, this is going to take a little extra to wrap your head around. When dealing with memory management, a lot of bugs have to do with who owns what and who can free the memory at any given time. Rust has a particularly interesting approach to how to deal with that and has some very strict rules about ownership and borrowing that ownership. I'll be talking about all of these pieces so that you can understand how to manage objects inside of the Rust code. And then of course finally the ultimate goal here is to write some Rust that integrates with Python. The first part of this course is all about Rust and has no prerequisites. The second part is about writing Rust code that is used by Python and for that you'll need some Python. I'll be assuming you're pretty comfortable with the core Python language. If you're still fairly new to Python, you might need to do some brushing up before you get to the integration stage. If so, maybe check out this course first to make sure you've got the background needed before tackling integrating with Rust.
|
|
|
transcript
|
2:39 |
Before getting started, let me give you a little background. Like Python, Rust is a general purpose language, meaning you can build anything you like in it. General or not, it tends to be used as a programming language, typically for things that are lower level than Python. It's definitely pickier than Python and can be more challenging to code, but the end result might be more bug-proof and likely will be faster. Rust was created by Graydon Hoare in 2006 at Mozilla. Yep, the Firefox people, among other things. In fact, a good part of Firefox is now written in Rust. Like with Python, a not-for-profit group is in charge of Rust and making decisions about the language. The Rust Foundation was created in 2021, and it's now the group that determines the future of the language. Unlike Python, Rust is a compiled language. Python is interpreted, meaning you're not actually running your code, you're running the Python interpreter, which then runs your code. Rust, by contrast, generates a machine language binary which your operating system executes directly. The output of the Rust compiler is a program which is closer in nature to CPython itself than your Python code that runs in the CPython interpreter. Like Python, Rust is part of the C family of languages, so although it's different, you will find some familiarity with the syntax. One of Rust's strengths is type safety. The compiler enforces type compatibility in all operations, as well as the scope of any declaration. Rust doesn't have a garbage collector like Python, but the compiler deallocates memory when things go out of scope. This is a superpower, as it almost eliminates a certain class of bugs. It's also what makes coding in Rust a little more difficult. You've got to be very particular about your declarations. I mentioned earlier that Rust is becoming a popular choice for Python tools. Some of your favorites out there are built on Rust, including linters like Pyrefly, Ruff, and Ty, as well as packaging tools like Pixi and uv. It isn't just about the tools, though. Increasingly, third-party Python libraries that require high performance are being written in Rust. arro3 is a wrapper to the Apache Arrow toolkit. Cryptography is a drop-in high-performance replacement for the Python package of the same name. Granian is a fast web server, and one of my favorites, Polars, is a super-fast dataframe library. Talk Python has a course on that as well, which I humbly submit is worth checking out. This quickly-growing awesome list contains projects and tools that use Rust in the Python ecosystem, if you'd like to see more.
|
|
|
transcript
|
2:21 |
All of the source code in this course is available in a Git repository. Download it to augment your coding along experience if you like. In case you need a face to go with the voice, this is my ugly mug. You might recognize my voice from the Real Python podcast. I'm also the author of Django in Action. That's got nothing to do with this course, but hey, you're a captive audience. Author's got a promo, huh? Feel free to connect. Reach out to me on LinkedIn or Bluesky. Rust is enough of a Python topic. that Michael Kennedy has interviewed folks about the language. Episode 487 interviews David Seddon and Samuel Colvin, and they talk about exactly what this course covers, writing extensions for Python in Rust. All right, enough intro-ee blather blather. Let's get this show on the road. The Python code in this course was tested using Python 3.14. Python does major releases once a year in October, but Rust does it differently. They release much more frequently, about every six weeks. You might then think to yourself, how will I stay caught up if it moves that fast? Well, although they release much more frequently, they have a concept called an edition. A Rust edition is a compatibility peg in the ground. I recorded this course in 2026, and although there have been a bunch of Rust releases since 2024, they're all compatible with the 2024 edition. No breaking changes. What's more, when you compile your code, you can specify an edition, and the compiler will maintain compatibility with it. I really like this idea. It's a nice compromise between stability and frequency of release. I'll talk more about this when I demo some code in the next lesson. All that's to say, go grab the latest version of Rust and you'll be fine. If you're from way out in the future, look out for the flying cars, and you run into a compatibility issue with the code I'm demonstrating, simply set your configuration to the 2024 edition and you'll be good. I'll cover how all of that works, and in fact I'll show you what's involved in installing Rust right after this slide. When you get to the second part of this course and are integrating Python code with Rust code, you're going to be using third-party Python libraries. It's always best practice to use a virtual environment in that situation, so you should set one of those up to get started as well.
|
|
|
transcript
|
2:39 |
This is the page you'll see if you visit rust-lang.org and click on the Install tab. Installing Rust actually installs several different programs, similar to how installing Python means you get pip and other things as well. One of those programs is rustup, which is a tool for management. If you've used PyEnv or uv to install different versions of Python, rustup is kind of like that. If you're on Linux, macOS, or other Unix-like systems, All you need to do to install Rust is copy the command line here on the page and paste it into your terminal. Most Unix systems come with curl. In the rare case that your system doesn't have it and you can't get it through your package manager, visit curl.se to install. Once you've got curl installed, this is that same command line from the web page that I just showed you. There's a lot going on here, but essentially it's downloading a shell-based install script and then executing it to get rustup going. Then rustup takes care of everything else. By default, the installation goes in .cargo in your home directory. Inside of that is the bin directory where all the tools are. The install script will attempt to update your path variable, but depending on what shell you're using, it may have trouble. If it does, add .cargo/bin to your path by hand. If you're on Windows and not using the Linux subsystem. I'll cover how to install there next. Instead of going to rust-lang.org, you can visit rustup.rs. This page has links to a variety of installers, including the script I just spoke about. By default, the page is collapsed. Below the command line is a link that says display all supported installers. Click that and the page will expand, showing you other options. The yellow arrow here points to the 64-bit Windows installer. There is also a Windows ARM version and a 32-bit version further down the page if you need them. As a note, although you can do Rust in Windows without a problem, this course will be demonstrating using a Unix command line. The Rust-specific things will be the same, but when I do things like create directories or list their contents, I'm going to be using Unix commands. If for some strange reason, all of these things fail, you still have options. This is the documentation page for installing Rust, and it covers a bunch of different ways of getting rustup on your machine, including using package managers, the nightly builds, and even how to compile it yourself. It also has details on how to configure tab completion for your Unix shell if you want to save yourself some key presses in the future.
|
|
|
transcript
|
0:41 |
There's one very important thing before you get started. I need to introduce you to Ferris. Ferris is the unofficial mascot of the Rust programming language. Oh, and if you thought Python programmers calling themselves Pythonistas was a little strange, well then it's going to take a bit to get used to the idea of crustacean. Sometimes they even leave the leading C off, giving you Rustaceans. Yes, this course will be littered with crab references, you've been warned. If you want Ferris images or even merch, visit rustacean.net. Now that you've done the important thing, meeting Ferris, you're all set to go. Let's get rusty.
|
|
|
|
53:08 |
|
|
transcript
|
3:46 |
In the previous lesson, I gave you an overview of the course. In this lesson, I'll give you a quick, well, maybe not so quick, tour of the Rust programming language, concentrating mostly on constructs that are similar to Python, laying the groundwork for more complex programs later. In the grand tradition of learning a programming language, I'm going to start with a variation on Hello World. This program will have a single source file and three lines of code. I'll go into this more deeply later, but for now, remember that Rust is a compiled language. By convention, source files get named with an .rs extension. Unlike Python, where the script is the thing that gets executed through an interpreter, a compiled language takes one or more source files and turns them into a machine language executable. Once you had that executable, that's all you need. You could distribute just that program without the source. In real life, it's a tad more complicated than that because machine language targets a specific machine architecture. But if a friend of yours was using a similar computer, you could just give them the resulting executable file and they could run it. To get that executable file, you run the source file or files through the compiler. The Rust compiler got installed by rustup for you, and it's called rustc. This part of the course is probably the only time you'll see me use rustc. Rust comes with tooling similar to uv that is good for building more complex projects. Once I've shown you the compiler, I'll move on to the way it's usually done instead. You have to walk and probably trip a few times before you motocross. That's how that goes, right? Especially with rust, since you'll be crab walking, it might take a little getting used to, and claws on the bike handles are a bit of a challenge. You can start counting crustacean references now. Put a little tick on your wall or something. Okay, so just what does the source look like? If you've ever used a main function as an entry point for a runnable module in Python, Rust expects something similar. You don't just put code willy-nilly in a file. You need an entry point that gets called when the executable gets run. Rust, in the tradition of C family languages, uses a function called main for said entry point. To define a function in Rust, you use the fn keyword. If you use type hints in Python, you'll find that the function signatures in Rust are similar to that, except using fn instead of def. Another consistency with most C family languages, and a deviation from Python, is that indentation is only cosmetic. Blocks are defined by brace brackets instead. Python is actually the weird one here. In fact, I'm not aware of another C family language that uses indentation. Doesn't mean there isn't one out there, but they're not common. There are two kinds of comments in Rust. The single line form is two forward slashes. This denotes a comment that is closed by the end of line. You can tack one of these on the end of a line of code if you like. There is also a multi-line format which uses slash star and star slash. Also a C thing. The last little difference is how you print things. Instead of Python's print function, in Rust you get println, short for print line. Do note the exclamation point on the end there. It's important. Rust includes the ability to write macros. You call a macro like a function, but with the exclamation point. println is actually a shortcut for a bunch of other stuff, but you don't need the details as long as you can match the syntax. Let's go say hello to our little crabby friend. Be grateful I didn't attempt a Scarface accent there.
|
|
|
transcript
|
4:16 |
I've opened a terminal with two windows. In the bottom window I have a Unix shell, and in the top I'll show you some code. I'll start by creating a directory for my new program. And now I'll go into it. I'm doing this next step through the magic of video editing. If you're coding along with me, this would be the point where you open your favorite editor and create a file called hello.rs. In real life, I'm very old school and use Vim, but I won't expose you to that any more than I'd serve you a Stegosaurus burger. This is hello.rs. It starts on line 1 with a comment showing you the name of the file and its location. You really don't need to do this, but I tend to do it for courses to help make sure you know where you're looking at. As I mentioned, comments in Rust use double slash. This is the program's entry point, the main function. Even in complex projects with multiple files, somewhere you need one of these so the compiler knows what code to call when the program starts. Instead of Python's def, Rust uses fn instead. I kind of like that. It's a little clearer about what it is. Def is defining what, whereas fn is function. The scope of the function is defined by the brace brackets. The opening brace on line 2 starts the body of the main function, while the closing brace on line 4 closes the scope. Later, when I add loops and conditionals, I will nest brace brackets, giving blocks within blocks. If you've coded in languages besides Python, this will feel like an old friend. If your first language is Python, well, then you'll understand the opposite feeling I had when switching to Python, the 9th or 10th language I learned. 80% of those used braces, so going to spaces was a bit of an adjustment. You'll get used to it eventually. In the body of the main function, there is a single call to println. Don't forget the exclamation point. This is similar to print in Python. You pass it a string and it gets printed out. Time to turn this source file into an executable. Let me just list the contents of the directory. And you can see I've only got the single source file. Now I'll run the compiler, passing in the name of the source. Now I'll check the directory again. And you see that a new file has been created named hello without any extension. That's the convention in Unix. If you're on Windows, you'll get an exe file. If you haven't seen it before, the little star after hello isn't part of the file name, but a feature of the ls directory listing tool. It annotates file names with symbols to tell you a little about it. Star means executable, which is what you need in this case. If you're on Windows, you'll also see a file named hello.pdb. That contains the corresponding debug information for your executable. On Unix, that's not necessary 'cause it's built into the executable file format instead. Now, let me run it. Hello, crabby world it is. In case you're not a Unix person, the./ means to run the file in the current directory. That isn't necessary in Windows, because the current directory is in the execution path by default there. Which, of course, could be considered a security flaw, but Windows is going to Windows. The resulting hello file is binary and contains machine code that your computer knows how to run. If I inspect the contents with the file command, You can see that it is a 64-bit Mach-O executable, which is the executable file format used on Macs. If you're on Linux, you'll likely see Elf here instead. If you're on Windows, you might not even have the file utility installed. If I send you this file, and you're on a 64-bit Mac on Apple Silicon, it should run. If you're on something else, it might not work, and you'd need a different executable created from the same source. Rust does support cross-compilation. It's beyond the scope of this course, but you can generate compatible executables for different platforms from the same compiler if you want to.
|
|
|
transcript
|
3:26 |
Packaging in the Rust ecosystem is first order. It's included amongst the tools you get when you install Rust. This is different from the Python approach, where package management is mostly done by third-party libraries. As such, you don't typically use the rustc compiler directly, like I just showed you. Instead, you're going to use Cargo, a build and package manager that comes with Rust. Similar to Python, I suspect a heavy influence on the decision in Python, Configuration for your project goes in a toml file. cargo's toml is named cargo.toml, logically enough. The cargo utility has subcommands that you use to create and manage your project. There's a whole bunch of them, but I'm going to start with just a few. new creates a new project. build compiles the source in your project, creating your executable. check is like build, but without the final creation of the executable. Compiling large projects can take a bit of time, especially if you're linking in a lot of third-party libraries. Check allows you to validate your code, the first step of building the executable, but stopping there without the more expensive final step. Although you can run your executable directly, Cargo puts it in a subdirectory and can build different versions of it. Calling the run command runs your program without you having to go looking for it in the subfolders. Finally, clean is to get rid of those pesky build artifacts that take up all sorts of space. Who needs a program when you can just send people the source code and they can imagine it working? Before delving into cargo, a quick aside about how language versioning works in Rust. It's a little different from what you're used to. The Rust language and compiler have a six-week release cycle. Yep, you heard that right. Six short weeks. Given how long it takes to put a course together, I'm pretty confident you and I are using different versions. The focus is on smaller, more frequent changes rather than the bigger bang approach that Python and most other languages use. To avoid a mess of compatibility problems, Rust introduces a concept separate from version numbers, editions. You can think of an edition as a compatibility target or collection of features. New editions come out slower than most languages, typically every three years. Each edition contains all the incremental changes up to then, and can be backwardly incompatible. This allows the language designers to make changes in things they don't think they got right the first time around. And although that might break compatibility between editions, the compiler supports all editions as targets, so if you've got the latest version of the compiler, you can target any edition. The current editions are Rust 2015, 2018, 2021, and 2024, which is the version I'll be using. 2027 will likely be the next release of an edition. To ask the compiler to use a specific edition, you can set it as a configuration argument in your cargo.toml file. If you don't specify it, it defaults to 2015, ensuring compatibility with the first edition. All right, let's go digging for crustaceans. put on your cargo shorts. Yeah, bad puns are where I'm at. It's probably a quiet call for help. Time for a new version of Hello World, this time using the cargo utility.
|
|
|
transcript
|
2:39 |
You create a new project with the cargo new command. This created a directory for the new project. Inside of it you get a source directory and a cargo.toml file. Actually you get more stuff but let's put a pin in that for the moment. This is the automatically generated cargo.toml file. Well, almost. I say almost because I've stuck a comment at the top of the file in my continued need to label things. You should see my shelves at home. If you're familiar with pyproject.toml and Python, this is quite similar. Square brackets in a toml file denote a section. Actually, they're a dictionary, but the tool uses dictionaries with specific names to hold key value pairs for configuration, so I find it easier just to think of it like a section. The package section specifies project level configuration. The first value is the name of the project. This corresponds to the argument you gave cargo new and will also be the name of the resulting executable. This is your version number. It defaults to semantic versioning with a 0.1 release. And this is the aforementioned edition. The default version is 2015 if it isn't specified in cargo.toml. The template that cargo new uses specifies the edition, setting it to the latest one. Down at the bottom here is where you specify what third-party libraries you are dependent upon. In the Rust world, these kinds of packages are known as crates. Get it? Cargo? Crates? Clever, huh? For our Hello World-like project, I won't be using any, so this section is empty. Alright, that's the config. Before digging into the code, I'll take a quick tangent. Remember when I said there was some other stuff in this directory? The dash A option for listing a directory shows the hidden files. There are two here, and both are for a git repo. The .gitignore file tells git what files not to include when doing git things. And the.git directory is where git stores its data. Yep, by default, cargo new sets you up with a git repository. The --vcs flag allows you to change what kind of version control system to use and accepts none as an argument. You can also globally configure default values for how cargo behaves by creating a config.toml file in your.cargo directory in your home directory. I'll point you at some docs that describe that a little later.
|
|
|
transcript
|
1:37 |
Now for the source code. First, let me go in the source directory. And this is what is inside. Previously, you saw me create hello.rs and compile it directly. For most projects, you're going to end up with multiple files. cargo new creates main.rs as the entry point and puts a dummy main function inside of it. Speaking of, this is it. This is not actually the default main.rs file. The default one is similar, but it doesn't have the comment up top and the default print message isn't in Spanish. I've edited the file already for our own version of the program. The contents are no different than hello.rs, but the file name and where it's located is friendly to the cargo tool. Time to build it. Let me just move back up a directory. And the command to create the executable is cargo build. Looking at the directory, you'll see some new things. First, cargo.lock. Remember how in cargo.toml there's a section for specifying third-party dependencies? When you specify a dependency, you don't have to be specific. This is similar to when you do pip install in Python. You can say, give me the latest version less than version 5, for example. What cargo.lock contains is the actual thing the dependency resolved to. This file is automatically generated as part of the build process and you shouldn't edit it. The other new thing here is the target directory. This is where Cargo puts the build artifacts, including the resulting executable file.
|
|
|
transcript
|
3:19 |
Looking in Target, you see a data file that Cargo uses to know what things need to be recompiled when something changes, and another directory. By default, Cargo creates a build with debug information in it and puts that in the debug directory. Looking in Target debug, you'll find a whole bunch of build artifacts, including our actual executable, "Hola Mundo". Just like with our manually compiled hello file from before, I can execute this directly. Evidently, Ferris the crustacean speaks Spanish. Typing all of those directory names is a pain, so Cargo has a shortcut for you. When you call Cargo Run, Cargo executes whatever the build target was. It also gives you a bit of info first on what it's doing. first two lines tell you that the dev target is what's getting invoked, and the third line is the actual output from our program. I find this a little messy. Having debug info from the runner mixed in with my code's output is confusing, but the message does make sure you know exactly what is getting run. I mentioned earlier that compilation of larger projects can be expensive. Unlike Python, where a module might get dynamically loaded, or even lazy loaded in Python 3.15, every bit of code in your project has to be compiled and glued together in a process called linking. A third party library might ship binary targets so that you don't have to compile them, but it still needs to be linked. If all you want to do is find out whether your source is okay, you can do that and skip the linking step with cargo check. Seeing as I didn't change anything, and I don't have any errors, this ran quickly and tells me everything is good. The default build target is a development executable and includes debug information. Debug information takes up space, so if you're not going to be debugging it, you can create a production version instead. To do that, pass the dash dash release flag to the cargo build command. The end result is a new release directory inside of target, the contents of which are similar to its sibling debug directory. If you want to run the production version, pass the release flag to the cargo run command. Same kind of info from the runner, and of course, hola mundo. Let's take a quick look at the debug and release executables. If you're not used to the full output of ls, that's a lot to take in. The key takeaway for our purposes is the number 523,632, which is the file size in bytes. Examining the release version shows a smaller file size of 420,084 bytes. That's almost a 20% improvement by not including the debug information in the executable.
|
|
|
transcript
|
1:37 |
Build artifacts take up space. If you'd like to get rid of some of them, you can use the cargo clean command. Passing the release flag in means only the release directory gets cleaned up. This time nine files and 800 kilobytes have gone to meet the big crustacean in the sky. As you can see, the target directory now only has the debug folder and, of course, that cache data. Running cargo clean without any flags cleans everything So now the target directory is gone as well Another big advantage of the cargo run command is that it knows whether something has changed in your files And whether your build is up to date So you don't really need the cargo build command You can just call cargo run and it'll do both one last hola before saying goodbye to this section i'll be showing you more features of the cargo package manager as i go along but if you want to get into the details yourself this is the documentation for it i mentioned that you can configure defaults like whether or not to create a version control directory and if so what kind this is a direct link to the section in the documentation that explains how to configure the cargo tool. In fact, I'm going to go turn off version control generation now, setting --vcs to none. I'd rather generate the repo by hand, especially seeing as I'm putting all of the sample code into a single repository.
|
|
|
transcript
|
1:38 |
Now that you've got a handle on the build tool, it's time to learn a little more of the Rust language itself. Rust supports both looping on conditions as well as a for each style. The while keyword is very similar to Python's. Although Python tends to emphasize iterables, you might not even have known that while was there. Like all block scopes in Rust, you use brace brackets to indicate what is inside of the loop. Like with Python, you don't need to put parentheses around the while loop's continuation condition. I mention this because some other C family languages do require them. Variables are a little more intricate than Python. I'm going to spend a lot of time in this course on typing in Rust and the hoops you have to jump through to compile things, but I'll try to keep it simple for now. First off, variables are statically typed. They get checked during compilation, and if you try to use two types that aren't compatible with each other, you will get an error. They're also immutable by default. Unless you specify otherwise, you can't change a variable. Assignment is done using the let keyword, which you can then combine with the mut keyword to specify a variable as mutable. I'm not really sure how to pronounce that. Saying mutt seems wrong because it's short for mutable, but mute has its own meaning, but whatever. Rust has many of the same data types as Python, including your typical integers, floats, and strings. It's actually far more complicated than that, but this is good enough as a place to start from. You've greeted a crustacean. Let's change gears and do a little rocket ship countdown, looping from 10 to 1.
|
|
|
transcript
|
2:57 |
Soon enough, I won't make you watch all the setup steps, but as you've only seen it once, I figured one more time won't hurt. Let me create a new project called Countdown. Then go into the resulting directory. And like before, I have a cargo.toml file and the source directory. Off screen, I've already replaced the default main.rs with our new code. Let me show that to you. Like always, I'm starting by defining the main function. The first line in main is a variable declaration. The let keyword tells Rust that I'm declaring a variable, and the mut keyword says I'd like to be able to modify it within the program. The rest of this line is what you'd expect. I'm creating a variable named number and assigning the value 10 to it. Note the semicolon on the end. I was a little sloppy before. You can sometimes get away without them. but they have meaning. That meaning will wait until later, but for now, try to get in the habit of sticking semicolons on the end of your statements. This is the while loop, and this is its condition. The comparison operators are the same as Python, which again has to do with that shared C-family heritage. By the way, there is also a loop keyword in Rust if you want an infinite loop. You just use loop on its own, no conditions, and of course the brace brackets to define its scope. inside of the while loop, I'm printing out the value of number. Like with fstrings in Python, you can get at variables inside of strings using brace brackets. Rust's mechanism is far simpler than Python's though. You can't do calculations, just the inclusion of a variable. There are ways of doing more complicated stuff, but I'll wait to show you that later. The second line in the loop decrements the value of number to do our countdown. And then at the bottom here, one last printed message before the program finishes. Let's give this a go. Remember, you can build and execute with one call to cargo run. That was fast, like a rocket ship should be. Let me scroll up a bit. First you see the output from building the executable, once more using the debug target by default. Then it starts at 10 and counts down. Python isn't the fastest language, and that's fine, that's really not its strength. One of the slower parts of Python is the startup time. That's because you have to initialize the interpreter that then loads your script. With a compiled executable, you have none of that overhead. So it isn't just that compiled languages are faster, they also don't have to do as much to get going. Hence our speedy little rocket. Speedy rockets are good. Maybe Ferris can visit Rocky. Amazeballs. Hmm. Modern media reference for the win.
|
|
|
transcript
|
1:07 |
Like Python, the preferred way to loop in Rust is using the for and in keywords. You'll see references in the documentation to looping over collections, but underneath it all, there is a concept of an iterator just like in Python. There isn't anything special about defining a function that you haven't seen already with main. The only difference with main is it's the program's entry point. Otherwise, it's a function declaration like any other. What I haven't shown you yet is the use of arguments and return values. To do this, the signature declaration is very similar to type hints in Python. The difference is there's no dynamic typing in Rust. You have to be explicit about what is being passed in and what is getting returned. If you're asking, well, I didn't see you use that when you were declaring main, that's because main returns the empty type, which you can think of like Python's None. There is a return keyword in Rust, but its use is subtly different from what you're used to. In a normal case, the last expression in a block gets returned as a result of the block, without the need for a keyword. Put a pin in that, I'll come back to it in a sec.
|
|
|
transcript
|
4:15 |
In the next example program, I'm going to be declaring a function that takes an argument and returns a value. So now's the time to dip your toes into rusts types. Hmm, dipping your toes into rust sounds like a great way to get tetanus. What was I saying before I went morbid? Oh yeah, types. You can tell I'm an old school Pythonista. Types make me go to a dark place. In the next lesson, I'll be covering the mechanics of memory in a lot more detail. For now, understand that Python abstracts away a lot of information when you say you want an integer. Most C family languages require you to be a lot more specific than Python does. You usually need to indicate how many bytes of storage you want to use to store a number. This goes for more than just integers in Rust. There are two kinds of primitives, scalars and compounds. First, let's cover the scalars. Signed integers are whole numbers that can be positive or negative. There are six sizes, five of which specify how many bits get used to store them. i8 is an 8-bit integer. A signed 8-bit integer can store from from negative 128 to positive 127. On the other end of the size spectrum is i128, which is a signed 128-bit integer. This ranges from negative 2 to the power of 127 up to positive 2 to the power of 127, plus or minus one. That's a 66 digit number in case you wanted to visualize it. The odd one out here on the end is isize, which is the size of a pointer on your system. Something else I'll cover more in a later lesson. For every signed integer type, there is an unsigned type. These only store positive values, meaning for example, u8 ranges from 0 to 255. Both signed and unsigned integers are fundamental types your CPU uses. Rust is mapping to your hardware, whereas Python abstracts all of that away. Floating point is a way of describing real numbers. You can't store all real numbers, but they're good enough for most math things. Rust supports two types of floating point, 32 and 64 bit. You already saw me use a string inside of the println statement. In Python, there isn't really an abstraction for a character on its own. You just use a short string. Rust, like C, you tired of me saying that yet, has a specific type for storing characters, while strings are a way of combining those together. The char type stores a single Unicode code point, which takes up four bytes. The bool type is for booleans. Rust doesn't capitalize the true and false values, which may take a little getting used to. Finally, the last type is known as the empty type. This gets used like None in Python. Rust supports tuples, and an empty set of parentheses like this is an empty tuple, hence the name. There are several different ways of declaring the type of a scalar. You saw this style already in the example code. The type here is inferred by the value being assigned. For whole numbers like this, when you don't specify a type, you get i32. That's a 32-bit integer. For real numbers, you get a 64-bit float. For a different kind of type, you specify it typically inline in the declaration. The semantic here, a variable name colon type, is the same as what gets used when declaring function arguments as well, and it's like type hints in Python. Here I'm saying that d is a 64-bit integer that contains the value of 112. Same idea, but with a boolean instead. Don't forget, by default, variables in Rust are immutable. If you wish to change the value in your program, you need to add the mut keyword to the declaration to make it mutable. Instead of using the colon syntax like above, you can use a suffix. I personally find these a little harder to read. This particular example is a unsigned 32-bit integer containing the value 5. So 5u32.
|
|
|
transcript
|
2:13 |
In addition to all those scalars, Rust also supports two compound primitive types. The first is fixed-length arrays. These are denoted by square brackets. These are true arrays, not lists like in Python. When you declare them, you're specifying how much memory gets used to store the contents, and it isn't changeable. Arrays must be made up of values of the same type. The second compound type is tuples. These are also fixed length and are denoted by parentheses. Unlike arrays, they can contain a mix of types. In addition to compound types, there are other kinds of collections as well. They just aren't primitive types. The standard library includes a list-like thing and a dict-like thing, which I'll cover in a later lesson. Rust can infer what is stored in an array and how long it should be by looking at the declaration. Here, I've filled it with three default integers. which you'll remember are i32s. The length of this array will be three because there are three items in it. You can be specific about the type and shape of an array using square brackets in the type declaration. The first value specifies the type of the data in the array, while the second value is the array's length. Again, remember arrays are fixed length primitives. This example is a five-member array containing i64 integers. The fixed part of fixed length means you can't change the size. You can change the values inside of it, though, as long as you continue to respect the data type. You use the mutable keyword to specify the values in an array can change. Arrays can only contain multiple values of the same type. Tuples allow you to mix types. Here, Here I'm using the suffix notation to create a tuple with 6 stored as a u8, true stored as a boolean, and negative 5 stored as a 32-bit float. Tuples can also be mutable, but you have to respect the type that you are replacing. If this were mutable, I could change the middle value to false, but I couldn't change it to a value that wasn't a boolean.
|
|
|
transcript
|
3:16 |
Like with Python, the Rust parser thinks of your code as a series of statements. Rust has two kinds of statements, a variable declaration and an expression. Python also has statements and expressions, but it differentiates between the two a little differently. In Rust, an expression returns a value. Think of when you're using Python's REPL. When you type in an expression, the result gets displayed after the prompt. If you declare a variable in the REPL, the result is none, or an empty prompt, because that expression doesn't return anything. There's no REPL in Rust because everything is compiled. There's tools that kind of mimic it, but that's a whole other thing. A lot of the code you've seen so far has actually been expressions. Sometimes you're not interested in the return value from an expression, and you can suppress the result by putting a semicolon on the end of the line. This is why I was able to be a little fast and loose with my semicolon usage in some of my programs. The return value from some expressions wasn't important in some of the code I've shown you. That said, you really should get in the semicolon habit. You might create weird effects that you weren't otherwise intending. Blocks, denoted by brace brackets, can return values. Not just function blocks, but general ones as well. The value returned is the expression at the bottom of the block. This would be the case where you want to leave the semicolon off. At risk of editorializing, I really hate this. They could have gone with a return statement and made it a lot more clear. Including or forgetting a single bit of punctuation seems like a recipe for errors. The good news is, most of those errors are going to be caught by the compiler, so at least it doesn't turn into a bug. I already mentioned the empty type when talking about primitives. If your block's line of code isn't an expression, or the expression has its value suppressed by a semicolon, then the block returns the empty type. Of course, if your block is a function and the return type of the function isn't empty, you'll get a compile error. A few quick examples to demonstrate what I was talking about. This first line is a function declaration, including the return type of a 32-bit integer. This, of course, is a statement. Well, this is an expression. It isn't a particularly useful expression because the result is being suppressed and not stored. In fact, it will cause the compiler to issue a warning. It will still compile, but it will let you know that you're doing something a little odd. Likewise, with a bare integer. Also compilable. Also not useful. You can declare a variable and populate it from the result of a block. Inside this block, I'm declaring another variable, the inner one. And because the last line of my block doesn't have a semicolon, the resulting 9 is what populates the variable y. This is also how you return something from a function. Any unsuppressed expression at the end of the block is the result for that block. Here, that's the bare value of 42, which of course, by default, is a 32-bit integer. Alright, you've been very patient with all the slides. Let's put all this together into an example and build a little program that gives you multiple values of the Fibonacci sequence.
|
|
|
transcript
|
4:08 |
It's time to loosen the training wheels a bit. I'm skipping over demonstrating cargo new and all that kind of stuff. This is main.rs of my funky fib program, which prints out multiple values from the Fibonacci sequence. Let's start by examining the main function first at the bottom of the file. I begin in main by declaring a fixed array of integers, giving it three values. Remember, because I haven't specified otherwise, these default to i32. These numbers represent the index of the Fibonacci value that I'm looking for, so I'm going to print out the 5th, 8th, and 12th Fibonacci value. I use a for loop to iterate over the array. The syntax here should be pretty familiar, squiggly line on the end notwithstanding. Like in Python, the name here is a variable that will contain each value from the collection, and the second chunk is the collection being iterated upon. For each value, I'm printing out a sentence, but for giggles, I'm adding something you haven't seen before. Remember, brace brackets in a string does variable interpolation, like an f string, but it isn't as complicated and can't generally evaluate. It has to be a single name. If you do want to evaluate something, instead you pass in empty brace brackets. The println macro takes a variable number of arguments, and when you have an empty brace, the value gets replaced by the contents of the corresponding argument to the call. The call to my fib function returns a value. Man, I'm lazy. I couldn't even type more than three letters. And that returned value is what populates the brace bracket placeholder in the string. Alright, that's our main function. Let me scroll back up here and I'll show you fib itself. This is your first function declaration with arguments and a return value. Like with Python, you use comma-separated names for the function arguments. And like with Python type hints, you do colon type to specify the type information. Unlike Python, this isn't a hint. This is a demand. The compiler enforces it. You can't get around it. You specify the type of a return value from the function using the arrow operator. So fib takes a single argument that is an i32 and returns an i32 as well. Inside of the function, I need three mutable variables to do my calculations. And then a for loop. There's two new ideas here for you to learn. Remember when I mentioned that not using a variable would cause a warning? Well, putting an underscore in front of it suppresses that warning. I'm essentially telling Rust that I don't care what goes in num, and since I don't care, I don't even need to name it. I could have just used an underscore here like the common pattern in Python. You can get away with it either way. The next new idea is the double dot operator. This is Rust's equivalent of range. This says to create an iterator that goes from 2 to the value of count. Remember, the fib function returns the nth value of the Fibonacci sequence, whatever the count is. So I have to loop through the Fibonacci values up until whatever count is. I'm starting at 2 because the first two values are 0 and 1, which is why the variables a and b are set the way they are. This function isn't too friendly and has a bug because a count smaller than 2 will return the wrong values. You can come back and fix it after I teach you conditionals. Inside the for block, I calculate the Fibonacci values. And then the last line in the function block is the expression c, which of course evaluates the contents of the variable. Since there's no semicolon on this line, that value is what gets returned from the fib function. Let's try this out. There you go, compiles and runs, giving me the 5th, 8th, and 12th values of the Fibonacci sequence. How's that for funky?
|
|
|
transcript
|
1:40 |
I'm going to do one last example program in this lesson, and it's going to cover two things. Conditionals and third-party libraries. With these two concepts, you can write a lot of basic Rust. As long as you don't need to do any fancier memory management, you're good to play. As you'd expect, Rust supports the usual if-else mechanism. There are a couple differences from Python, though. First, there's no elif. The concept's still there. You just use else and if together. This is again part of that C family heritage and frankly I've never been clear why Python decided it needed a third keyword. It might make the parsing slightly easier because you don't have to scan ahead for pairs of keywords but now I'm just thinking out loud and that benefits no one. The second difference is that the Boolean operators used to combine conditions are the C family style ones. So instead of the words or and and, you've got to use double pipe and double ampersand respectively. If you've coded in languages besides Python, this is probably going to be familiar. I know I said couple, but what's one more between friends? Third, and in my mind less important, is there's no concept of truthiness. Sticking with Rust's strict static typing style, say that three times fast, you have to use a boolean condition in your if-else clauses. The good news about this is you can't accidentally make the mistake where you use bitwise operators instead of booleans. Single pipe instead of double, for example. You can't do this because the result won't be a boolean, and that won't compile.
|
|
|
transcript
|
1:03 |
Rust has a large collection of third-party libraries. In fact, a lot of the stuff you've come to expect in the Python standard library is a third-party library in Rust. For example, I'm going to show you the third-party package for generating a random number shortly. To go with the cargo theme, packages in Rust are known as crates. You can install a crate into your project using the cargo add call, similar to how you would use pip install. There is also a cargo remove to get rid of them. That said, you don't have to use these commands at all. Instead, you can just update your cargo.toml file. When you change the dependencies section, the build command figures this out and installs what needs to be installed. Rust's equivalent of PyPI is crates.io. Visit it to find crates for your projects. Let's take Ferris to the casino and play a little craps. Crustacean, ferrous crustacean. Shaken, not stirred. Good morning, Miss Moneypenny. Yeah, I'm embarrassed too.
|
|
|
transcript
|
5:14 |
This is main.rs in my new project called Craps. If you're not familiar with the game, it's played with two dice. Rolling a 7 or 11 is an instant win. Rolling a 2, 3, or 12 is an instant loss. Anything else is called the point, and you keep rolling trying to hit the point again. If during this second phase you roll a 7, you lose. Our program uses a random number generator to simulate two die, and then rolls as needed to determine if you win or lose. The random number generator is in the rand crate. So here at the top, I'm importing that module with the use keyword. Let me just scroll down to main. This line generates a random number between 1 and 6. It does that using a function from the rand crate. There are a few things going on here that you haven't seen before. Let me break them down. First, this is the rand module. That's not really any different than if you do import math in Python and then said math dot something. This is the part that's different. Rust doesn't use the dot operator for accessing things inside of modules. It uses a set of colons instead. This essentially says access the rand module and look for a function named random_range inside of it. You'll recall that a range is actually a type in Rust denoted with double periods. The random_range function takes a range, which in this case is 1 through 6. You saw one of these in the rocket ship program, but this time I've got an extra equals sign in it. Without the equals, a range is inclusive on the bottom and exclusive on the top, so 1 dot dot 10 gives you 1 through 9. Adding the equal sign makes it inclusive on the top of the range, so 1 dot dot equals 10 gives you 1 through 10. This second line generates a value for our second die, and this one sums the two random values together. This is our first conditional. It checks for the instant win conditions of rolling a 7 or 11. The comparison operators are the same as Python, so this checks if the result is a 7. This is our boolean OR condition, and the second part here checks if the result is an 11 instead. The second conditional checks for our instant lose condition, rolling a 2, 3, or 12. It uses the combined else-if keywords for a second clause. Rust doesn't have Python's in operator, and in fact, if this were real code, I probably wouldn't do it this way, but I wanted to demonstrate containment in a collection. My collection is a static array containing the three losing values, 2, 3, and 12. I then call a method on the array contains, which returns true if the argument is found in the array. And of course the argument I'm looking for is the result roll. Don't worry about the ampersand too much for now. The short version is it passes a pointer to the result variable into the function instead of the value. This has to do with how Rust deals with memory management, and I'll cover that more in a later lesson. If you're a C programmer, it's similar to what you're used to, but with the added value of not needing to free anything. The last part of the conditional is the else block, inside of which I'm calling a function. So the if clause is instant win, the else if clause is instant loss, and everything else means you need to roll again, which I'm handling in the loop_roll function. Let me scroll up to that. My loop_roll function takes one argument, a 32-bit integer, which is what Craps calls the point. This is the target you're attempting to re-roll. At the top, I'm declaring variables for any roles needed inside of the function. And this is the loop keyword. I mentioned it in passing when I showed you the while loop. This is an infinite loop. Not sure why a language needs this when you could just while true, but, well, it's here, so I might as well use it. At the top of the loop, I am rolling the dice again. In point mode, 7 now means you lose. Earlier, I showed you how you use a bare expression without a semicolon to return a value from the bottom of a block. Well, what if you want to return partway through a function? Like Python, Rust has a return keyword. You can even give it an expression if you want to, but my function returns empty, So to shortcut escape, all I need is return on its own. This leads me to wonder why they bothered with the whole expression on the bottom thing, seeing as they had to be able to parse out the return keyword anyway. But, well, I've never designed a language from scratch, so who am I to criticize? Doesn't stop me, but I like to pretend I'm above it. The second part of the conditional checks if the roll was the same value as the target, which is the winning condition. If you get past the conditional, the roll wasn't a 7 or the point, so it's time to roll again, so you head to the top of the loop.
|
|
|
transcript
|
2:26 |
Alright, that's the code. Let's get ready to build it. This is the cargo.toml file. I've added an item to the dependencies section, the rand crate, using version 0.10.0. Like with pip and other tools, Cargo supports the use of characters in version strings to specify ranges of versions, greater thans and that kind of stuff. By default though, if you put a single version number like this, the most recent patch version, that's the last value, gets ignored. So saying 0.10.0 as a spec actually means 0.10.whatever you've got for me. That's kinda handy. Let's build and run this. I shouldn't complain about Rust being fast, but wow that goes by in a blink. Let me scroll up a bit. Okay, let me scroll up a lot. There's a lot more going on this time with our run command because of including the rand crate in cargo.toml. Notice that a bunch of things got downloaded. This is just like pip. Rand itself has dependencies, so all of those have to be downloaded as well. Once you've got all the crates downloaded, then they get compiled. This compilation is into temporary objects which get included in the final executable. That means if you need to compile again, it won't need to rebuild them. Only your code will need recompiling if you've changed it. And the rest down here at the bottom, starting with the line of equals, is the result of our code. I got lucky and won on my first roll. Really, I did hackish video magic things to make it look like I got lucky, if only I could do the same thing with the lottery numbers. Let me run this again. Scrolling back up. This time there was no downloading and no compilation, because the crates were already built and our code hasn't changed. I rolled a 5. Then in my first point roll I got a 7, which is craps. This time I lost. That's much more realistic. One last time for giggles. Here's a more complicated one. I rolled a 10, then went into point mode, rolling a 6, 4, 11, and 12, then finally a 10, which allows me to win. Well, my shooting hand is tired. I think it's time for a break.
|
|
|
transcript
|
2:31 |
You've come a long way and have the basics down for writing simple Rust programs. Let's quickly review what you've learned so far. Rust is a compiled language and outputs a machine language binary used by your operating system to run the program. Rust itself is a strict, statically typed language, meaning you can't be all loosey-goosey with your type declarations like in Python. Like Python, Rust is a member of the C family of languages, so although its syntax is a little different from Python, it isn't totally foreign. The Rust compiler is rustc, but you don't tend to use that directly. The best practice is to use Cargo instead, a uv-like build and package management tool that comes with Rust. You learned most of the basic syntax of Rust, starting with loops, including constructs for while, for each, and infinite loops. To declare a variable in Rust, you use the let keyword. Remember, variables are immutable by default, and you need the mut keyword to be allowed to change them. Rust has a wider range of scalar primitive types than Python because it's specific about the amount of memory a value takes up. So instead of a single integer, you have several different sizes. In addition to the basic primitives, there are also two compound primitive types, statically sized arrays and mixed-use tuples. One thing that's a little different, see I avoided the word weird, is how you get back a value from a block, including functions. In addition to the normal return keyword, you can just use an expression. All expressions return values, so when you write expressions as lines of code, when you don't want a value, you stick a semicolon on the end to suppress it. You just saw a program that uses conditional blocks with the expected if and else. Where it's different from Python is you use if and else together instead of the elif keyword. Conditional operators are C-style, so you don't write out the words or and and, but use double pipe and double ampersand instead. Like with Python, Rust has a rich third-party ecosystem. The packages are known as crates, and once you have them installed, the use keyword is how you import them into the namespace. Submodules and contents of modules are accessed using the double colon operator instead of regular old dot in Python. In the next lesson, I'm going to get a little tangential and talk generally about some computing concepts to lay the groundwork for memory management in Rust.
|
|
|
|
23:24 |
|
|
transcript
|
2:01 |
In the previous lesson, I covered the basics of the Rust language and how to use cargo to build and run your projects. In this lesson, I'm going to step away from coding a little to give you some background on how your computer works so you can better understand why Rust does what it does. Python abstracts away a lot of what it is doing for you. This is a good thing. It gives you less to worry about. It does have consequences, though, one of which is slower run times. For example, Python 3's integer has no upper limit. When you use an integer, Python automatically adjusts the amount of memory it takes up, allowing you to do crazy math. Under the covers, this means it has to increase the amount of memory used as the integer grows. It also means you don't run into problems like overflow adding two very large numbers together. On the downside, all of that takes code, and if all you're doing is adding two very small integers, well, that code is still there. Rust doesn't have some of those niceties. That's one of the reasons it's significantly faster than a lot of other languages. But to better understand what Rust is doing and why you have to jump through certain hoops when coding, it's useful to comprehend a little of what the hardware is actually doing. If you're an old school C programmer and you're comfortable with memory and pointers and machine language, you might want to skip to the next lesson. If you just don't care about that stuff and are happy to take me in my word when I cover the borrow checker, then well, you could skip for that reason as well. I'm not going to cover a specific platform, and in fact, we'll likely be oversimplifying a bunch of stuff to the point of almost being wrong. I'll be giving you enough to have a mental model, and not much more than that. In this lesson, I'll be covering the difference between code compiled to machine language and code compiled to an interpreter, memory mechanisms like the stack and heap, and memory layout and management, like primitive types, allocating memory, and freeing it up afterwards as well. So if you're still with me, great. If not, I'll see you in the next lesson.
|
|
|
transcript
|
6:25 |
Programming languages are higher level abstractions that describe what you want your computer to do. Your CPU doesn't understand C or Python. It has a language all its own. It's comprised of low level instructions that your CPU performs and are often called machine language. This language is processor specific. Most processors implement the same kinds of operations, but how they do it varies by architecture. If you've heard the phrase assembly language, that is almost the same as machine language. Assembly language is a text-based representation of machine language. Without it, you'd have to write machine language as a series of bytes, which I've done a long time ago on a very old machine, and I can tell you, it isn't fun. Some of the operations in machine language work on pieces of data. That data is stored in the CPU in tiny memory-like things called registers. How many registers there are and what kinds of data you can put in them varies between architectures. An oversimplification of programming is a series of machine language instructions that populate registers, then perform operations that modify those registers. Even something simple like adding two numbers typically requires multiple operations. You need to fetch data from memory into your registers, perform the operation, like addition, the result of which usually goes into a register, sometimes a new one, sometimes one of the registers containing the arguments. And then once you've got a result, you then want to transfer that from the register back to somewhere in memory. Let's take a look at a gross oversimplification to give you the idea of how all this works. I've just made up a machine language for the example, but it's similar to languages I've actually used. The first instruction loads the static value 1500 into register A. The second instruction loads data from a memory address, that's that long hex value, and puts its contents into register B. To be clear, that hex value isn't what's going into the register. It's a notation describing where in main memory to find the value that's going to be put in register B. And then this is my actual operation. Notice that it doesn't have arguments. Some simpler processors hard code what registers get used. My example can only add register A and B together and puts the result into A. More complex CPUs allow you to modify which registers to add or may even have operations that work directly with memory addresses. Now that I've added the two registers together, this last instruction is the inverse of the second one. Instead of loading something from memory, this is putting something into memory. Here I'm taking the contents of register A, the result of my addition, and putting it in memory one byte away from the value that populated register B. Coding in assembly is exhausting. Something as simple as adding two variables together takes multiple lines of code. This is why higher level languages were invented. A compiler essentially is a translator, a specialized piece of software that converts text into the machine language equivalent. More complex compilers, including Rusts, even have an intermediary step, compiling first to a processor-agnostic format, then converting that to the actual machine language. This allows you to split the translation into two steps. If someone comes along with a new CPU architecture, you don't have to write a new Rust compiler, just a new converter between the intermediate and final step. All that stuff I just showed you was a text representation of the machine language. What the processor is actually working with is a series of numbers. An executable program is just a chunk of binary that contains exactly those numbers. The binary data in the file is the machine language instructions that the CPU is being asked to run. Your operating system loads the file into memory, then points the CPU at the first instruction and tells it to start executing the code from there. Compilers are both hardware specific and operating system specific. The executable contains the binary machine language, but the file format is operating system specific. I'll see if I can say specific one more time, just, you know, cuz. You may recall that I showed you that the output of HelloFerris was a Mach-O 64-bit executable. That's the format used by macOS, iOS, and NeXT machines. People will loosely refer to Python's compiler. That isn't incorrect, but it isn't the same thing as what I've been talking about. Python is an interpreter. That's a program in its own right that reads a data file containing code, which it then executes. This is kind of the difference between the hardware itself and a virtual machine. The virtual machine is a piece of software that pretends to be hardware. An interpreter is a similar level of abstraction. Think about running formulas inside of a cell in Excel. Excel is acting as the interpreter to the formula language. Some interpreters operate on file directly, while others use a binary intermediary format for efficiency. Python is the latter. When Python goes to run your code, it first compiles it into a format known as bytecode. If you've ever seen a PYC file or a __pycache__ directory containing PYC files, that's the binary bytecode representation of your program. Each time you run Python, it decides if it needs to compile a new set of bytecode files, and that's one of the reasons it is slower to start than some other languages. Bytecode is a platform-agnostic set of instructions for the interpreter which are similar to machine language, but instead of being for your CPU, they're for the interpreter itself. You can actually see what this looks like through the dis module, which allows you to disassemble your Python into a text representation of the bytecode for you. Python does it this way so that your program will run on any machine. You need different versions of the interpreter for each platform, but the bytecode is the same everywhere and can be passed around. That is an oversimplification. There are some corner conditions for optimization, but that's the general idea. So when you hear the phrase ""compiler"" with Python, they're talking about the translator between your code and bytecode. While for a language like Rust, the translation is to machine language, which of course is platform specific.
|
|
|
transcript
|
3:00 |
Computers only know numbers. Even text is a mapping between numbers and what we think of as text. For example, 65 in ASCII is capital A. It is very useful for us to show capital A on the screen because it can be read like a book, but in practice your CPU really only cares about the 65. This is gonna sound like a duh statement, but data takes up space in memory. That letter A takes up one byte. The reason I'm stating the obvious is to make you stop and think just how much is going on in the background when you write code. Your program has to know how much space each piece of data takes up and importantly not have them run into each other. Python does a lot of this for you. Everything in Python is an object and Python goes out of its way to handle the memory management of all of those objects. For example, an integer can be any size in Python. The integer object grows memory as necessary as your number gets bigger. Of course, this comes with a price. Extra code overhead to deal with all of this. Rust, like most compiled languages, takes a different approach. It leaves a lot of this up to you, the programmer. This is why there are so many different kinds of integers and floats. It gives you the choice how much storage you want to use. The cost of this is a constraint and extra work for you, the coder, but the benefit is raw speed and less overhead. Adding two integers in Rust translates to far less machine code than doing the same thing in Python. Even if you're comparing the machine code to the bytecode, it is fewer instructions, let alone if you're comparing it to the fact that the interpreter also needs code to handle the bytecode. Not only do computers only know numbers, they only know binary numbers. Binary numbers are made up of bits, where each bit can only be a 0 or a 1. Everything else you see is a conversion between that and the number format that you're dealing with. A byte is 8 bits grouped together. This hasn't been true for all of history, but it's more or less settled now. The 8 bits in a byte can mean different things depending on context. Take this binary number, for example, which is 195 in decimal. If I stored it in a byte, it might mean 195 if that byte is an unsigned integer. If it's a signed integer, it means negative 61. If it represents ASCII, it's A with a little hat. Hat just sounds much more fun than tilde. And of course, that's just a single byte. If you want to store a sentence, you need a byte for each letter. Or more than that if it's in Unicode, where a single character is stored in four bytes. Even that's an oversimplification. Text is way more complicated than you might think it is. You're probably familiar with hexadecimal. That's numbers in base 16. Hex gets used a lot in programming because it's a convenient shorthand. A single hex character represents 4 bits, so instead of writing out 8 ones and zeros for a byte, you can write 2 characters.
|
|
|
transcript
|
2:38 |
When I introduced Rust's primitives, I briefly described signed versus unsigned integers. I want to delve a little more deeply into that now, as it's connected to the idea that a byte can mean different things, even for something as primitive as an integer. A single 8-bit byte can represent 256 different numbers. That's 2 to the power of 8. 2 being binary, and 8 being the number of bits. So, 256 permutations of those bits. The simplest representation is to store the numbers 0 through 255. This is called unsigned because it's only positive numbers. To store negative numbers, you need the bits to mean something else. One way to do that is to use one of the bits as the negative sign. If the bit is on, the number is negative. If the bit is off, the number is positive. This isn't actually how your computer does it. It used to be done this way, but there's a math trick that can get you one more number in the range. I won't get into that here. It's called two's complement if you want to look it up. Of course, if you're using a bit to represent the sign, that means you can't go all the way up to 255. So a signed integer can store from -128 to +127. Rust gives you the choice of what kind of primitive to use. If you don't need negative numbers, you can choose an unsigned value, and that doubles the maximum value you can store in it. Staying in the 8-bit world, what happens if you add 250 to 250? The result requires more storage than fits in 8 bits. This causes what's called an overflow. What typically happens in your CPU is that the extra bits needed to represent the bigger number get chopped and a flag gets set to indicate that an overflow happened. Your programming language then decides what to do if the flag is set. For my example of 250 plus 250, the resulting 500 takes 9 bits to represent. Chopping the leading bit gives 254. The outcome of an overflow depends on the kind of data. Adding two large positive numbers could result in a negative number, because the leading sign bit might get flipped. Python's integer object deals with all this good stuff for you, dynamically creating more space in memory so the integer can grow indefinitely. But Rust doesn't do that. Depending on the kind of overflow and where it is, this can sometimes be caught by the compiler itself. If the compiler can't catch it and it happens in your program, Rust detects the overflow bit getting set and causes a panic. That's a fancy term for it forces the program to crash. All this means you need to make sure you pick the right kind of primitive to store your numbers.
|
|
|
transcript
|
2:55 |
Let's move on to how all of this gets stored in your computer's memory. You can think of your memory like a wall of mailboxes in an apartment building. Each box has an address, and you can stick something in each box. For bigger things, you might use a series of contiguous boxes for the storage, the address being a reference to where the thing starts. What you're storing determines how many boxes you need. A pointer is a variable containing the address of a memory location. This would be like writing down box number 152 on a post-it note and storing that in box number 27. Box 27 holds the pointer and its contents tell you to look in box 152. When you call a function with arguments, those arguments take up space. There are two common ways of passing those arguments in. There are more, but they're really just variations on what I'm going to talk about. The first way is called pass by value, and that means all of the argument gets passed inline to the function. If I pass in a string that contains a sentence, all of the memory needed for the sentence gets passed in. By contrast, pass by reference, or using pointers, which are a similar idea, only passes the address of the thing. So instead of passing the whole sentence, you pass the address of where that sentence is stored in memory. As you can imagine, pass by reference tends to be more efficient because you don't have to copy large things around to pass them into functions. Consider this pretend sample chunk of memory. Each line a unique address starting at 1000 and stores one byte. These are my mailboxes. Box 1000 is storing a single byte representing an 8-bit integer. Hex 42 translates into 66 decimal. Box 1001 stores the letter T, but it's just the beginning of a string, so lines 1002 through 1004 contain the rest of the word talk. 1005 contains 0, also known as null. This is a sentinel for marking the end of the string. This is how C stores strings in memory. It has all sorts of problems, so newer languages tend to take a different approach, but this was good enough to demonstrate the idea of a continuous block storing text. Box 1006 is the first byte in a two-byte pointer. When you combine 1006 with 1007, you get 3E9. That's 1001. So this is a pointer to the beginning of our text that says talk. Modern computers don't tend to use single-byte locations in memory. They use wider groupings of bytes known as words. A word is typically four or eight bytes wide. They also tend to get cranky if you try to cross word boundaries, so things might not be as simple as what I've just shown you here, but the CPUs of my youth did actually work this way, and I wanted to keep it simple to convey the idea.
|
|
|
transcript
|
2:43 |
The operating system and your program organize memory in two different ways. One is called the stack and the other is the heap. Stacks get used for function calls. This is just a stack data structure, with each function call putting more things on the top of the stack. When a function finishes, its data gets popped off. If you've ever heard the phrase stack trace to describe the output from an exception in Python that crashes your program, that's this stack. It's showing you the pile of functions that led to the place where your code down. Each program gets its own block of memory for the single stack used for execution calls. The heap, on the other hand, is a general memory space used to store the stuff your program needs. Your operating system controls it, and you ask the OS for chunks at a time. To make things more complicated, some programs will ask for more than they need, and then manage the block internally. Python does this with lists. When you create a new list, it doesn't just size it for the original contents. It uses more, so adding something to the list doesn't necessarily incur the cost of asking for more memory. Another added complication is modern operating systems virtualized memory. In the old days, you got a block of the actual RAM, and if you weren't careful, you might affect another program's space. Now, the OS creates a virtual block that is isolated to your program. The total memory available to all of your programs usually exceeds the total RAM on the machine, and the OS is responsible for swapping memory to disk as needed. When coders think about memory, it's easy to simplify it to the space where your data gets stored, but your program lives there too. Some memory is the instructions for the CPU, and some memory is the data. The CPU knows what part of memory to read and execute next by looking at a special instruction register. When you call a function, this register gets changed to the location in memory where the function's code lives. In fact, this is one of the ways hackers can attack a program. They change the resident code to something else, hoping to trick it into doing something it shouldn't. Functions can take arguments, and those arguments also get stored on the stack. You'll recall that pass-by-value arguments have their whole contents passed to the function, which means that same stuff is going on the stack. By contrast, pass-by-reference arguments just need to put the pointer onto the stack. In addition to the function's arguments, the memory location of the calling code is also stuck on the stack. When your function is done, this location gets popped and put into the instruction register, allowing the code to continue from the calling point. If you've heard of the concept of a stack frame, the frame is all the stuff you need to make the function call together.
|
|
|
transcript
|
2:25 |
To store something new, you need a piece of memory. In your program, this will likely be from the heap, but the same concepts work for when the program starts and needs its stack created by the OS. To get the chunk of memory, you ask the operating system for a chunk of a given size. Your compiler and or programming language help with this since they sometimes know the size of the thing you're creating. If you create an array of five 8-bit integers in Rust, you don't have to explicitly ask for five bytes. You just ask for the array, and the compiler does the underlying work of memory allocation, also deciding where the array gets stored on the stack or the heap. Memory isn't infinite, so when you're done with it, you need to free it. Otherwise, you'll run out of space eventually. The problem with that last sentence is the word done. It can be tricky to know when your code is done with an item. Lower level languages like C make you responsible for the allocation and freeing of all memory. You have to be careful though. Freeing the same thing twice causes errors. Also, freeing something and then continuing to use it means you're likely to corrupt your data. When that space in memory gets allocated to something else, you're going to overwrite values. The other complication is you have allocated a specific size. You need to make sure that you don't write more than that. I mentioned earlier that in C, a string has a null value at the end as a sentinel. It is possible to accidentally overwrite that sentinel, resulting in your code thinking the string is a lot longer than it is, which can cause all sorts of problems. Because this is all so hard, newer languages try to help you out. Python's answer to this is garbage collection. You don't worry about allocation at all, and in the background, Python figures out when you're done with an object and frees the memory for you. This has overhead. There is always some code running in the background to clean up after you. Rust has taken a different approach. Instead of managing memory in real time with a garbage collector like Python, it has a concept called a borrow checker. This moves most of the memory management capabilities into the compiler. But the consequence is you have to be very specific which part of your code owns an item. The compiler detects and stops you from doing some of those problematic things that I just mentioned, which avoids many of the memory type bugs that are common in C. In fact, this was the main purpose of this tangent. I wanted you to have enough background in the mechanics of primitive types and memory management that you could appreciate what the borrow checker was doing when I teach it to you.
|
|
|
transcript
|
1:17 |
Let's review our tangent topics. I described two kinds of compilers. Both of them are text-to-binary translators. Python's compiler creates bytecode, an intermediary binary format the interpreter uses, while Rust's compiler generates an executable machine code format that you run directly. Although when you run code you think of text, graphics, and other things, fundamentally everything your computer does is based on binary numbers. The context of the use of such data is what makes it useful, for example using the ASCII table to translate numbers to characters. Python abstracts away the size of its primitives, while Rust is very specific about it. Rust has many primitive types that map to exactly how much space they take up. For example, float64 stores a real number in 8 bytes, which is 64 bits. Your program has two different ways of organizing memory. The first is the stack, which gets used to store data for for functions and their arguments. The second is the heap, which is for more general storage. Managing memory can be complicated and error prone. Modern programming languages have tried to attack this problem in a variety of ways. Python uses a garbage collector, while Rust uses the borrow checker. In the next lesson, I'll head back into coding land and show you some more Rust.
|
|
|
|
44:38 |
|
|
transcript
|
1:33 |
In the previous lesson, I gave you a quick tour of your computer's internals and the basics behind how a program uses memory. In this lesson, I'll be drilling down on Rust's strict typing and how it stops certain kinds of memory errors through ownership. Lots of languages have strict typing, but Rust seems stricter than most. In C, you can get away with casting things to void pointers and back, and although it is strict, you can play fast and loose. Rust locks that stuff down because it is a source of errors in your code. There are ways of getting around this, so your hands aren't tied, but you have to explicitly declare that you're going to write unsafe code, which is beyond the scope of this course. That strictness becomes quite evident when you look at the response from system calls. In Python, if something goes wrong with a file read, it raises an exception. Rust doesn't have exceptions. In C, the function call itself tends to return an indicator, like minus one, for an error. The typical programmer just ignores that and hopes the happy path continues. Rust uses its type system to ensure that you don't just assume the happy path. One of the more common solutions to this in Rust is for system calls to return an enum. The enum typically contains two states, a happy state and an error state, and due to the type strictness, you must deal with it. An enum can be parameterized, so the happy state can then contain the actual response that you want. to access. I'll show you all of this in a bit. For now, I'm going to start with a simple enum so you can see how they work.
|
|
|
transcript
|
1:32 |
Before I show you that code, I want to introduce you to one more concept. Rust includes a C-like switch case mechanism called a match. If you've played with the match statement that got added to Python in 3.10, this is similar. How it works is you declare a match block, matching on an item, then inside of the block is a series of comparison cases. A comparison is tied to a result, either an expression or a block, which of course makes sense because you'll remember from earlier that blocks themselves are expressions in Rust. And since match itself is a block, it can return a value. So whichever case matches the result becomes the result returned, as long as you don't put a semicolon on the end of it. To take this one step further, it's quite common to put a match at the end of a function, so the result of the match becomes the return value for the function. Now back to that type strictness thing. The cases in a match need to be comprehensive. so if you're matching on an enum type, you'll get a compiler error if there isn't a case for each possible value of the enum. This ensures you don't forget something. There is a catch-all default if you want an everything else case, but without it you have to include all the possible choices. Let's revisit the land of random chance and write a program that generates a chance cube. Yes, that is a geeky, esoteric reference to Star Wars. If you're not familiar, chance cubes are dice where three sides are coloured red and three sides are coloured blue. Colorblind friendly, the galaxy far, far away is not.
|
|
|
transcript
|
3:57 |
You're far enough along that I'm skipping steps. I've run cargo new for a project called chancecube. This is its cargo.toml file. Note, as I'm playing with random things, once again, like the craps program, I've added the rand dependency. And this is chancecube's main.rs file. The first part declares an enum using the enum keyword. Logical that is. Okay, I'll stop now. After the keyword, you specify the name of the enum. This is kind of like a class in Python. Using enum has created a new type, the chance cube type. Inside of the enum, you comma separate the possible values for the enum. My first possibility is red and the second is blue. Note that these are not strings. These themselves are types which happen to be part of the chance cube type. I want to hit this home. Since these are types, they have to be adhered to like any other type in Rust, so the strictness applies. Let me move down here a bit. Main begins by generating a random integer between 0 and 1. To be more accurate, I could have generated six sides and then I'd have to do a bunch of extra typing in a moment and lazy I am. Oh yeah, I said I'd stop. This is a bit messy. What I'm doing here is randomly selecting one of the two possible states of the chanceCube enum. Let's break this down piece by piece. The square brackets are an array, so I've created an inline array that contains two values, the first being an instance of the chanceCube red type and the second of the chanceCube blue type. Note the use of double colons here. This is similar to when importing part of a module. Rust uses double colons for this kind of referencing where Python would use dot notation. The second set of square brackets is an index accessor. The value of the index is 0 or 1, so this will access the first or second value of our array. And as index was randomly generated, the result will either be red or blue based on that random number. Two lessons back, I glossed over the use of the ampersand to convert the result into a pointer. The good news is, this is the lesson where I'll explain it in more detail. The bad news is this isn't the part of the lesson where I'm going to do that. For now, just accept it. These aren't the droids you're looking for. When I said I stop, I just meant the mediocre Yoda impression, not the bad Star Wars references. With the random chance cube type in hand, here I'm using match to do something based on which value popped up. The match keyword declares a case block, while its argument is the thing being matched against. In our case, the random enum type generated in the line above. The cases in Rust's match are just a series of expressions separated by commas. The first case checks if the value is red, and then the arrow says what to do if that is true. On the right-hand side of the arrow, you put an expression. It could be a single value, or like what I've done here, a block. Our block will actually return empty, as that's what println! does, but it will trigger the side effect of printing red to the screen. And this is the second possibility, the blue case, where I do something similar. Remember, the Rust compiler will throw an error if not all the choices for the enum are represented in this match block. But there's two, and I've got two, so it'll be fine. Let's give this a go. What's that? Where'd all the info about building go? Ah yes, the lovely -q for quiet. It suppresses all the build output and leaves you just with the result of the program. I like this. I always find all the build info noisy and sometimes have to stare real hard to figure out where my output starts. Let's see if I can get another color. I'll leave it up to you to guess whether that happened randomly or whether I waved my hand over it to get the result I wanted. Ooh, see? Esoteric Phantom Menace reference.
|
|
|
transcript
|
2:31 |
When I first mentioned the idea of enums in Rust, I spoke about the concept of using them as return results. Let's dig into that a little further. One of the more common enums you'll see is the result enum. It is used as a return type from all sorts of calls. The result enum has two possible values, Ok and Err. On their own, that seems a little insufficient, as you want your function to actually return something. Well, a result can do that. Rust supports the concept of generics. That's a type that wraps another type, with the inner type being something determined at compile time. There's a similar concept in Python where you can declare a list containing only certain kinds of types through the type hint mechanism. This is the declaration of Rust's result enum. You don't need to understand all of this to use it, so don't worry if it doesn't sink in on the first try. You've already seen the enum keyword, which creates a new enum type. The pub modifier here specifies that this type is publicly available. So far, your programs have only been in a single file. When you start creating more complicated projects with multiple files, the scope of declarations is private to the file unless you specify otherwise using the pub keyword. This is common in a lot of object-oriented languages. Python is the outlier in deciding it didn't care about this. When I was a Java programmer, I was very intense about making sure to protect coders from themselves so they could only access what they were supposed to, but as I moved more and more to Python, I kind of embraced the idea of letting the coder do whatever they want. I'll warn you with a leading underscore that you shouldn't trust the value, but hey, we're all adults here. The angle brackets declare the generic parameter. By convention, if your generic wraps a single type, you call that type T, T as in template or type, rather than T as in generic. Inside of the enum, you can use the generic type. The Ok type takes the templated type as an argument. This is how you can return a value from a call as well as use the enum. If your call is successful, it returns OK, wrapping the actual return value. If the call isn't successful, you return the ERR type, which takes an ERR type as an argument, which itself includes info about what went wrong. Let's go see this in practice. I'm going to write a quick program that reads from a file and displays its contents. In fact, I'm going to do it two different ways so you can see what is going on and the shortcut that Rust gives you to deal with this common case.
|
|
|
transcript
|
2:09 |
Okay, I'm teasing a little bit. Before I do that, I'm going to introduce a couple more concepts, starting with a little more detail about strings. The texts you've seen so far have actually been the str type. This is an immutable chunk of text known as a string slice. Remember when I punted on the whole ampersand thing? Well, I'm going to punt on this as well. People tend to call the string slice type a string, except when you need to differentiate it from the mutable type. The capital S string type is similar to a string buffer in Python. It is a mutable representation of text. In fact, when you slice a capital S string, what you get back is a string slice type, that's the str, which points at the piece of the capital S string that you sliced. You create a string with the new method. If you've done some C++, this isn't that kind of new. Rust doesn't have an explicit constructor like that. Instead, you typically define methods that act as factories and return new instances. Convention is to name the main factory new. This isn't a compile constraint, but you'll see it everywhere. The new factory returns an empty string instance. The from factory takes a string slice as an argument and creates a capital S string initialized with that content. The push method adds a single character, which is its own type onto the end of the string. The push str method adds a string slice onto the end of the string. And the chars method creates an iterator over the characters that make up the string. This can get a little messy as the string object stores Unicode, so be wary about what is a character versus what's a byte. Which brings me to the len method that returns the number of bytes in the string instance. Note that this may not be the same as the number of characters depending depending on the content of your text. Rust doesn't have an f-string like Python, but it does have the format macro. This works similarly to Python's format function in that it has a little mini-language that allows you to format one or more arguments. Think of it like the println macro, but it puts the content into a string instead of printing it out.
|
|
|
transcript
|
2:21 |
You've seen arrays in Rust, which you'll remember are immutable. Well, like the string counterpart, there is a way of doing mutable sequences as well, the vector. Like with the Rust enum, these are generics. You can dynamically change the length of them, but each item has to be of the same type. This is more of that strictness. There are ways around this, wrapping each item in a general container, then storing that container, but most of the time you want to stick with the type safety. To go with the vector type, there is also the vec macro. It instantiates a new vector from a given array, meaning it takes little extra code to create a vector. And like with arrays, you can access the parts of a vector using the square bracket index notation. I know, I know, my just a few quick concepts is turning out to be longer than the whole enum thing, but most of these translate fairly closely to a Pythonic approach, so I'm trying to squeeze a few things in to make the next example a little more realistic. The standard module has a submodule called env for environment that has a function in it called args which returns an iterator of the command line arguments. This is essentially like Python's sys.argv but as a function instead. Like with sys.argv, the first argument returned is the name of the program. This is a C family in Unix-y thing and is how most programming languages do this. All subsequent values in the args iterator are what you typed on the command line. Like with Python, this isn't really the recommended way of doing things. There are crates out there that handle this better, similar to how you would use argparse or a third-party library instead. But this is good enough for the example I'm going to show you. Like with most iterators, the argument iterator has a method called collect, which groups the values in the iterator together into a collection. You can use this as a way of populating a vector, for example. Last thing, I promise. The fs submodule of standard is the file system module. Lots of good tools in here. I'm going to use the read_to_string function, which takes the name of a file and returns a result enum. The OK value gets populated with the contents of the file as a string. If something goes wrong with reading the file, you get a result error instead. And that, boys and girls, brings me back to what I was trying to do in the first place. Let's finally go look at some code.
|
|
|
transcript
|
2:03 |
I'm going to show you the code in steps, starting with reading arguments from the command line. Of course, to get here you need to run cargo new. I've called this project ASCII ME. No third party crates this time, just the code. I'm using two submodules here, from good old standard. The first is env, which is where you get the command line arguments function, and the second is process, which is where the exit function lives, so that I can close the program early if I don't like what was sent on the command line. This beast is responsible for getting the arguments into a vector. This is the function that actually returns the command line argument iterator, which I then chain to the collect method, which converts it into a collection. And then all of that gets put into a vector type templated on the string type. Since vector is a kind of collection, it knows how to deal with the response from collect. Once I've got the vector, I check its length. Remember, there will always be an argument one, as that's the name of the program. So here I'm checking if there was a single extra argument passed in. It's going to be my file name that I read. If the right number of arguments wasn't passed in, the eprintln! macro is exactly like the println! macro, except it prints to standard error instead of standard out. And calling exit closes the program. In Unix, by convention, an exit value of 0 means success, and anything else is an error. It's kind of the opposite of truthy values in Python. 0 is good, and there's only one kind of good. All the others can map to a variety of error codes that your program can emit. I'm just sticking with one to mean error. If you're running the code and you get here, you've got a vector of command line arguments. This creates a reference to the second value and points the variable filename at it. For now, let's just keep it simple and prove this works. I'll print out the value and filename and that's it. Let's try this out. And there it is with an argument. And there's the error condition when there is no argument.
|
|
|
transcript
|
2:51 |
Okay, now that that is working, let's actually try to read the file. This is my second version of the program. The file reading function is in the fs submodule, so I'm importing that. This function is going to read from a file and print it out. It takes a reference to a string type as its argument. There's that pesky ampersand again. I will get to it, I promise. This is where the real work happens. The read_to_string function attempts to read the file from the given name and it returns a Result enum, which I'm storing in response. This is really what you came here for. In Python, this equivalent function would just return a string and throw an exception if something goes wrong. Rust wants to be particular about response types and makes sure you think about every possible situation where an error can happen and do something about it. So what comes back from this call is that ubiquitous Result enum instead. Then I'm going to match on the response. If it's the Result Ok type, then I'll print the file out. Notice the extra bit here. Match supports binding to values. If the response is the Ok type, when this case triggers, the contents inside of OK get put into the content variable. Python supports something similar if you've played with its match statement. Then inside of the OK case, I print out a message, then print out the entire content string. Remember, when doing a match on an enum, you have to provide for all possible conditions. This case is the other possibility from result, the error type. The error type is also parameterized, but I don't care about the contents. The type checker won't let me ignore it, so I have to put something here. If I just put error, it would compile, but I'd get a warning that error doesn't get used. Putting the underscore in front of it says to the compiler that I don't care that it doesn't get used. Inside this case, I print out the fact that there was a problem. To be consistent, I should have used the eprintln! macro, but oh well, too lazy now. In this version of main, instead of printing out the file name, I call our newly added function. Tired of the Star Wars references from before? Let's try something different. Run, Forrest, run. Let me just scroll back up for a second. Because I passed in an argument, it was the success case, printing out the contents of cargo.toml. Now let's try a failure case. The error condition matched, and there's our message. This pattern of using the result enum is so common that there are a bunch of different shortcuts for handling the situation, so you don't have to use a full-blown match every time. our next version of the program will use one of those shortcuts.
|
|
|
transcript
|
1:57 |
In this version, I've replaced our function called readWithMatch with a new function called readWithExpect. Like before, I have a call to read to string, but with a subtle difference. Look ma, no semicolon. That's because I'm chaining this call with another call. Remember, what comes back from this is the result type. The result type has a method called expect. This method checks the value of result. If it's the Ok type, it returns the wrapped content. If it's the error type, it panics. I know that sounds like I'm being funny, but that is actually what Rust calls it. A panic forces the program to exit. Think of it like the ultimate uncatchable exception. There's actually a macro called panic which you can invoke directly, and under the covers, that's what expect is doing. The string pass to expect gets passed to panic, which gets printed to the screen, along with some other stuff. You'll see in a second that this isn't quite as friendly as our previous version, but it sure is a heck of a lot less code. Down at the bottom of main here, I've switched our old function for our new one instead. A do run run run, a do run run. I expect exactly one of you is old enough to have any idea what I'm talking about there. There's the good case, just to show that I didn't break anything. And there's the panic. Like I said, not quite as friendly a message. The second line of output includes our text. Notice that it is kind of generic. You can't use the brace bracket mechanism to populate the message and embed a value into expect. For more information, you could set the environment variable RUST_BACKTRACE to one, then run the code again. And in that case, you'd see a full stack trace of the problem. That only works if you've built in dev mode. That extra info isn't included when you build with --release.
|
|
|
transcript
|
3:19 |
This is the final version of my code. I've added a function for printing the contents of our file in a special way. This has nothing to do with our result thing. You've seen that already. Here I'm just showing off a few things you can do with strings and making the program a little more useful. The print text function takes a string type which will be the contents of our file. What I'm going to do is build a line to print to the screen. Once it is full, print it, then reset and create a new line. This instantiates a new string type in an empty state. The chars method returns an iterator on the characters on a string. So here I'm going to loop through our file's text one character at a time. Remember that whole lesson on memory? Well, there was a reason for it. Rust will allow you to convert between certain types. A character type represents a 4 byte value in Unicode. 4 bytes is 32 bits. So here, using the as keyword, I'm converting the four bytes representing a character into a single unsigned integer. That's u32 to its friends. This numeric value is known as the code point, and it's the unique identifier for the character in Unicode. The first 255 values of Unicode correspond to the ASCII table. Code point values greater than or equal to 33 and less than or equal to 126 are printable ASCII characters. There are some that are higher as well, but I'm keeping it simple here. First, I'm checking if it's a printable ASCII value, and if it is, I add it to the line buffer. If not, I need to convert it into its hex representation before doing anything with it. The format call returns a string object, which I then append to our line buffer. The first argument here specifies how the code point value is to be formatted. The 0x is just my own text, while the braces say to format the next argument to the function as a zero prefixed two-digit hexadecimal value. The second argument is what I'm formatting, our code point value. So if ASCII printable, you'll see the character. If not printable, you'll see the hexadecimal representation of the code point. To make it readable, I'm putting spaces between each piece of our output. And finally, I want our output to fit on a terminal, so I'm checking if the current line buffer is longer than 75. You have to be careful with this as the length method counts bytes, not characters, but I'm only ever putting plain ASCII into the buffer. Remember, I'm formatting the non-ASCII as hexadecimal ASCII characters, so I can naively consider bytes and characters the same thing in this case. Once I've hit the buffer limit, I print out the result, then create a new line buffer. The loop then keeps going until all the text has been exhausted. Finally, at the bottom, I print out whatever is left in the line buffer. The final change is to replace the vanilla printout with our new fancier function inside of read with expect. And there you go, a somewhat hard to read version of the TOML file. Notice the hex A after the package header. That's a new line. ASCII-X20 is the space character, which is the other code point you see in here.
|
|
|
transcript
|
2:01 |
There are a whole bunch of ways of causing panics when an error condition happens. You saw me use expect in the previous code. Let me briefly cover a few alternatives you can choose from. The unwrap call is like expect but without the error message. If your function works, it unwraps the value associated with okay and returns it. Otherwise, it panics. You'll get a similar output to expect just without the custom error message. Say you don't want to panic but want to keep going. Well, unwrap_or_default is one way of doing that. Note that this call doesn't take an argument, so when it says default, it means the default value for the thing being created. That will be zero for most numbers, empty strings for strings, etc. In case typing unwrap is too much, even that shortcut has been shortened. You can put a question mark after a result type and it either returns the unwrapped value in OK or panics. There is a subtle difference between this and unwrap having to do with how the error and the panic is handled, but it still panics and for early on in your journey, that's good enough. One of the problems with expect is it doesn't allow you to format your error message. You can only give it a static string slice. The unwrap_or_else gives you much more control. This is a bit past what this course covers, but Rust supports closures which are similar to lambdas in Python. They're denoted by a pair of pipe operators. So what happens here is my call to do something, or whatever else I've put there, gets converted to a closure. This could be a block, of course. Since it is a closure, it doesn't get called unless the error happens. This can be very efficient. For example, you would only pay the cost of calling format on an error message to pass to the panic call when the error has been triggered. All of these things still cause panics, though, which means you're getting that extra output given to the user. One of the more popular crates out there is called Anyhow, and it augments the panic handling and gives much cleaner output. Worth checking out as your Rust coding progresses.
|
|
|
transcript
|
3:57 |
The time has finally arrived. I can't speak to whether you've been waiting for it or not, but I've definitely been putting it off. The last core concept of Rust you should understand is ownership. Ownership is what governs which variables point to which bits of memory. All languages have a similar concept, but Rust is rather specific and strict about it. Quite honestly, this can be a bit painful. There's a joke in the Rust community that you don't need to debug your code because the challenge is getting it to compile. If you can do that, it probably works. Ownership is probably one of the harder concepts to learn for someone coming from Python. In Python, everything is an object and all your variables are actually just references to those objects. Need two variables to point to the same thing? No problem. The garbage collector figures out when an object is no longer being used and cleans up after you. There is an expense that comes with that though. Running a garbage collector takes time and memory. Also, having multiple references to an object can cause bugs. Did you mean to copy it or did you mean to have two variables point to the same thing? Rust handles this stuff differently and it can take a little getting used to. Here are the core ideas behind ownership. In the lesson on computing internals, I talked about the two types of memory a program gets. First, the stack which is used for local allocations and function calls. That traceback you see when something blows up in Python is the calling part of that same stack. And second, the heap, the general purpose memory area. As a lower level language, Rust requires you to be explicit about this. Pretty much everything you've seen me allocate so far has been on the stack, as it's been local to a function. When dealing with larger chunks of data, you don't want to put it on the stack, and you definitely don't want to pass copies along. Rust has explicit types for heap allocation, which you can use instead. I already mentioned that Rust doesn't do garbage collection, so just how does it know when to free an object? Well, it all has to do with scope. In the simplest case, which is what you've seen so far, when something is declared in a function and only used in that function, when the function finishes, the stack gets popped and the memory gets freed. That simple case can get you a long way, but it doesn't solve all situations. Sometimes you want to allocate something in a function and pass it back, meaning the allocated object outlives the function that created it. To do this, you need a reference to the object and you can pass the reference back. When a reference gets returned, the ownership of it is transferred to the new holder and Rust waits until the new holder goes out of scope to deallocate the object. There are actually two different things going on under the hood, and you've seen examples of both, whether you realized it at the time or not. Some kinds of objects get copied, and other kinds of objects get their ownership moved. Rust has a concept called a trait, which is beyond the scope of this course, but you can think of it like a definition of behaviors for a type. If your object supports the copy trait, then when you assign it to a new variable, that new variable gets a copy of the object. Without the copy trait, a move happens. Like its name implies, the move changes the ownership. If that happens, the original owner's reference is no longer valid, and if you try to use it, you'll get a compile error. And that is the hard part. There can only be one owner of a thing at a time. All the challenging things about dealing with ownership come down to this. In fact, when you pass references around, it gets referred to as borrowing the object. And like an object in the real world, only one person can hold it at a time. When you hear references to the borrow checker in Rust, that's the part of the compiler that figures out who has borrowed what, and ensures only one entity owns an object at a time. The challenge with teaching this stuff is that the best examples include code that doesn't compile. So for the next little bit, I'm going to show you some examples in slides that hopefully make the concept clear.
|
|
|
transcript
|
2:21 |
I'm going to start out with an example that shows the difference between copying and moving. Consider one of the first things I showed you, creating a variable that contains an integer. This allocation is happening on the stack. I don't have a surrounding block here just to keep the example less cluttered, but this normally would be inside of a function and the allocation belongs to the part of the stack used for that function call. When I create a variable called y and reference x, this is basically doing the exact same thing. y gets created on the stack and initialized to an integer. Just this time, the integer is a copy of the one stored in x. An instance of a string type isn't quite as simple, though. The s1 variable is still created on the stack, but it is actually a pointer to a string type object. The data, the letters hello in this case, gets allocated on the heap. Creating s2 is different from creating y. The copy mechanic that works on integers doesn't work on string types, so what is happening instead is that s2 is now pointing to the data. The reference has been moved from s1 to s2. This invalidates s1. Only one reference can own it at a time. So if I try to use it, even in something as simple as a print, I get a compile error. So sometimes it's a copy and sometimes it's a move. How can you know the difference? Most things are move only. In fact, it is typically only the base data types that are copyable. That includes all of the varying sizes of integers and floats, booleans, and character types. Remember, there's a difference in Rust between strings, string slices, and characters. The copying is only implemented on characters. That kind of makes sense as there are statically sized things, which you saw earlier when I cast one to an unsigned 32-bit integer. Tuples can be copied if what they contain is copyable. So a tuple with an integer and a float is fine, but a tuple with a boolean and a string is not, because the string is not copyable. Rust provides a mechanism for making your own types copyable. It is called the copy trait. In fact, that's what's happening with this list. The types on this screen implement this trait. As I mentioned before, traits are beyond the scope of this course. Just know that they're a way of defining an interface on objects, sort of like an abstract base class in Python.
|
|
|
transcript
|
3:30 |
When you're done with an item in Rust, it goes through a process called dropping. This is kind of like a destructor in languages like C++. One reason something gets dropped is because it goes out of scope. In our examples with data on the stack, the data gets dropped when the function is done and the stack unwinds. There are other things that can cause a value to be dropped as well though. Once more, this is a pointer on the stack to a string type on the heap. When you change the pointer to point to a new object, the old object gets dropped. So at this line, our hello string will get freed up, and s will point to the new world string. Some types implement a clone method for copying an object. This isn't like the copy trait, as it isn't implicit, you have to explicitly call it. Like with new, this isn't a language feature, but a convention done by many types. starting with my usual s1, and this call creates a new piece of data. The clone method on s1 returns a brand new object containing a copy of the original. This then gets assigned to s2. Nothing got moved here, so you can legally use both of them as they're individual things that just happen to currently contain duplicate content. Let's look at some more examples of moving ownership, this time between functions. On the screen here, I have a main function which calls the takeOwnership function. Let's step through what happens. When takeOwnership is called, the ownership of s is passed into the function. Inside of that function, you can do things with what was s and is now text, but the ownership is now tied to the scope of this function. It got moved. So when this function returns, text gets dropped, which means any use of s after the function call results in a compile error. The ownership got moved. If I hand you a balloon and you decide to pop it, I can't then use that same balloon. You've seen factory methods on types like new and from. Those return new objects. This works because a function can pass ownership back to the caller. Let's look at how that happens. My gives ownership function is declared to return a string type. Inside of the function, I create a new string, then return it from the function. Remember, no semicolon means the expression is what gets returned. I really don't like that part of Rust and can't get used to it, but that's what it is. My grumbling notwithstanding, ownership here is transferred back to the caller, which means it gets returned into s and therefore can be used here. Like any other variable in a block, it goes out of scope at the end of the block and gets dropped. Obviously with main it will go away because the program ends, but it would get dropped at this point if it was some other function as well. You can combine the giving and taking concepts into one function as long as you're careful about what you're replacing. The call to takes and gives back passes the ownership of s1 into the function. Inside of the function, you can then do something with it as it's owned. When you're done, at the end of the function, you can return the instance which passes ownership back to the caller. Which, in this case, puts the object into s2. Of course, that means s1 is no longer valid, so this print would cause a compile error. But this one would be allowed as the object's ownership was transferred to s2 upon the function's return.
|
|
|
transcript
|
5:00 |
Passing ownership back and forth can be a pain in the butt. Often you want a function to perform an operation on your object, to visit it, but not to take ownership. You do that with a reference. And that, finally, boys and girls, is what the ampersand does. The couple of times that I sprinkled it, like so much oregano in my code, was because ownership transfer wasn't allowed for what I was doing in those cases. By prefixing an ampersand on a value, I can pass around a reference, meaning the ownership stays in place. Like with regular variables in Rust, references are immutable by default. That doesn't mean you can't change the reference, that means you aren't allowed to make changes to the thing that is being referenced. You'll see this in a minute, but for example, you can call length on a string reference, but you can't call push, because that changes it. Of course, they wouldn't be much use if they were only immutable, so you can declare mutable kinds. You just have to be explicit about it. It doesn't get used much, but the star operator dereferences the item. so if you have a reference and need to get at the original object, you can use star. If you're coming from the world of C, Rust doesn't have or need an arrow operator. You call methods on reference objects with a dot, just like a regular value. This is why the star isn't used very much. Rust is intelligently figuring out which thing you mean as it goes along. If you're not coming from the C world, I apologize for that little babbling that sounded like Latin. Ampersand good. Star good, but not used much. K? Let's look at an example of passing a reference around instead of transferring ownership. This code is silly. I've built a function that returns the length of a string, which of course is just a method on the string, but it demonstrates references, so let's embrace the silly. The argument I'm passing to getLength is a reference to our string type. As you might expect, since the function needs to take a reference rather than a type, you need to modify the signature saying that it takes a reference. You do that with the ampersand as well. Inside of the function, you can call methods on the string type reference and ownership hasn't been transferred, so it's okay for text to get dropped. It's just a reference, not the object itself. You'll note that in the previous example, I called the len method on the reference, which doesn't attempt to modify the object. That's good, because doing so would have caused a compilation error. By default, references are immutable, but you can change that with the mut keyword. Passing a mutable reference is similar to a regular one, just with ampersand mute instead of ampersand on its own. Likewise, the function signature needs to declare that it is expecting a mutable reference. Inside of the function, I can now call methods that modify the object, like push_str, which appends a new bit of text on our object. I find the whole mutable, immutable thing kind of overkill, but like I mentioned before, my years in Python have made me kind of laissez-faire about the whole protect the programmer thing. The idea here is to be explicit about what you want changed. I suspect this leads to lazy thinking and just making everything mutable, but that's my cynicism popping out. You can only have one owner at a time, and if you transfer ownership, the original variable gets dropped. The same isn't true for references. Nothing new here. This is a reference to our string type. And this is a second one. This print is allowed because both references are allowed. The use of the asterisks here is actually not necessary. Rust is smart enough to dereference the pointer in most cases. The only time you really need the star operator is when you're explicitly trying to visit something. Like trying to move the thing being referenced to. This would be the Latin stuff that I was talking to C programmers about. In C, you have to know exactly when to use this and when not, or its equivalent arrow operator. Rust just takes care of it for you. It isn't all ambrosia though. Here, I've taken a mutable reference to the string type. This is allowed because the previous two references were immutable, but this would cause a compilation error. The whole idea behind ownership is so that Rust can track when something should be dropped. Multiple mutable references would mean change could happen in multiple places and ownership would become unclear, so it's not allowed. A dangling pointer, or reference, is one which points to something that has since been freed. As you might imagine, they're dangerous as nobody knows just what they point to anymore. The space and memory that they point to might have been reallocated to something else. Rust disallows dangling pointers. Let me show you what that looks like. My dangle function returns a reference to a string. That in itself is okay. Here, I'm allocating a new string type which will have a lifetime of inside of this function. So at the bottom, when I try to return a reference to the object, I have a problem. The object is going to get dropped when the function returns, so the reference is now dangling. Rust detects this, and a compile error results.
|
|
|
transcript
|
1:19 |
Ownership, the borrow checker, and pointers take some getting used to if you only have coded in languages like Python that abstract away these problems. It pretty much just takes practice. I learned C in high school, and I distinctly remember a lesson where my teacher was trying to explain the ampersand and star operators, and we could tell he didn't quite get it. He actually said, if it doesn't work one way, just try the other one. A few days later, he was talking about something else that needed pointers, and the penny dropped for him. He actually openly said to the class that he finally got it. All rather honest of him. Don't worry if your own penny takes a while. You'll get there with practice. When you get it wrong, you'll get compile errors. The good news is the Rust compiler gives pretty good errors. The language of the errors can be a little hard to wrap your head around, but now that you understand ownership and borrow references, it should make sense. A lot of the time, the compiler not only gives an error, but also gives a hint, telling you maybe you need a reference where you have an instance. This is an example error. I put the previous attempt at two mutable references into a little snippet and compiled it. You can see here that it tells you you can't borrow s as mutable more than once. It points you both at the first and second reference and even tells you where you attempted to use it. That's pretty comprehensive.
|
|
|
transcript
|
2:17 |
This lesson covered some of the core Rust concepts that make it different from other languages, including some approaches that are quite different from Python. Although Python has an enum, it's almost an afterthought. In fact, it was a fairly late addition to the language. Rust not only has an enum, but it loves them. The result enum is used everywhere to indicate success or failure of a function call. The result type has two subtypes, Ok and Err. The Ok type is a generic and when instantiated wraps the data you would otherwise return from your function. Another more recent addition to Python is the match keyword, which is a spiritual descendant of the switch case statement in C. Rust has one of these as well, and they're commonly used to differentiate between wrapped responses from function calls. It's a little cleaner than an if-else block, and as it's a self-contained block, it can return a value. Evaluating a result enum response is so common that Rust has several mechanisms built in to avoid a lot of match-based boilerplate. The expect and unwrap calls return the wrapped value from an Ok type or cause a panic error to happen. These got used so much that even that was too much typing, so Rust introduced the question mark operator to shorten it even more. The use of enum types as responses is technically possible in Python, it's just not how it's done. This next bit is a stark difference though. Rust uses data ownership to decide when to free memory once you are done with it. Rust is very picky about data ownership. When you create a variable, the compiler tracks who owns that variable and ensures there's only ever one owner at a time. Passing ownership around can be a bit painful, so instead you can borrow ownership by taking a reference to an object. References can be passed around in your code and stop you from needing to pass ownership back from within functions. When you get a reference, the object you point to is immutable by default. You can explicitly declare a mutable reference, but then, like with ownership, you can only have one of those at a time. You've come a long way, and now have a decent grounding in the Rust language. There's plenty more, but it's time to embrace the Python experience. In the next lesson, I'll show you how to integrate Rust code with a Python program.
|
|
|
|
34:18 |
|
|
transcript
|
3:17 |
In the previous lesson, I showed you Rust's preferred mechanism for error handling and introduced you to ownership and the borrow checker. This lesson is our ultimate goal, integrating Rust and Python. Python was designed from the get-go to integrate with compiled languages. In fact, parts of the standard library are written in C. I don't mean the interpreter, which is written in C as well, but a selection of the Python modules and functions that you use aren't written in Python. This is done for performance. For example, if you hunt down the Python installation on your system, within it there is a lib directory. That lib directory has all sorts of.py files, which are modules in the standard library. If you look closely, though, you'll find some of them are missing. You won't find math.py because there isn't one. To keep things snappy, or as snappy as Python gets, all the math code is written in C. So pretty much from day one, Python has been able to integrate with lower level languages. This also leads us to our de facto answer to the question, but isn't Python too slow? For important things, like heavy numerics, Python isn't that slow because smart people have written fast third-party libraries in lower level languages and integrated them into Python. Perennial favorites like NumPy and Pandas are exactly My favorite data frame library, Polars, is written this way, and it's even built in Rust. You'll find Rust throughout the Python ecosystem, and there are three ways you'll encounter it. First, as tools written in Rust. These often don't have any Python bits incorporated, they're just tools that work with Python code that are written in Rust. No integration is necessary in this case. The second case, which is what this lesson focuses on, is creating an extension module in Rust that can be called in Python. This is how libraries like Polars work. And the third way is integrating Python code into Rust. The same library you're going to use to call Rust functions from Python can also be used to integrate the Python interpreter into a Rust program. I don't know if this is how it's done, but if you were writing a linter, one way you could do it would be to use Python's AST library to parse the Python, and then do the fast checking steps in Rust itself. Alright, if you want to create a Rust crate that can be called as a Python module, you'll need a couple of tools to get that going. First is PyO3, a Rust crate that is the compatibility layer between the two languages. Rust and Python have their own types, and one of the things PyO3 does is translate between the two concepts. In fact, it declares types in Rust for many of the common concepts in Python, including the data types, exceptions, and more. The other thing PyO3 does is create the calling hooks. When you write a function or class in Rust, PyO3 wires it up as an entry point for the Python extension interface, giving you the ability to call it like it were native. Technically, PyO3 is all you need, but then you'd have to go and get all fiddly with the build process. There are several tools out there that help you manage and build your Rust components as Python libraries. The one that PyO3 recommends is Maturin. Think of like cargo but for building Python-compatible crates.
|
|
|
transcript
|
2:34 |
Remember in the last lesson when I kind of implied you had enough knowledge of Rust to build a crate for Python? Well, it was more of an almost. I need to introduce you to a couple quick things before I get started. Up until now, you've been using main.rs as the source file for your projects. This is where the Rust compiler looks for the entry point to your code when you run a program. That's not the only way to build things, though. If you're not building a program, but a library, instead of using main.rs, you use lib.rs. This isn't specific to integrating with Python. If you wanted to build a Rust crate with utility code for use in other programs, you would use this structure as well. Not only has all the code been in main.rs, but it has also been in the global namespace. Rust has a way of grouping things in modules. But unlike Python, it isn't linked to the file, but tied to the contents of a block. You declare a module with the mod keyword, which means you can actually have multiple modules in a file. I kind of prefer Python's way of doing it. I'm likely only going to put one module in a file anyway to keep my code organized, but this is how it's done in Rust. The last new thing I need to introduce is procedural macros. These are controls you put in your code that affect how compilation works. They're similar to #define statements in C, if that's familiar to you. They get used for all sorts of things, including automatically generating code for classes like extra debug methods. The format of a macro is kinda ugly if you ask me. It starts with a hash, and then the name of the macro being called goes inside of square brackets. There are also ways of parametrizing them, passing in arguments if need be. PyO3 declares several of these macros, one for each kind of entry point you need to map. Modules, functions, and classes. Before I show you how this works, make sure you're set up to code along. PyO3 supports Python 3.7 and above. You're going to be installing third-party Python libraries to manage your code, so use whatever your favorite tool is to create a virtual environment. I'm old school and still use virtualenv, but I hear all the new kids grunting the word uv. You do you. Once you've got the virtual environment set up, you need to pip install, or your equivalent, the Maturin Python library. The code examples here were tested with version 1.13.3, which was the latest when I recorded. If you've got all that going, you're ready to use the maturin command to create your project. Let me show you how that's done.
|
|
|
transcript
|
1:31 |
You know how in Python you can use the plus sign to concatenate strings together? Have you ever accidentally tried to add a string in an integer? There be exceptions. I'm going to start out by writing a little library that lets you add arbitrary things to strings, converting them first. This is going to be a bit of a journey, but the first step is creating a directory put all this stuff in. Now that I'm in my new directory, I'll use the maturin init command to initialize a new project. There's more than one way to skin a cat. What barbaric person came up with that expression? Poor little kitties. And PyO3 isn't the only way of integrating Rust with Python. Maturin supports four different integration mechanisms, so when you call init, it prompts you, asking you which library you want to use. PyO3 is what I'll be using and it's the first choice so all I have to do is hit enter. And once I have, Maturin tells me it's all done. Let's take a look at what it did. It created two toml files and a directory. Since this project is going to include both Rust and Python, it kind of makes sense to find configuration files for both kinds of code. Like cargo new, Maturin also creates a sample Rust file, which as I mentioned is lib.rs rather than main.rs because you're building a library rather than a program. Let's take a look at these three files.
|
|
|
transcript
|
5:14 |
This is the cargo.toml file. The package section is similar to what you've seen so far, but there is some new stuff in here as well. When building a library, you need a lib section to specify what you're building. The name of the library specifies the entry point. This is like in Python, where the name of the third-party library and the module that you import from it don't have to be the same thing. This cryptic little bit says what to actually build. Something The cdylib specifier says to build a shared dynamic library for your platform. So on Linux, you'll get a.so file. On macOS, a .dylib or .so, depending on your versions. On Windows, you'll get a DLL. Whatever your platform, it'll be a shared dynamic library for that platform. At the bottom of the Cargo.toml file, I've included the PyO3 crate, which is what you'll be using to talk to Python. This is the pyproject.toml file. The build section says what to use to build the project. You're probably used to things like setup tools and wheel in here, but in our case, it is Maturin. Our project name is the same as the crate, and our spec includes information on the languages involved. Then finally, it uses the dynamic attribute to specify the version information is determined by the compiler. This is lib.rs with a sample module and function inside of it generated by Maturin. What this code does is create a function in a module that can be loaded by Python that adds two integers together and returns the result as a string. Let's go over it bit by bit. First off, you have to import the stuff you need from the PyO3 crate. Up until now, I've always imported the module, then referenced its contents. The use of the star here means to import everything in the module into the namespace. This is generally not considered good practice, but it saves a bunch of typing, so people do it. This is our first procedural macro. The PyModule macro tells the compiler to make this Rust module available as a Python module. The macro is one of the things that got imported as part of the use star above. What the macro is decorating is an actual Rust module. you declare a Rust module with the mod keyword, and then give it a name. Note that this has to be the same name as was specified in the lib section of cargo.toml. That's what tells the build system to make this module accessible in the resulting library. Rust's namespacing is kind of finicky. The call to use on the first line of this file imports things at the file level, and you needed it for the pymodule macro. Modules get their own namespacing though, So you need to call use again to get at the two other PyO3 things needed here. Like with the PyModule macro, the PyFunction macro ties a Rust thing to a Python thing, in this case, a function. Using this macro means that the Rust function that it wraps will be available as a Python equivalent. And this is the actual function. All told, it's just a regular Rust function. Depending on what you're doing with Python, the types involved might be different, but otherwise it's a function like any other. This function takes two integers. Recall that the usize type is an integer that is the size of a pointer on your platform, so likely 32 or 64 bits depending on your architecture. I'm not sure why they chose this in the default example. In fact, I would argue it's a bad idea. I don't like using usize for non-pointery things. It just adds confusion to the mix. One of the things that PyO3 does is convert signatures. Touch more on this later, but you can either use a Rust built-in type like the sample does here and have PyO3 convert it to a Python integer automatically, or PyO3 also provides types like PyInt and PyString that allow you to be specific about it. There are pros and cons to choosing each, which I'll talk about more in the future. Rust likes using the result enum as a return value. PyO3 continues this tradition with its own type, PyResult. PyResult has an Ok and Err subtype. What's different here is what PyO3 does with it. If you return PyResult with an Err subtype, the wrapper turns that into an exception on the Python side. So the function itself returns an Ok type. Note the lack of a semicolon here. I still can't get used to that. What Ok wraps is the addition of our two arguments, and then it calls to_string on the result doing a conversion. This particular code can't actually generate an error type, so no matter what, the py result will be the Ok type. If you were doing something fancier though, PyO3 would cause a Python exception to happen for you. Remember, this is the example code that Maturin generated when I created the new project. As a result, I'm not going to build this library. It's just a sample. Now that you've seen the idea, let's start building our own actual function.
|
|
|
transcript
|
2:37 |
Ultimately, our goal is a function that converts a variety of Python objects to strings and concatenates them together. But to start with, let's do something very similar to what was in the Maturin sample code: a function that takes two arguments, one a string, and the other an int, and combines them. In Python, the function signature will look like this. I don't usually get all crazy with the type annotations for something this simple, but I've done it here to be clear as to what the types are in Python. A string and an int going in, and a string coming back out. Once the Rust code has been written, you use Maturin to build it and install it into the local virtual environment. Let's go give this a shot. This is my new version of lib.rs. You can tell it's mine, as it starts with the comment that tells you where you are. My anal retention is a signature all in itself. That notwithstanding, the rest of the top of the file is the same as the the default, declaring and using the same module name. It needs to correspond to the declaration in cargo.toml. And then the module has a function in it with the registration macro wrapping it. This is the first real change. I've started the implementation of my promised add function. For this version, I'm using the Rust String type and a 32-bit integer. I told you that usize thing bugged me. Like before, this function is returning a PyResult type. I don't want to modify being sent in, so I'm creating a new empty string. I'm not even sure what attempting to modify the argument directly would do. Python strings are immutable, while the Rust type I'm converting to isn't. If this was pure Rust, the ownership here would be messy, but PyO3 takes care of it, so I don't have to use a reference. I probably should have for pureness sake, but hey, this is what it is, and it works. Here, I'm appending the first argument to our new empty string. Previously, when you saw me use this, I was using a string slice, which is actually what the argument to push_str takes. Here, because I'm using a string object, I pass in a reference. Doing so is equivalent to slicing the whole thing, so it lets me do it, which is convenient because it saves me from having to convert the type. The second verse is similar to the first. Once more, I'm appending to our result, but this time I'm converting our u32 with the to_string call before doing so. Since to_string returns a string type, I need a reference to that as well here. Then finally, my semicolonless endline returns the Ok type wrapping our result. That's the code. Now off to the terminal to build it.
|
|
|
transcript
|
2:31 |
I'll start by calling cargo build to make sure that our library compiles. You don't actually have to do this step as the next step will do it for you, but cargo build is way faster than the maturing command. So if, like me, your first attempt doesn't work because you forgot an ampersand, well your edit it then compile it again loop goes faster if you use cargo first. Now I'll call the maturin develop command. This builds and installs the result in the virtual environment. Let me scroll this up a bit. There's a lot here. Notice all those compilation steps? That's all the build dependencies. Our Cargo.toml only mentions PyO3, but PyO3 has its own dependencies. When I ran cargo build, it was only compiling the library code itself, not linking it into the resulting.so or dll. To link it into the end result, you need all the other things that PyO3 is dependent upon, which is all the stuff that's getting compiled here. And that's it. Your new library has been installed in the Python virtual environment. Let's try it out in the REPL. The library declares a module containing our add function. Let me import it. And now give it a try. I'm not sure whether to be excited or bored. Yes, the function isn't super fancy, but on the other hand, you've built some rust and are calling it from Python, which is worthy of celebration. Let me just exit the REPL. You've seen all this work, but let's just prove it's happening because of a binary file. There are now two string add directories in the site packages area of my virtual environment. The first directory is stringadd, which is where Python looks when you import the module. Inside of that directory, there is a __init__.py file, the dunder pycache compilation directory, and more importantly, the.so file that is the result of compiling our Rust code. Notice the naming structure. It has the name of the module, then the compilation interpreter target, then the architecture. Darwin is the name of the BSD port that macOS is built upon, And it is dynamically loaded, so it's a.so file. The second directory listed here is the meta information associated with the distribution. If you built a package for PyPI, this would be included in the wheel that got built for it.
|
|
|
transcript
|
4:19 |
Maturin has a bunch of commands. Let me just quickly go over some of the more useful ones. You already saw me use the develop command. This is the one that builds your.so file and installs it into the virtual environment. You use this to build and debug your stuff. If you're building something for your own use, this is all you need. If you're building for packaging to PyPI, then you'll need other commands as well. The other command you saw me use was init. That's what creates the sample files for your project. The init command is for when you already have a directory where you would put these things. Whereas the new command is like its cargo equivalent in that it creates the project directory for you before doing the init on the inside. If you don't want to install the crate into the virtual environment, you use the build command instead of develop. If you've done the development step and are ready to distribute, this would be the command you might use to create the production release. This typically gets called as part of a continuous integration pipeline. The result of the build command is a Python wheel file. The publish command is what you use to build and upload that same wheel up to PyPI, while the upload command just does the uploading step. Maturin has other commands as well, but these are the ones you're most likely to use. The project I'm showing you only has Rust code in it, but depending on what you're building, you might have both Python and Rust. Unfortunately, the project structures for both of those like to put their code in a source directory. Rather than mix your code, Maturin has a couple of different directory structures it knows how to work with. When you run the init or new command, you can pass the --mixed flag to get a structure that uses source for Rust and a Python directory for your Python code. It also properly populates the pyproject.toml file so that the tools know where to look for things. The downside of this is it is a Maturin-specific configuration. Other Python tools can get picky about there being a source directory or not. Most are configurable though, you just might need to add more configuration to pyproject.toml to get it to work. There's one other layout that Maturin supports. In that one, you put your Python in a source directory, then have your Rust in a directory named Rust. The Cargo.toml and the Rust source go inside of it. The --source flag gets you started with this, but doesn't fill in the whole thing, so take a look at the maturin docs if this is your preferred approach. If you want to share your code on PyPI, you're going to need to go through extra steps. A full example of this is beyond the scope of this course, but I will take a moment to highlight a few things. Like with any Python library, you need to create a wheel file. That file will include the compiled.so file that you just saw me create locally. And this is why Python is considered very portable. The language works anywhere there is an interpreter. A Rust binary is platform-specific though, which means if you're shipping code that includes a compiled Rust result, you'll need a wheel for each platform you want to support. For example, when you pip install Polars, you'll notice that two wheels get downloaded. The first is the Python wrapper, and the platform part of the wheel name is Any because it works with Any Python. Through tricks and black magic, when you install, another wheel will get fetched as well. This one is a platform-specific compiled library. On my Mac, I get a wheel called Polars Runtime 32 CP310ABI3 macOSX ARM64. Yep, that's a mouthful, and I even skip parts of it. Essentially, it's a wheel specific to Python 310 or above for the 64-bit ARM-based macOS chips. All that is to say that if you're building this stuff for other people, you've got more hoops to jump through than just sending them some Python files. Luckily, there are tools out there to help you with this. I haven't used this one myself yet, but cibuildwheel is a cross-platform wheel-building tool. It supports multiple versions of CPython, PyPy, and others across most of the major operating systems, as well as a couple I hadn't even heard of. This tool, combined with Rust's ability to do cross-platform compiles, heads you towards mastering the whole build target universe.
|
|
|
transcript
|
2:11 |
When I first explained the sample code produced by Maturin, I mentioned that you could use either Rust native types or those provided by the PyO3 crate in your code. There is a trade-off here, so I want to briefly talk about the difference. When you write a function signature that the macro maps to Python, you can use either a Rust native type like u32, or the conversion types provided by PyO3 that map to Python equivalents. In the case of an integer, that's the Pyint type. You can do either because PyO3 maps the values. If you use u32, PyO3 figures out that you want a Python integer and maps it for you. But as you might guess, there is some extra code involved in this conversion cost. It's a one-time cost on first use of the type, so if your signature has a u32, the macro gets the value of the integer from Python and maps it into your data type. Alternatively, if you use PyInt, you don't have to pay this cost because you've got access to the object, but of course that means if you're using it a lot, you might be crossing the boundary into Python more, which has its own costs. The documentation says to favor using Rust native types where you can, but if you're writing performance-sensitive code, you should benchmark it before you make a decision. So if there's an overhead cost, why does the documentation suggest using the native types? Well, once you've got a native type, you're very much in Rust land with fewer complications. One particular benefit is tighter type checking. For example, a Python list can contain anything, which means you can't do type checking on the contents of PyList. By contrast, a vector is typed. One of the design principles of Rust is to do strict checking, so it isn't terribly surprising that crustaceans would prefer the stricter mode. The other complication has to do with compatibility. For example, a u32 has a size limit, while a Python integer does not. For the vast majority of code, this isn't an issue, but if you're building libraries for science or math, you might want to consider how this works. Libraries like Polars force the issue, making you choose the size of the thing in the data frame, even though it looks like an integer.
|
|
|
transcript
|
2:42 |
Let's finish off our project, adding a little more complication to our code. Remember, the goal is to have a function that takes any number of Python objects as arguments, converts each of them to strings, and then combines them together. Yes, it would probably be smarter to do join with a generator that uses the str function, but that would make Ferris sad. Do you think I'd forgotten about your favorite crustacean? To accomplish our goal, you could copy and paste some code from the PyO3 docs and get it to work, but I'm going to at least give you a quick high-level explanation so the new syntax isn't all weird. First off, PyO3 has a Rust type called Py that is the base for any structure pointing at a Python object. Python can be multi-threaded, and this can cause some problems. What if Rust is trying to get at something that is on an inactive Python thread? Well, to deal with this problem, PyO3 includes a smart pointer called Bound. A smart pointer is a Rust concept that adds fancy features to references that allow you to do things like have code run when the associated object gets dropped. If you're paying careful attention, you might be asking yourself, just what is that extra little trick in the signature? It isn't a typo. Lifetimes are a deep topic, so let me give you a high-level explanation. Hopefully it will be enough to explain why the extra punctuation. When you create an object, Rust uses ownership to figure out when to drop it, freeing the memory. Similarly, the borrow checker determines how long a reference should be valid. You saw hints of this when I tried to dangle a pointer. The compiler knew that that was a problem and screamed about it. Yes, I think compiler messages are like me being yelled at. I have deep, deep issues. If your function takes multiple references, Rust might try to be efficient and drop one before the other. But this might not actually be what you want. Rust has an additional annotation that you can add to a reference to specify its lifetime. That's not quite right. You don't specify the lifetime, but if you annotated several references the same way, they'll all share the lifetime of the longest lived amongst them. That's what that little tick is for. In the case of the bound type, the lifetime that it's trying to match is the instance of the Python interpreter that its objects are associated with. So what's all this for? Well, I want a variable number of arguments in the Python signature. In Python, you do that with *args. The intent here is to use a bound type with a Rust tuple to represent that *args. You also need to let PyO3 know that that's what you're trying to do. You do this by adding information to the PyFunction macro.
|
|
|
transcript
|
3:29 |
And this is it. The top bit of the file here hasn't changed, but I do need to import something new. The contents of star args in Python is a tuple, so here I'm importing PyO3's equivalent rust type, pytuple. Our function now has two macros. The first was the one you saw before, and here's a new one that adds more information to how it should work. The signature modifier tells py function how to map the rust function to its Python equivalent. Specifying star args here tells it that you want the tuple argument in the function to represent star args in Python land rather than a regular old tuple as an argument. And here is our modified signature. The only tricky bit is PyO3's bound type which you'll recall is a way of associating a generic object in Python with Rust while taking care of issues with suspended threads. Don't worry, this isn't a problem I would have thought about either, but the PyO3 documentation says how to accomplish all of this, and I'm just following along. The tick indicates a lifetime annotation, and the underscore indicates that I don't care about it. Our code is simple, there's only one thread and one interpreter, so this is the coding equivalent of sticking our heads in the sand. And finally, this is what is getting bound, PyO3's rust type for a Python tuple. That was the tricky bit. Everything else is pretty vanilla by comparison. Our args value is a PyTuple, which has an iter method which returns an iterator. So this for loop will iterate over each item in the tuple, which is our arguments that are being passed in from Python. Then, like how I previously converted the u32, I call to_string on each of our items. Since everything in the PyTuple will be a Py object and almost everything in Python can be converted to a string, you can be pretty confident that this will work. Remember, to_string returns a string object, but push_str expects a string slice, so I'm grabbing a reference here to fix that. And that's it. Not a terribly long piece of code, but it has lots of Rust to Python goodies in it that you can use as examples in your own projects. Let's try this out. This time I'll skip the cargo build step and just let Maturin do the work. Notice that there are far fewer things getting compiled this time. All those dependencies that were built didn't change, and their build results are still in Cargo's cache. The only thing that needed to be compiled was my new code. This is also why it compiled a lot faster. Through the magic of video editing, I didn't make you wait the four seconds it took last time, but this time I didn't even have to cheat. It only took 0.12 seconds. Let's fire up the REPL. Importing. And let's start with the bad case. No arguments. That's nice. Since the code started by creating an empty Rust string, if there is nothing to iterate over, the result is the original empty string. There, I've mixed some integers, a float, and a string for kicks. How about something messier? Anything that can be converted to a string in Python will work in our routine. That was fun.
|
|
|
transcript
|
1:16 |
To go with the star args modifier, PyO3 also has a double star keyword args modifier. Like with args, you need to indicate that you want that in the signature modifier. But there's one messy complication. PyO3 needs the dictionary passed in to be optional. I'm not sure why they did it this way as they could have got away with an empty dict, which in fact is how Python does it. But this is the recommendation from PyO3. The option type is a generic wrapper that is based on result that indicates whether or not there is a thing. Instead of using pi tuple for keyword args, you use a pi dict. What's on the screen is just a copy of the example code from the PyO3 docs. But seeing as I'm here, I might as well explain what it does. You've seen methods on the result type, like unwrap. It also has a method called map_or. It takes a value and a closure. If the result is okay, then the closure gets applied to the result, which in this case returns the length of the dictionary. If the option, which inherits from result, is an error, for example if the option was empty, then the default value gets returned instead, which in our case is zero, meaning no keyword arguments.
|
|
|
transcript
|
1:22 |
A couple more things before wrapping up. I only showed you how to write modules and functions. PyO3 also supports classes. Of course, to do that, you need to learn how to write classes in Rust, which isn't part of this course. Rust has the struct keyword to define a data structure. It also has the impl keyword to attach methods to those data structures, which gives you something kind of like a class. I find their choices here weird, as this is actually done in two different blocks for some unknown reason, but hey, all languages have their quirks. Once you have a Rust struct, you can use the PyClass macro to map it to a Python object. With these two things, you can write your own Rust representations of whatever classes you have written in Python. Fancy things like Dunder methods are handled simply by attaching them as methods to the Rust struct. PyO3 takes care of mapping all of the special stuff. The other thing you should be aware of is thread safety. PyO3 assumes all of your Rust code is thread safe. For simple code like I showed you, that assumption is okay because the variables within it are isolated and so aren't a problem. But if you're writing more complicated Rust code, you may need locks to make sure you don't have any race conditions. This is especially important with the ongoing work to remove the GIL from Python in the form of the free threaded builds.
|
|
|
transcript
|
1:15 |
This lesson finally gave you what you came here for, integrating Python with Rust. The most common way of doing that is through the excellent PyO3 crate. The crate includes Rust types that can connect to and are compatible with Python types, allowing you to send data back and forth along the boundary between the two languages. In fact, PyO3 will automatically map Rust native types to Python equivalents if you like, allowing you to write Rust just like Rust. A lot of the connectivity magic happens through the use of procedural macros, which you use to decorate Rust modules, functions, and classes that you want to expose into the Python realm. Up until this lesson, your compile target has always been a program, but Rust supports more than that. You can compile to a dynamically loaded library like a.so or.dll file, depending on your platform. And then you can use a tool like Maturin to build a Python wheel that includes that same compiled Rust code, giving you access to your creation. There are other tools as well that help you cross-compile and package things up so you can build wheels to share your projects with others. I hear you like reviews. How about some more? The last lesson reviews the course and points you at places to look to continue your journey.
|
|
|
|
9:18 |
|
|
transcript
|
6:19 |
In the previous lesson, I showed you how to create a Rust crate, which could be imported as a Python module. This final lesson summarizes the course and points you to places where you can get more information. I started out by admitting that at first glance, it might be a little odd to find a course on Rust at Talk Python. Python being half of the title and all, but Rust is becoming quite common in the Python ecosystem. Many tools for working with Python are built in Rust, and there are a growing number of libraries that use Rust to squeeze performance out of your system. Rust is a strict, statically typed, compiled language aimed at systems programming. It isn't your daddy's system language, though. It has modern concepts like iteration, while still including byte-level data types and their manipulation, giving you the speed and efficiency you hope for from a compiled language. The Rust compiler isn't how you typically work with Rust, opting instead to use Cargo, a package management tool similar to uv. Rust has a decent-sized library of third-party packages, which are known as crates. Rust's PyPI equivalent is crates.io. Let's talk a little bit about the language syntax. First off, indentation is cosmetic, unlike Python. You denote blocks using brace brackets, which is how pretty much every other C family language does it. Python is the outlier. A block is considered an expression, and the last line in a block is its return value. If you are not interested in an expression's return value, you can suppress it with a semicolon. Many of Rust's keywords are very similar to Python's, including for, while, if, and else. Although there isn't an elif, instead you use the else and if keywords together to achieve the same thing. Variable declaration is different from Python. For starters, it uses the let keyword, and by default all variables are immutable. To get a mutable variable, you use the mut keyword. Rust is particular about what you declare and how that maps to what gets stored in memory. Its primitive types are more specific than Python's, making you choose how big they are and how they get used. For example, integers come in both signed and unsigned variants, and there are a selection of sizes specified by the number of bits used to store them. Like with Python, there are two floating point primitives, a 32-bit and 64-bit variation. Also like with Python, Rust has a Boolean type. True and false are a small case instead, but otherwise it's what you'd expect. There is no none in Rust. What gets used in similar situations is the empty type, denoted by a set of parentheses. This is like an empty tuple, hence the name empty type. There are several different ways of dealing with text in Rust. The char primitive type represents a character. The str primitive type is a string slice, which is an immutable chunk of text, while the capital S string is a mutable text object. There are methods for converting these different types into each other as you see fit. In addition to the base primitive types, Rust has two compound ones as well. Tuples and arrays are both fixed-sized storage mechanisms. Tuples can store a mix of primitive types, while arrays must be uniform, holding multiple things of the same kind. If you need a dynamically sized collection, something a little closer to a Python list, you use vector instead. Like arrays though, the contents of a vector need to be uniform, but unlike arrays, these can change on the fly. Collections like vector can be turned into iterables through the .iter method, while iterables can be turned into collections with the collect method. Rust has an enum type which it uses as a fundamental mechanism. The result enum is commonly used to indicate the success or failure of a function call. This enum has two subtypes, Ok and Err. Both of them are generics and can wrap values, with Ok wrapping the thing you want to return from a function, while Err wraps error information. The match statement allows you to examine a value and invoke a matching case. MATCH is a block and therefore an expression, so the selected case can be used to return a value. Rust is picky about matching on enum types, expecting every possible subtype to have a case statement. If you forget one, you'll get a compile error. Dealing with result enums is very common. To avoid a lot of match statement boilerplate, the result enum has convenient shortcut methods. The expect method returns the value that OK wraps, or panics if there was an error. You can pass a string to expect to be included in that panic message. The unwrap method is similar to expect, but without the error string. Rust doesn't have a garbage collector. Instead, memory gets freed when a value goes out of scope. The ownership system ensures that only one variable is in control of a value at a time. Some values can be copied or cloned to get around this limitation. And the ownership of a value can be transferred to other variables through reassignment, as a function argument, or as a return value. Instead of transferring ownership around, the most common practice is to borrow ownership through a reference. You get a reference to an object by prefixing it with an ampersand. Like all variables, references are immutable by default. You can change that with the mut keyword, but there can only be one mutable reference to an item at a time for safety. The PyO3 crate helps you create dynamically loadable libraries which can be used as Python modules. PyO3 uses procedural macros to map Rust modules, functions, and classes to their Python equivalents. The library also automatically translates Python data types to Rust equivalents and back again, and ships with a number of custom Rust types that correspond to Python objects, like PyList and PyDict. If you are building a Rust crate to be used as a Python module, the Maturin tool can help you with the build and deployment process.
|
|
|
transcript
|
1:25 |
This course was only the briefest taste of the world of Rust. Yeah, the word taste and rust shouldn't be in the same sentence. There's plenty more in the language to dig through, though. That's a better phrase. Buried things tend to rust. There are a number of other concepts you need to learn before considering yourself a Rust expert, including writing unit and integration tests, functional programming, object-oriented programming, traits, lifetimes, smart pointers, concurrency, and turning off the strictness going hog wild and writing unsafe code. If you want to learn more, a good place to start is the Rust documentation. There's the normal docs, and then to go along with it, a free online book which provides a series of tutorials on a variety of topics, including all those things I just mentioned. If that's not enough, though, the Rust by Example site contains a series of example programs covering a variety of the language's concepts. There's another useful book as well, one on cargo. This is all about the build tools and is where you would go to learn about things like customizing cargo's defaults. The PyO3 documentation is pretty decent, so if you're trying to figure out how to do more integration between the languages, that's a good place to delve deeper. And of course, if you're going down that path, especially if you want to put your code up on PyPI, you'll want to Check out the Maturin docs to understand that process better.
|
|
|
transcript
|
1:34 |
If instead of digging around more into Rust you want to learn some more Python, there are a few courses at Talk Python that cover similar topics to those that I talked about in this course. The Python Typing course teaches you more about Python's typing system. If you'd like to learn how to use type checking tools to write better code with stricter constraints, this is a good place to start. Although Python is far looser about memory management than Rust, and the garbage collector magically takes care of most things, at scale you can still run into problems. The Python Memory Management and Tips course teaches you more about memory management in your Python code. The Modern Python Projects course is a general one on how to structure a Python project. It covers tools that are similar to cargo, but for Python instead. You've made it this far, so you deserve a little treat. One of the advantages of being an author with Manning, as I know they're marketing people, and they were nice enough to offer an exclusive discount to their Rust in Action book for those who took the course. Use the code RUST50A to get 50% off either the electronic or dead tree version of the book. This book is an interesting mix. It covers language syntax, but it also has a series of system programming projects. Along the way, you'll learn more than just the language. You'll also learn about the kinds of hard problems you can solve with Rust. That's all for me. I hope you found value in the course. Thanks for your attention. Feel free to reach out on Bluesky if you have any questions or just want to say hello.
|