Overview

This course builds on DS110 (Python for Data Science) by expanding on programming language, systems, and algorithmic concepts introduced in the prior course. The course begins by exploring the different types of programming languages and introducing students to important systems level concepts such as computer architecture, compilers, file systems, and using the command line. It then moves to introducing a high performance language (Rust) and how to use it to implement a number of fundamental CS data structures and algorithms (lists, queues, trees, graphs etc). Then it covers how to use Rust in conjunction with external libraries to perform data manipulation and analysis.

Prerequisites: CDS 110 or equivalent

A1 Course Staff

Section A1 Instructor: Thomas Gardos
Email: tgardos@bu.edu
Office hours: Tuesdays, 3:30-4:45pm @ CCDS 1623

If you want to meet but cannot make office hours, send a private note on Piazza with at least 2 suggestions for times that you are available, and we will find a time to meet.

A1 TAs

  • Zach Gentile

    • Email: zgentile@bu.edu
    • Office Hours: Mondays, 1:30-3:30pm
    • Location: CDS 15th Floor (Office Hours Area)
  • Emir Tali

    • Email: etali@bu.edu
    • Office Hours: Wednesdays, 11:30am - 1:30pm

A1 CAs

  • Ting-Hung Jen

    • Email: allen027@bu.edu
    • Office Hours: Fridays 3:30-5:30
    • Location: CDS 15th Floor (Office Hours Area)
  • Matt Morris

    • Email: mattmorr@bu.edu
    • Office Hours: Mon/Wed 12:15-1:15

B1 Course Staff

Section B1 Instructor: Lauren Wheelock
Email: laurenbw@bu.edu
Office hours: Wed 2:30-4:00 @ CCDS 1506 Coffee slots: Fri 2:30-3:30 @ CCDS 1506

If you want to meet but cannot make office hours, send a private note on Piazza with at least 2 suggestions for times that you are available, and we will find a time to meet.

B1 Teaching Assistant

  • TA: Joey Russoniello
    • Email: jmrusso@bu.edu
    • Office Hours: Thursdays, 10am-12 noon
    • Location: CDS 15th Floor (Office Hours Area)

B1 Course Assistants |

  • Ava Yip

    • Email: avayip@bu.edu
    • Office Hours: Tuesdays 3:45-5:45
    • Location: CDS 15th Floor (Office Hours Area)
  • Pratik Tribhuwan

    • Email: pratikrt@bu.edu
    • Office Hours: Fridays 12:00-2:00
    • Location: CDS 15th Floor (Office Hours Area)

Lectures and Discussions

A1 Lecture: Tuesdays, Thursdays 2:00pm-3:15pm (LAW AUD)

Section A Discussions (Wednesdays, 50 min):

  • A2: 12:20pm – 1:10pm, CDS B62, (led by Zach) Note new location!!
  • A3: 1:25pm – 2:15pm, IEC B10, (led by Zach)
  • A4: 2:30pm – 3:20pm CGS 311, (led by Emir)
  • A5: 3:35pm – 4:25pm CGS 315, (led by Emir)

B1 Lecture: Mondays, Wednesdays, Fridays 12:20pm-1:10pm (WED 130)

Section B Discussions (Fridays, 50 min):

  • B2: Tue 11:00am – 11:50 (listed 12:15pm), 111 Cummington St MCS B37 (led by Joey)
  • B3: Tue 12:30pm – 1:20 (listed 1:45pm), 3 Cummington Mall PRB 148 (led by Joey)
  • B4: Tue 2:00pm – 2:50pm (listed 3:15pm), 665 Comm Ave CDS 164
  • B5: Tue 3:30pm – 4:20 (listed 4:45pm), 111 Cummington St MCS B31

Note: Discussion sections B4 and B5 are cancelled because of low enrollment. Please re-enroll in B2 or B3 if you were previously enrolled in B4 or B5.

Note: There are two sections of this course, they cover the same material and share a piazza and course staff but the discussion sections and grading portals are different. These are not interchangeable, you must attend the lecture and discussion sessions for your section!

Course Websites

  • Piazza

    • Lecture Recordings
    • Announcements and additional information
    • Questions and discussions
  • Course Notes (https://ds210-fa25-private.github.io/):

    • Syllabus (this document)
    • Interactive lecture notes
  • Gradescope

    • Homework, project, project proposal submissions
    • Gradebook
  • GitHub Classroom: URL TBD

Course Content Overview

  • Part 1: Foundations (command line, git) & Rust Basics (Weeks 1-3)
  • Part 2: Core Rust Concepts & Data Structures (Weeks 4-5)
  • Midterm 1 (~Week 5)
  • Part 3: Advanced Rust & Algorithms (Weeks 6-10)
  • Midterm 2 (~Week 10)
  • Part 4: Data Structures and Algorithms (~Weeks 11-12)
  • Part 5: Data Science & Rust in Practice (~Weeks 13-14)
  • Final exam during exam week

For a complete list of modules and topics that will be kept up-to-date as we go through the term, see Lecture Schedule (MWF) and Lecture Schedule (TTH).

Course Format

Lectures will involve extensive hands-on practice. Each class includes:

  • Interactive presentations of new concepts
  • Small-group exercises and problem-solving activities
  • Discussion and Q&A

Because of this active format, regular attendance and participation is important and counts for a significant portion of your grade (15%).

Discussions will review lecture material, provide homework support, and will adapt over the semester to the needs of the class. We will not take attendance but our TAs make this a great resource!

Pre-work will be assigned before most lectures to prepare you for in-class activities. These typically include readings plus a short ungraded quiz. We will also periodically ask for feedback and reflections on the course between lectures.

Homeworks will be assigned roughly weekly at first, and there will be longer two-week assignments later, reflecting the growing complexity of the material.

Exams Two midterms and a cumulative final exam covering theory and short hand-coding problems (which we will practice in class!)

The course emphasizes learning through practice, with opportunities for corrections and growth after receiving feedback on assignments and exams.

Course Policies

Grading Calculations

Your grade will be determined as:

  • 15% homeworks (~9 assignments)
  • 20% midterm 1
  • 20% midterm 2
  • 25% final exam
  • 15% in-class activities
  • 5% pre-work and surveys

I will use the standard map from numeric grades to letter grades (>=93 is A, >=90 is A-, etc). For the midterm and final, we may add a fixed number of "free" points to everyone uniformly to effectively curve the exam at our discretion - this will never result in a lower grade for anyone.

We will use gradescope to track grades over the course of the semester, which you can verify at any time and use to compute your current grade in the course for yourself.

Homeworks

Homework assignments will be submitted by uploading them to GitHub Classroom. Since it may be possible to rely on genAI tools to do these assignments, against the course policy, our grading emphasizes development process and coding best practices in addition to technical correctness.

Typically, 1/3 of the homework score will be for correctness (computed by automated tests for coding assignments), 1/3 for documenting of your process (sufficient commit history and comments), and 1/3 for communication and best practices, which can be attained by replying to and incorporating feedback given by the CAs and TAs on your work.

Exams

The final will be during exam week, date and location TBD. The two midterms will be in class during normal lecture time.

If you have a valid conflict with a test date, you must tell me as soon as you are aware, and with a minimum of one week notice (unless there are extenuating  circumstances) so we can arrange a make-up test.

If you need accommodations for exams, schedule them with the Testing Center as soon as exam dates are firm. See below for more about accommodations.

Deadlines and late work

Homeworks will be due on the date specified in gradescope/github classroom.  

If your work is up to 48-hours late, you can still qualify for up to 80% credit for the assignment. After 48 hours, late work will not be accepted unless you have made prior arrangements due to extraordinary circumstances.

Collaboration

You are free to discuss problems and approaches with other students but must do your own writeup. If a significant portion of your solution is derived from someone else's work (your classmate, a website, a book, etc), you must cite that source in your writeup. You will not be penalized for using outside sources as long as you cite them appropriately.

You must also understand your solution well enough to be able to explain it if asked.

Academic honesty

You must adhere to BU's Academic Conduct Code at all times. Please be sure to read it here. In particular: cheating on an exam, passing off another student's work as your own, or plagiarism of writing or code are grounds for a grade reduction in the course and referral to BU's Academic Conduct Committee. If you have any questions about the policy, please send me a private Piazza note immediately, before taking an action that might be a violation.

AI use policy

You are allowed to use GenAI (e.g., ChatGPT, GitHub Copilot, etc) to help you understand concepts, debug your code, or generate ideas. You should understand that this may may help or impede your learning depending on how you use it.

If you use GenAI for an assignment, you must cite what you used and how you used it (for brainstorming, autocomplete, generating comments, fixing specific bugs, etc.). You must understand the solution well enough to explain it during a small group or discussion in class.

Your professor and TAs/CAs are happy to help you write and debug your own code during office hours, but we will not help you understand or debug code that generated by AI.

For more information see the CDS policy on GenAI.

Attendance and participation

Since a large component of your learning will come from in-class activities and discussions, attendance and participation are essential and account for 15% of your grade.

Attendance will be taken in lecture through Piazza polls which will open at various points during the lecture. Understanding that illness and conflicts arise, up to 4 absences are considered excused and will not affect your attendance grade.

In most lectures, there will be time for small-group exercises, either on paper or using github. To receive participation credit on these occasions, you must identify yourself on paper or in the repo along with a submission. These submissions will not be graded for accuracy, just for good-faith effort.

Occasionally, I may ask for volunteers, or I may call randomly upon students or groups to answer questions or present problems during class. You will be credited for participation.

Absences

This course follows BU's policy on religious observance. Otherwise, it is generally expected that students attend lectures and discussion sections. If you cannot attend classes for a while, please let me know as soon as possible. If you miss a lecture, please review the lecture notes and lecture recording. If I cannot teach in person, I will send a Piazza announcement with instructions.

Accommodations

If you need accommodations, let me know as soon as possible. You have the right to have your needs met, and the sooner you let me know, the sooner I can make arrangements to support you.

This course follows all BU policies regarding accommodations for students with documented disabilities. If you are a student with a disability or believe you might have a disability that requires accommodations, please contact the Office for Disability Services (ODS) at (617) 353-3658 or access@bu.edu to coordinate accommodation requests.

If you require accommodations for exams, please schedule that at the BU testing center as soon as the exam date is set.

Re-grading

You have the right to request a re-grade of any homework or test. All regrade requests must be submitted using the Gradescope interface. If you request a re-grade for a portion of an assignment, then we may review the entire assignment, not just the part in question. This may potentially result in a lower grade.

Corrections

You are welcome to submit corrections on homework assignments or the midterms. This is an opportunity to take the feedback you have received, reflect on it, and then demonstrate growth. Corrections involve submitting an updated version of the assignment or test alongside the following reflections:

  • A clear explanation of the mistake
  • What misconception(s) led to it
  • An explanation of the correction
  • What you now understand that you didn't before

After receiving grades back, you will have one week to submit corrections. You can only submit corrections on a good faith attempt at the initial submission (not to make up for a missed assignment).

Satisfying this criteria completely for any particular problem will earn you back 50% of the points you originally lost (no partial credit).

Oral re-exams (Section B only)

In Section B, we will provide you with a topic breakdown of your midterm exams into a few major topics. After receiving your midterm grade, you may choose to do an oral re-exam on one of the topics you struggled with by scheduling an appointment with Prof. Wheelock. This will involve a short (~10 minute) oral exam where you will be asked to explain concepts and write code on a whiteboard. This score will replace your original score on the topic, with a cap of 90% on that topic.


T-TH Lecture Schedule

Note: Schedule will be frequently updated. Check back often.

Note: Homeworks will be distributed via Gradescope and GitHub Classroom. We'll also post notices on Piazza.

See also Module Topics by Week below.

Lecture Schedule

DateLectureHomework
Week 1------
Sep 2Lecture 1: Course Overview, Why Rust
Sep 4Lecture 2: Hello Shell
Week 2------
Sep 8HW1 Released
Sep 9Lecture 3: Hello Git
Sep 11Lecture 4: Hello Rust
Week 3------
Sep 15HW1 Due
Sep 16Lecture 5: Programming Languages, Guessing Game Part 1
Sep 18Lecture 6: Hello VSCode and GitHub Classroom
Week 4------
Sep 23Lecture 7: Vars and Types,
Sep 25Lecture 8: Finish Vars and Types, Cond Expressions, Functions,
Week 5------
Sep 30Lecture 9: Finish Functions, Loops Arrays, Tuples
Oct 2Lecture 10: Enum and Match
Week 6------
Oct 7Lecture 11: A1 Midterm 1 Review
Oct 9🧐📚 Midterm 1 📚🧐
Week 7------
Oct 14No Class -- Monday Schedule
Oct 16Lecture 12: Structs, Method Syntax, Methods Revisited
Week 8------
Oct 21Lecture 13: Ownership and Borrowing, Strings and Vecs
Oct 23Lecture 14: Slices, Modules,
Week 9------
Oct 28Lecture 15: Crates, Rust Projects,Tests, Generics
Oct 30Lecture 16: Generics, Traits
Week 10------
Nov 4Lecture 17: Lifetimes, Closures
Nov 6Lecture 18: , Iterators, Iters Closures
Week 11------
Nov 11Lecture 19 -- Midterm 2 Review
Nov 13🧐📚 Midterm 2 📚🧐
Week 12------
Nov 18Lecture 20: Complexity Analysis, Hash Maps (only)
Nov 20Lecture 21: Hashing Functions, Hash Sets, linked lists,
Week 13------
Nov 25Lecture 22: Stacks, Queues
Nov 27🦃 No Class -- Thanksgiving Recess 🌽
Week 14------
Dec 2Lecture 23: Collections Deep Dive,
Dec 3Exam 2 Corrections Due, 11:59pm
Dec 4Lecture 24: Algorithms and Data Science
Dec 8HW 7 Due, 11:59pm
Week 15------
Dec 9Final Review
Dec 10🎉 Last Day of Classes 🎉
Dec 16🧐📚 Final Exam 3:00pm -- 5:00pm 📚🧐Law Auditorium

Module Topics by Week

Module topics by week for the Tues-Thurs A1 Section.

Work in progress. Check back often.

(Midterm 1 -- Systems, Basic Rust)

  • Modules, crates, Rust projects, (58, 60, 62, 64)
  • File IO, Error handling (64, 100)
  • Structs, methods (30, 31, 38)
  • Generics, traits (42, 44)
  • Collections, vectors, lifetimes (46, 92)
  • Iterators and closures (94, 96, 98)
  • Hashmaps, hashsets (52)
  • Tests (48)
  • Complexity analysis (50)

(Midterm 2 -- Advanced Rust)

  • Calling Rust from Python (920)
  • SWE project management / project intro (new, 900)
  • Linked lists, stacks, queues (66, 68, 70)
  • Graph representation / graph algo (54, 56)
  • Graph search, BFS, DFS, CC/SCC (72, 74, 76, 78) - maybe not from scratch
  • Priority queues, binary heap, sorting (82, 84) - maybe not from scratch
  • Shortest path / trees part 2 (86)
  • Algorithms (108, 110, 112)
  • Divide and conquer / merge sort (122)
  • NDArray, survey of data science in Rust (114, 120)
  • More data science
  • Parallel code (126)

(Final Exam)

MWF Lecture, HW, and Exam Schedule

See the B1 Schedule for an up-to-date schedule for the MWF (B1) section

DS210 Course Overview

About This Module

This module introduces DS-210: Programming for Data Science, covering course logistics, academic policies, grading structure, and foundational concepts needed for the course.

Overview

This course builds on DS110 (Python for Data Science). That, or an equivalent is a prerequisite.

We will cover

  • programming languages
  • computing systems concepts
  • shell commands

And then spend the bulk of the course learning Rust, a modern, high-performance and more secure programming language.

Time permitting we dive into some common data structures and data science related libraries.

New This Semester

We've made some significant changes to the course based on observations and course evaluations.

Question: What have you heard about the course? Is it easy? Hard?

Changes include:

  1. Moving course notes from Jupyter notebooks to Rust mdbook
  2. Addition of in-class group activites for almost every lecture where you can reinforce what you learned and practice for exams
    • Less lecture content, slowing down the pace
  3. Homeworks that progressively build on the lecture material and better match exam questions (e.g. 10-15 line code solutions)
  4. Elimination of course final project and bigger emphasis on in-class activities and participation.
  5. ...

Teaching Staff and Contact Information

Section A Instructor: Thomas Gardos

  • Email: tgardos@bu.edu
  • Office hours: Tuesdays, 3:30-4:45pm @ CCDS 1623

Section B Instructor: Lauren Wheelock

  • Email: laurenbw@bu.edu
  • Office hours: Wednesday, 2:30-4:00pm @ CCDS 1506
Teaching AssistantsCourse Assistants
TA: Zach Gentile
Email:
Office Hours: Mondays, 1:20-3:20pm
CA: Ting-Hung Jen
Email:
TA: Joey Russoniello
Email:
Office Hours: Thursdays, 10am-12 noon
CA: Matt Morris
Email:
TA: Emir Tali
Email:
Office Hours: Wednesdays, 11:30am - 1:30pm
CA: Pratik Tribhuwan
Email:
CA: Ava Yip
Email:

Course Logistics

Lectures:

(A) Tue / Thu 2:00pm - 3:15pm, 765 Commonwealth Ave LAW AUD (B) Mon / Wed / Fri 12:20pm - 1:10pm, 2 Silber Way WED 130

Lectures and Discussions

A1 Lecture: Tuesdays, Thursdays 2:00pm-3:15pm (LAW AUD)

Section A Discussions (Wednesdays, 50 min): Led by TAs
A2: 12:20pm – 1:10pm, SAR 300, (Zach)
A3: 1:25pm – 2:15pm, IEC B10, (Zach)
A4: 2:30pm – 3:20pm CGS 311, (Emir)
A5: 3:35pm – 4:25pm CGS 315, (Emir)

B1 Lecture: Mondays, Wednesdays, Fridays 12:20pm-1:10pm (WED 130)

Section B Discussions (Fridays, 50 min??): Led by TAs
(listed as 75 minutes for technical reasons but actually meet for 50)

  • B2: Tue 11:00am – 10:50 (listed 12:15pm), 111 Cummington St MCS B37 (Joey)
  • B3: Tue 12:30pm – 1:20 (listed 1:45pm), 3 Cummington Mall PRB 148 (Joey)
  • B4: Tue 2:00pm – 2:50pm (listed 3:15pm), 665 Comm Ave CDS 164
  • B5: Tue 3:30pm – 4:20 (listed 4:45pm), 111 Cummington St MCS B31

Note: Discussion sections B4 and B5 are cancelled because of low enrollment. Please re-enroll in B2 or B3 if you were previously enrolled in B4 or B5.

Course Websites

See welcome email for Piazza and Gradescope URLs.

  • Piazza:

    • Lecture Notes
    • Announcements and additional information
    • Questions and discussions
  • Gradescope:

    • Homework
    • Gradebook
  • GitHub Classroom: URL TBD

Course objectives

This course teaches systems programming and data structures through Rust, emphasizing safety, speed, and concurrency. By the end, you will:

  • Master key data structures and algorithms for CS and data science
  • Understand memory management, ownership, and performance optimization
  • Apply computational thinking to real problems using graphs and data science
  • Develop Rust skills that transfer to other languages

Why are we learning Rust?

  • Learning a second programming language builds CS fundamentals and teaches you to acquire new languages throughout your career
  • Systems programming knowledge helps you understand software-hardware interaction and write efficient, low-level code

We're using Rust specifically because:

  • Memory safety without garbage collection lets you see how data structures work in memory (without C/C++ headaches)
  • Strong type system catches errors at compile time, helping you write correct code upfront
  • Growing adoption in data science and scientific computing across major companies and agencies

More shortly.

Course Timeline and Milestones

  • Part 1: Foundations (command line, git) & Rust Basics (Weeks 1-3)
  • Part 2: Core Rust Concepts & Data Structures (Weeks 4-5)
  • Midterm 1 (~Week 5)
  • Part 3: Advanced Rust & Algorithms (Weeks 6-10)
  • Midterm 2 (~Week 10)
  • Part 4: Data Structures and Algorithms (~Weeks 11-12)
  • Part 5: Data Science & Rust in Practice (~Weeks 13-14)
  • Final exam during exam week

Course Format

Lectures will involve extensive hands-on practice. Each class includes:

  • Interactive presentations of new concepts
  • Small-group exercises and problem-solving activities
  • Discussion and Q&A

Because of this active format, regular attendance and participation is important and counts for a significant portion of your grade (15%).

Discussions will review and reinforce lecture material through and provide further opportunities for hands-on practice.

Pre-work will be assigned before most lectures to prepare you for in-class activities. These typically include readings plus a short ungraded quiz. The quizz questions will reappear in the lecture for participation credit.

Homeworks will be assigned roughly weekly before the midterm, and there will be 2-3 longer two-week assigments after the deadline, reflecting the growing complexity of the material.

Exams 2 midterms and a cumulative final exam covering theory and short hand-coding problems (which we will practice in class!)

The course emphasizes learning through practice, with opportunities for corrections and growth after receiving feedback on assignments and exams.

In-class Activities

Syllabus Review Activity (20 min)

In groups of 2-3, review the course syllabus and answer the following questions:

Concrete:

  1. Add your names to a shared worksheet
  2. How are assignments and projects submitted?
  3. What happens if you submit work a day late?
  4. If you get stuck on an assignment and your friend explains how to do it, what should you do?
  5. What would it take to get full credit for attendance and participation?
  6. If you have accomodations for exams, how soon should you request them?
  7. When and how long are discussion sections?

Open-ended:

  1. What parts of the course policies seem standard and what parts seem unique?
  2. Identify 2-3 things in the syllabus that concern you
  3. What strategies could you use to address these concerns?
  4. Identify 2-3 things you're glad to see
  5. When do you plan to submit your first assignment / project? What do you think it will cover?
  6. List three questions about the course that aren't answered in the syllabus

AI use discussion (20 min)

Think-pair-share style, each ~6-7 minutes, with wrap-up.

See Gradescope assignment. Forms teams of 3.

Round 1: Learning Impact

"How might GenAI tools help your learning in this course? How might they get in the way?"

Round 2: Values & Fairness

"What expectations do you have for how other students in this course will or won't use GenAI? What expectations do you have for the teaching team so we can assess your learning fairly given easy access to these tools?"

Round 3: Real Decisions

"Picture yourself stuck on a challenging Rust problem at midnight with a deadline looming. What options do you have? What would help you make decisions you'd feel good about?"

More course policies

See syllabus for more information on:

  • deadlines and late work
  • collaboration
  • academic honesty
  • AI use policy (discussed below)
  • Attendance and participation
  • Absences
  • Accommodations
  • Regrading
  • Corrections

AI use policy

You are allowed to use GenAI (e.g., ChatGPT, GitHub Copilot, etc) to help you understand concepts, debug your code, or generate ideas.

You should understand that this may may help or impede your learning depending on how you use it.

If you use GenAI for an assignment, you must cite what you used and how you used it (for brainstorming, autocomplete, generating comments, fixing specific bugs, etc.).

You must understand the solution well enough to explain it during a small group or discussion in class.

Your professor and TAs/CAs are happy to help you write and debug your own code during office hours, but we will not help you understand or debug code that is generated by AI.

For more information see the CDS policy on GenAI.

Intro surveys

Please fill out the intro survey posted on Gradescope.

Why Rust?

Why Systems Programming Languages Matter

Importance of Systems Languages:

  • Essential for building operating systems, databases, and infrastructure
  • Provide fine-grained control over system resources
  • Enable optimization for performance-critical applications
  • Foundation for higher-level languages and frameworks

Performance Advantages:

  • Generally compiled languages like Rust are needed to scale to large, efficient deployments
  • Can be 10x to 100x faster than equivalent Python code
  • Better memory management and resource utilization
  • Reduced runtime overhead compared to interpreted languages

Memory Safety: A Critical Advantage

What is Memory Safety?

Memory safety prevents common programming errors that can lead to security vulnerabilities:

  • Buffer overflows
  • Use-after-free errors
  • Memory leaks
  • Null pointer dereferences

Industry Recognition:

Major technology companies and government agencies are actively moving to memory-safe languages:

  • Google, Microsoft, Meta have efforts underway to move infrastructure code from C/C++ to Rust
  • U.S. Government agencies recommend memory-safe languages for critical infrastructure
  • DARPA has programs focused on translating C to Rust
  • CISA (Cybersecurity and Infrastructure Security Agency) advocates for memory-safe roadmaps

image.png Whitehouse Press Release


image-2.png Darpa Program


image-3.png CISA -- The case for memory safe roadmaps CISA -- Cybersecurity and Infrastructure Security Agency


Programming Paradigms: Interpreted vs. Compiled

Interpreted Languages (e.g., Python):

Advantages:

  • Interactive development environment
  • Quick iteration and testing
  • Rich ecosystem for data science (Jupyter, numpy, pandas)
  • Easy to learn and prototype with

Compiled Languages (e.g., Rust):

Advantages:

  • Superior performance and efficiency
  • Early error detection at compile time
  • Optimized machine code generation
  • Better for production systems

Development Process:

  1. Write a program
  2. Compile it (catch errors early)
  3. Run and debug optimized code
  4. Deploy efficient executables

Using Rust in a Jupyter Notebook

The project EvCxR (Evaluation Context for Rust) creates a Rust kernel that you can use in Jupyter notebooks.

  • Can be helpful for interactive learning
  • There are some quirks when creating Rust code cells in a notebook
    • Variables and functions are kept in global state
    • Order of cell execution matters!
  • Previous versions of the course notes used this format

We use Rust mdbook with code cells that get executed on the Rust playground.

Same format that the Rust language book is written.

Technical Coding Interviews

And finally...

If you are considering technical coding interviews, they sometimes ask you to solve problems in a language other than python.

Many of the in-class activities and early homework questions will be Leetcode/HackerRank style challenges.

This is good practice!

Hello Shell!

About This Module

This module introduces you to the command-line interface and essential shell commands that form the foundation of systems programming and software development. You'll learn to navigate the file system, manipulate files, and use the terminal effectively for Rust development.

Prework Readings

Review this module.

Pre-lecture Reflections

Before class, consider these questions:

  1. What advantages might a command-line interface offer over graphical interfaces? What types of tasks seem well-suited for command-line automation?
  2. How does the terminal relate to the development workflow you've seen in other programming courses?

Learning Objectives

By the end of this module, you should be able to:

  • Create, copy, move, and delete files and directories at the command line
  • Understand file permissions and ownership concepts
  • Use pipes and redirection for basic text processing
  • Set up an organized directory structure for programming projects
  • Feel comfortable working in the terminal environment

Why the Command Line Matters

For Programming and Data Science:

# Quick file operations
ls *.rs                    # Find all Rust files
grep "TODO" src/*.rs       # Search for TODO comments across files
wc -l data/*.csv          # Count lines in all CSV files

Advantages over GUI:

  • Speed: Much faster for repetitive tasks
  • Precision: Exact control over file operations
  • Automation: Commands can be scripted and repeated
  • Remote work: Essential for server management
  • Development workflow: Many programming tools use command-line interfaces

File Systems

File System Structure Essentials

A lot of DS and AI infrastructure runs on Linux/Unix type filesystems.

Root Directory (/):

The slash character represents the root of the entire file system.

Linux File System

Directory Conventions

  • /: The slash character by itself is the root of the filesystem
  • /bin: A place containing programs that you can run
  • /boot: A place containing the kernel and other pieces that allow your computer to start
  • /dev: A place containing special files representing all your devices
  • /etc: A place with lots of configuration information (i.e. login and password data)
  • /home: All user's home directories
  • /lib: A place for all system libraries
  • /mnt: A place to mount external file systems
  • /opt: A place to install user software
  • /proc: Lots of information about your computer and what is running on it
  • /sbin: Similar to bin but for the superuser
  • /usr: Honestly a mishmash of things and rather overlapping with other directories
  • /tmp: A place for temporary files that will be wiped out on a reboot
  • /var: A place where many programs write files to maintain state

Key Directories You'll Use:

/                          # Root of entire system
├── home/                  # User home directories
│   └── username/          # Your personal space
├── usr/                   # User programs and libraries
│   ├── bin/              # User programs (like cargo, rustc)
│   └── local/            # Locally installed software
└── tmp/                  # Temporary files

Navigation Shortcuts:

  • ~ = Your home directory
  • . = Current directory
  • .. = Parent directory
  • / = Root directory

To explore further

You can read more about the Unix filesystem at https://en.wikipedia.org/wiki/Unix_filesystem.

The Linux shell

It is an environment for finding files, executing programs, manipulating (create, edit, delete) files and easily stitching multiple commands together to do something more complex.

Windows and MacOS has command shells, but Windows is not fully compatible, however MacOS command shell is.

Windows Subystem for Linux is fully compatible.

In Class Activity Part 1: Access/Install Terminal Shell

Directions for MacOS Users and Windows Users.

macOS Users:

Your Mac already has a terminal! Here's how to access it:

  1. Open Terminal:

    • Press Cmd + Space to open Spotlight
    • Type "Terminal" and press Enter
    • Or: Applications → Utilities → Terminal
  2. Check Your Shell:

    echo $SHELL
    # Modern Macs use zsh, older ones use bash
    
  3. Optional: Install Better Tools:

Install Homebrew (package manager for macOS)

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

Install useful tools

brew install tree      # Visual directory structure
brew install ripgrep   # Fast text search

Windows Users:

Windows has several terminal options. For this exercise we recommend Option 1, Git bash.

When you have more time, you might want to explore Windows Subsystem for Linux so you can have a full, compliant linux system accessible on Windows.

PowerShell aliases some commands to be Linux-like, but they are fairly quirky.

We recommend Git Bash or WSL:

  1. Option A: Git Bash (Easier)

    • Download Git for Windows from git-scm.com
    • During installation, select "Use Git and optional Unix tools from the Command Prompt"
    • Open "Git Bash" from Start menu
    • This gives you Unix-like commands on Windows
  2. Option B: Windows Subsystem for Linux (WSL)

    # Run PowerShell as Administrator, then:
    wsl --install
    # Restart your computer
    # Open "Ubuntu" from Start menu
    
  3. Option C: PowerShell (Built-in)

    • Press Win + X and select "PowerShell"
    • Note: Commands differ from Unix (use dir instead of ls, etc.)
    • Not recommended for the in-class activities.

Verify Your Setup (Both Platforms)

pwd              # Should show your current directory
ls               # Should list files (macOS/Linux) or use 'dir' (PowerShell)
which ls         # Should show path to ls command (if available)
echo "Hello!"    # Should print Hello!

Essential Commands for Daily Use

pwd                        # Show current directory path
ls                        # List files in current directory
ls -al                    # List files with details and hidden files
cd directory_name         # Change to directory
cd ..                     # Go up one directory
cd ~                      # Go to home directory

Creating and Organizing:

mkdir project_name        # Create directory
mkdir -p path/to/dir      # Create nested directories
touch filename.txt        # Create empty file
cp file.txt backup.txt    # Copy file
mv old_name new_name      # Rename/move file
rm filename               # Delete file
rm -r directory_name      # Delete directory and contents
rm -rf directory_name     # Delete dir and contents without confirmation

Viewing File Contents:

cat filename.txt          # Display entire file
head filename.txt         # Show first 10 lines
tail filename.txt         # Show last 10 lines
less filename.txt         # View file page by page (press q to quit)

File Permissions Made Simple

Understanding ls -l Output:

-rw-r--r-- 1 user group 1024 Jan 15 10:30 filename.txt
drwxr-xr-x 2 user group 4096 Jan 15 10:25 dirname

Permission Breakdown:

  • First character: - (file) or d (directory)
  • Next 9 characters in groups of 3:
    • Owner permissions (rwx): read, write, execute
    • Group permissions (r-x): read, no write, execute
    • Others permissions (r--): read only

We will see these kinds of permissions again in Rust programming!

Common Permission Patterns:

  • 644 or rw-r--r--: Files you can edit, others can read
  • 755 or rwxr-xr-x: Programs you can run, others can read/run
  • 600 or rw-------: Private files only you can access

Pipes and Redirection Basics

Saving Output to Files:

ls > file_list.txt        # Save directory listing to file
echo "Hello World" > notes.txt  # Overwrite file contents
echo "It is me" >> notes.text   # Append to file content

Combining Commands with Pipes:

ls | grep ".txt"          # List only .txt files
cat file.txt | head -5    # Show first 5 lines of file
ls -l | wc -l            # Count number of files in directory

Practical Examples:

# Find large files
ls -la | sort -k5 -nr | head -10

# Count total lines in all text files
cat *.txt | wc -l

# Search for pattern and save results
grep "error" log.txt > errors.txt

Setting Up for Programming

Creating Project Structure:

# Create organized development directory
# The '-p' means make intermediate directories as required
mkdir -p ~/development/rust_projects
mkdir -p ~/development/data_science
mkdir -p ~/development/tools

# Navigate to project area
cd ~/development/rust_projects

# Create specific project
mkdir my_first_rust_project
cd my_first_rust_project

Customizing Your Shell Profile (Optional)

Understanding Shell Configuration Files:

Your shell reads a configuration file when it starts up. This is where you can add aliases, modify your PATH, and customize your environment.

Common Configuration Files:

  • macOS (zsh): ~/.zshrc
  • macOS (bash): ~/.bash_profile or ~/.bashrc
  • Linux (bash): ~/.bashrc
  • Windows Git Bash: ~/.bash_profile

Finding Your Configuration File:

It's in your Home directory.

# Check which shell you're using (MacOS/Linus)
echo $SHELL

# macOS with zsh
echo $HOME/.zshrc

# macOS/Linux with bash
echo $HOME/.bash_profile
echo $HOME/.bashrc

Adding Useful Aliases:

# Edit your shell configuration file (choose the right one for your system)
nano ~/.zshrc        # macOS zsh
nano ~/.bash_profile # macOS bash or Git Bash
nano ~/.bashrc       # Linux bash

# Add these helpful aliases:
alias ll='ls -la'
alias ..='cd ..'
alias ...='cd ../..'
alias projects='cd ~/development'
alias rust-projects='cd ~/development/rust_projects'
alias grep='grep --color=auto'
alias tree='tree -C'

# Custom functions
# This will make a directory specified as the argument and change into it
mkcd() {
    mkdir -p "$1" && cd "$1"
}

Modifying Your PATH:

# Add to your shell configuration file
export PATH="$HOME/bin:$PATH"
export PATH="$HOME/.cargo/bin:$PATH"    # For Rust tools (we'll add this later)

# For development tools
export PATH="/usr/local/bin:$PATH"

Applying Changes:

# Method 1: Reload your shell configuration
source ~/.zshrc        # For zsh
source ~/.bash_profile # For bash

# Method 2: Start a new terminal session
# Method 3: Run the command directly
exec $SHELL

Useful Environment Variables:

# Add to your shell configuration file
export EDITOR=nano           # Set default text editor
export HISTSIZE=10000       # Remember more commands
export HISTFILESIZE=20000   # Store more history

# Color support for ls
export CLICOLOR=1           # macOS
export LS_COLORS='di=34:ln=35:so=32:pi=33:ex=31:bd=34:cd=34:su=0:sg=0:tw=34:ow=34' # Linux

Shell Configuration with Git Branch Name

A useful shell configuration is modify the shell command prompt to show your current working directory and your git branch name if you are in a git project.

See DS549 Shell Configuraiton for instructions.

Shell scripts

A way to write simple programs using the linux commands and some control flow elements. Good for small things. Never write anything complicated using shell.

Shell Script File

Shell script files typically use the extension *.sh, e.g. script.sh.

Shell script files start with a shebang line, #!/bin/bash.

#!/bin/bash

echo "Hello world!"

To execute shell script you can use the command:

source script.sh

Hint: You can use the nano text editor to edit simple files like this.

In-Class Activity: Shell Challenge

Prerequisite: You should have completed Part I above to have access to a Linux or MacOS style shell.

Part 2: Scavenger Hunt

Complete the steps using only the command line!

You can use echo to write to the file, or text editor nano.

Feel free to reference the cheat sheet below and the notes above.

  1. Create a directory called treasure_hunt in your course projects folder.

  2. In that directory create a file called command_line_scavenger_hunt.txt that contains the following:

    • Your name / group members
  3. Run these lines and record the output into that .txt file:

whoami                    # What's your username?
hostname                  # What's your computer's name?
pwd                      # Where do you start?
echo $HOME               # What's your home directory path?
  1. Inside that directory, create a text file named clue_1.txt with the content "The treasure is hidden in plain sight"

  2. Create a subdirectory called secret_chamber

  3. In the secret_chamber directory, create a file called clue_2.txt with the content "Look for a hidden file"

  4. Create a hidden file in the secret_chamber directory called .treasure_map.txt with the content "Congratulations. You found the treasure"

  5. When you're done, change to the parent directory of treasure_hunt and run the command zip -r treasure_hunt.zip treasure_hunt.

    • Or if you are on Git Bash, you may have to use the command tar.exe -a -c -f treasure_hunt.zip treasure_hunt
  6. Upload treasure_hunt.zip to gradescope - next time we will introduce git and github and use that platform going forward.

  7. Optional: For Bragging Rights Create a shell script that does all of the above commands and upload that to Gradescope as well.


Command Line Cheat Sheet

Basic Navigation & Listing

Mac/Linux (Bash/Zsh):

# Navigate directories
cd ~                    # Go to home directory
cd /path/to/directory   # Go to specific directory
pwd                     # Show current directory

# List files and directories
ls                      # List files
ls -la                  # List all files (including hidden) with details
ls -lh                  # List with human-readable file sizes
ls -t                   # List sorted by modification time

Windows (PowerShell/Command Prompt):

# Navigate directories
cd ~                    # Go to home directory (PowerShell)
cd %USERPROFILE%        # Go to home directory (Command Prompt)
cd C:\path\to\directory # Go to specific directory
pwd                     # Show current directory (PowerShell)
cd                      # Show current directory (Command Prompt)

# List files and directories
ls                      # List files (PowerShell)
dir                     # List files (Command Prompt)
dir /a                  # List all files including hidden
Get-ChildItem -Force    # List all files including hidden (PowerShell)

Finding Files

Mac/Linux:

# Find files by name
find /home -name "*.pdf"           # Find all PDF files in /home
find . -type f -name "*.log"       # Find log files in current directory
find /usr -type l                  # Find symbolic links

# Find files by other criteria
find . -type f -size +1M           # Find files larger than 1MB
find . -mtime -7                   # Find files modified in last 7 days
find . -maxdepth 3 -type d         # Find directories up to 3 levels deep

Windows:

# PowerShell - Find files by name
Get-ChildItem -Path C:\Users -Filter "*.pdf" -Recurse
Get-ChildItem -Path . -Filter "*.log" -Recurse
dir *.pdf /s                       # Command Prompt - recursive search

# Find files by other criteria
Get-ChildItem -Recurse | Where-Object {$_.Length -gt 1MB}  # Files > 1MB
Get-ChildItem -Recurse | Where-Object {$_.LastWriteTime -gt (Get-Date).AddDays(-7)}  # Last 7 days

Counting & Statistics

Mac/Linux:

# Count files
find . -name "*.pdf" | wc -l       # Count PDF files
ls -1 | wc -l                      # Count items in current directory

# File and directory sizes
du -sh ~/Documents                 # Total size of Documents directory
du -h --max-depth=1 /usr | sort -rh  # Size of subdirectories, largest first
ls -lah                            # List files with sizes

Windows:

# Count files (PowerShell)
(Get-ChildItem -Filter "*.pdf" -Recurse).Count
(Get-ChildItem).Count              # Count items in current directory

# File and directory sizes
Get-ChildItem -Recurse | Measure-Object -Property Length -Sum  # Total size
dir | sort length -desc            # Sort by size (Command Prompt)

Mac/Linux:

# Search within files
grep -r "error" /var/log           # Search for "error" recursively
grep -c "hello" file.txt           # Count occurrences of "hello"
grep -n "pattern" file.txt         # Show line numbers with matches

# Count lines, words, characters
wc -l file.txt                     # Count lines
wc -w file.txt                     # Count words
cat file.txt | grep "the" | wc -l  # Count lines containing "the"

Windows:

# Search within files (PowerShell)
Select-String -Path "C:\logs\*" -Pattern "error" -Recurse
(Select-String -Path "file.txt" -Pattern "hello").Count
Get-Content file.txt | Select-String -Pattern "the" | Measure-Object

# Command Prompt
findstr /s "error" C:\logs\*       # Search for "error" recursively
find /c "the" file.txt             # Count occurrences of "the"

System Information

Mac/Linux:

# System stats
df -h                              # Disk space usage
free -h                            # Memory usage (Linux)
system_profiler SPHardwareDataType # Hardware info (Mac)
uptime                             # System uptime
who                                # Currently logged in users

# Process information
ps aux                             # List all processes
ps aux | grep chrome               # Find processes containing "chrome"
ps aux | wc -l                     # Count total processes

Windows:

# System stats (PowerShell)
Get-WmiObject -Class Win32_LogicalDisk | Select-Object Size,FreeSpace
Get-WmiObject -Class Win32_ComputerSystem | Select-Object TotalPhysicalMemory
(Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime  # Uptime
Get-LocalUser                      # User accounts

# Process information
Get-Process                        # List all processes
Get-Process | Where-Object {$_.Name -like "*chrome*"}  # Find chrome processes
(Get-Process).Count                # Count total processes

# Command Prompt alternatives
wmic logicaldisk get size,freespace  # Disk space
tasklist                           # List processes
tasklist | find "chrome"           # Find chrome processes

File Permissions & Properties

Mac/Linux:

# File permissions and details
ls -l filename                     # Detailed file information
stat filename                     # Comprehensive file statistics
file filename                     # Determine file type

# Find files by permissions
find . -type f -readable           # Find readable files
find . -type f ! -executable       # Find non-executable files

Windows:

# File details (PowerShell)
Get-ItemProperty filename          # Detailed file information
Get-Acl filename                   # File permissions
dir filename                       # Basic file info (Command Prompt)

# File attributes
Get-ChildItem | Where-Object {$_.Attributes -match "ReadOnly"}  # Read-only files

Network & Hardware

Mac/Linux:

# Network information
ip addr show                       # Show network interfaces (Linux)
ifconfig                          # Network interfaces (Mac/older Linux)
networksetup -listallhardwareports # Network interfaces (Mac)
cat /proc/cpuinfo                 # CPU information (Linux)
system_profiler SPHardwareDataType # Hardware info (Mac)

Windows:

# Network information (PowerShell)
Get-NetAdapter                     # Network interfaces
ipconfig                          # IP configuration (Command Prompt)
Get-WmiObject Win32_Processor      # CPU information
Get-ComputerInfo                   # Comprehensive system info

Platform-Specific Tips

Mac/Linux Users:

  • Your home directory is ~ or $HOME
  • Hidden files start with a dot (.)
  • Use man command for detailed help
  • Try which command to find where a command is located

Windows Users:

  • Your home directory is %USERPROFILE% (Command Prompt) or $env:USERPROFILE (PowerShell)
  • Hidden files have the hidden attribute (use dir /ah to see them)
  • Use Get-Help command in PowerShell or help command in Command Prompt for detailed help
  • Try where command to find where a command is located

Universal Tips:

  • Use Tab completion to avoid typing long paths
  • Most shells support command history (up arrow or Ctrl+R)
  • Combine commands with pipes (|) to chain operations
  • Search online for "[command name] [your OS]" for specific examples

Hello Git!

About This Module

This module introduces version control concepts and Git fundamentals for individual development workflow. You'll learn to track changes, create repositories, and use GitHub for backup and sharing. This foundation prepares you for collaborative programming and professional development practices.

Prework

Read through this module.

If you're on Windows, install git from https://git-scm.com/downloads. You probably already did this to use git-bash for the Shell class activity.

MacOS comes pre-installed with git.

From your Home or projects directory in a terminal or cmd, run the command:

git clone https://github.com/cdsds210/simple-repo.git

If it is the first time, it may ask you to login or authenticate.

Ultimately, you want to cache your GitHub credentials locally on your computer. This page gives instructions.

Optionally you can browse through these Git references:

Pre-lecture Reflections

Before class, consider these questions:

  1. Why is version control essential for any programming project?
  2. How does Git differ from simply making backup copies of files?
  3. What problems arise when multiple people work on the same code without version control?
  4. How might Git help you track your learning progress in this course?
  5. What's the difference between Git (the tool) and GitHub (the service)?

Learning Objectives

By the end of this module, you should be able to:

  • Understand why version control is critical for programming
  • Configure Git for first-time use
  • Create repositories and make meaningful commits
  • Connect local repositories to GitHub
  • Use the basic Git workflow for individual projects
  • Recover from common Git mistakes

You may want to follow along with the git commands in your own environment during the lecture.

Why Version Control Matters

The Problem Without Git:

my_project.rs
my_project_backup.rs
my_project_final.rs
my_project_final_REALLY_FINAL.rs
my_project_broken_trying_to_fix.rs
my_project_working_maybe.rs

The Solution With Git:

git log --oneline
a1b2c3d Fix input validation bug
e4f5g6h Add error handling for file operations
h7i8j9k Implement basic calculator functions
k1l2m3n Initial project setup

Key Benefits:

  • Never lose work: Complete history of all changes
  • Fearless experimentation: Try new ideas without breaking working code
  • Clear progress tracking: See exactly what changed and when
  • Professional workflow: Essential skill for any programming job
  • Backup and sharing: Store code safely in the cloud

Core Git Concepts

Repository (Repo): A folder tracked by Git, containing your project and its complete history.

Commit: A snapshot of your project at a specific moment, with a message explaining what changed.

The Three States:

  1. Working Directory: Files you're currently editing
  2. Staging Area: Changes prepared for next commit
  3. Repository: Committed snapshots stored permanently

The Basic Workflow:

Edit files → Stage changes → Commit snapshot
     (add)      (commit)

Push: Uploads your local commits to a remote repository (like GitHub). Takes your local changes and shares them with others.

Local commits → Push → Remote repository

Pull: Downloads commits from a remote repository and merges them into your current branch. Gets the latest changes from others.

Remote repository → Pull → Local repository (updated)

Merge: Combines changes from different branches. Takes commits from one branch and integrates them into another branch.

Feature branch + Main branch → Merge → Combined history

Pull Request (PR): A request to merge your changes into another branch, typically used for code review. You "request" that someone "pull" your changes into the main codebase.

Your branch → Pull Request → Review → Merge into main branch

Git Branching

Lightweight Branching:

Git's key strength is efficient branching and merging:

  • Main branch: Usually called main (or master in older repos)
  • Feature branches: Created for new features or bug fixes

module014_1.png

Branching Benefits:

  • Isolate experimental work
  • Enable parallel development
  • Facilitate code review process
  • Support different release versions

Essential Git Commands

Here are some more of those useful shell commands!

One-Time Setup

# Configure your identity (use your real name and email)
git config --global user.name "Your Full Name"
git config --global user.email "your.email@example.com"

If you don't want to publish your email in all your commits on GitHub, then highly recommended to get a "no-reply" email address from GitHub. Here are directions.

# Set default branch name
git config --global init.defaultBranch main

Note: The community has moved away from master as the default branch name, but it may still be default in some installations.

# Verify configuration
git config --list

Starting a New Project

# Create project directory
mkdir my_rust_project
cd my_rust_project

# Initialize Git repository
git init

# Check status
git status

Daily Git Workflow (without GithHub)

# Create a descriptive branch name for the change you want to make
git checkout -b topic_branch

# Check what's changed
git status                    # See current state
git diff                      # See specific changes

# make edits to, for example filename.rs

# Stage changes for commit
git add filename.rs          # Add specific file
git add .                    # Add all changes in current directory

# Create commit with a comment
git commit -m "Add calculator function"

# View history
git log                      # Full commit history
git log --oneline           # Compact view

# View branches
git branch

# Switch back to main
git checkout main

# Merge topic branch back into main
git merge topic_branch

# Delete the topic branch when finished
git branch -d topic_branch

Writing Good Commit Messages

The Golden Rule: Your commit message should complete this sentence: "If applied, this commit will [your message here]"

Good Examples:

git commit -m "Add input validation for calculator"
git commit -m "Fix division by zero error"
git commit -m "Refactor string parsing for clarity"
git commit -m "Add tests for edge cases"

Bad Examples:

git commit -m "stuff"           # Too vague
git commit -m "fixed it"        # What did you fix?
git commit -m "more changes"    # Not helpful
git commit -m "asdfjkl"        # Meaningless

Commit Message Guidelines:

  1. Start with a verb: Add, Fix, Update, Remove, Refactor
  2. Be specific: What exactly did you change?
  3. Keep it under 50 characters for the first line
  4. Use present tense: "Add function" not "Added function"

Working with GitHub

Why GitHub?

  • Remote backup: Your code is safe in the cloud
  • Easy sharing: Share projects with instructors and peers
  • Portfolio building: Showcase your work to employers
  • Collaboration: Essential for team projects

Connecting to GitHub:

# Create repository on GitHub first (via web interface)
# Then connect your local repository:

git remote add origin https://github.com/yourusername/repository-name.git
git branch -M main
git push -u origin main

Note: The above instructions are provided to you by GitHub when you create an empty repository.

# Check remote connection
git remote -v

# Clone existing repository
git clone https://github.com/username/repository.git
cd repository

# Pull any changes from GitHub
git pull

# Push your commits to GitHub
git push

Daily GitHub Workflow

# Create a descriptive branch name for the change you want to make
git checkout -b topic_branch

# Check what's changed
git status                    # See current state
git diff                      # See specific changes

# make edits to, for example filename.rs

# Stage changes for commit
git add filename.rs          # Add specific file
git add .                    # Add all changes in current directory

# Create commit with a comment
git commit -m "Add calculator function"

# View history
git log                      # Full commit history
git log --oneline           # Compact view

# View branches
git branch

# Run local validation tests on changes

# Push to GitHub
git push origin topic_branch

# Create a Pull Request on GitHub

# Repeat above to make any changes from PR review comments

# When done, merge PR to main on GitHub

git checkout main

git pull

# Delete the topic branch when finished
git branch -d topic_branch

Git for Homework

Recommended Workflow:

# Start new assignment
cd ~/ds210/assignments
mkdir assignment_01
cd assignment_01
git init

# Make initial commit
touch README.md
echo "# Assignment 1" > README.md
git add README.md
git commit -m "Initial project setup for Assignment 1"

# Work and commit frequently
# ... write some code ...
git add src/main.rs
git commit -m "Implement basic data structure"

# ... write more code ...
git add src/main.rs
git commit -m "Add error handling"

# ... add tests ...
git add tests/
git commit -m "Add comprehensive tests"

# Final commit before submission
git add .
git commit -m "Final submission version"

Best Practices for This Course:

  • Commit early and often: We expect to see a minimum of 3-5 commits per assignment
  • One logical change per commit: Each commit should make sense on its own
  • Meaningful progression: Your commit history should tell the story of your solution
  • Clean final version: Make sure your final commit has working, clean code

Common Git Scenarios

"I made a mistake in my last commit message"

git commit --amend -m "Corrected commit message"

"I forgot to add a file to my last commit"

git add forgotten_file.rs
git commit --amend --no-edit

"I want to undo changes I haven't committed yet"

git checkout -- filename.rs    # Undo changes to specific file
git reset --hard HEAD          # Undo ALL uncommitted changes (CAREFUL!)

"I want to see what changed in a specific commit"

git show commit_hash           # Show specific commit
git log --patch               # Show all commits with changes

Understanding .gitignore

What NOT to Track: Some files should never be committed to Git:

# Rust build artifacts
/target/
Cargo.lock    # Ignore for libraries, not applications

# IDE files
.vscode/settings.json
.idea/
*.swp

# OS files
.DS_Store
Thumbs.db

# Personal notes
notes.txt
TODO.md

Creating .gitignore:

# Create .gitignore file
touch .gitignore
# Edit with your preferred editor to add patterns above

# Commit the .gitignore file
git add .gitignore
git commit -m "Add .gitignore for Rust project"

Resources for learning more and practicing

  • A gamified tutorial for the basics: https://ohmygit.org/
  • Interactive online Git tutorial that goes a bit deper: https://learngitbranching.js.org/
  • A downloadable app with tutorials and challenges: https://github.com/jlord/git-it-electron
  • Another good tutorial (examples in ruby): https://gitimmersion.com/
  • Pro Git book (free online): https://git-scm.com/book/en/v2

GitHub Collaboration Challenge

Form teams of three people.

Follow these instructions with your teammates to practice creating a GitHub repository, branching, pull requests (PRs), review, and merging. Work in groups of three—each person will create and review a pull request.

1. Create and clone the repository (≈3 min)

  1. Choose one teammate to act as the repository lead.
    • They should log in to GitHub, click the “+” menu in the upper‑right and select New repository.
    • Call the repository "github-class-challenge", optionally add a description, make the visibility public, check “Add a README,” and
    • click Create repository.
    • Go to Settings/Collaborators and add your teammates as developers with write access.
  2. Each team member needs a local copy of the repository. On the repo’s main page, click Code, copy the HTTPS URL, open a terminal, navigate to the folder where you want the project, and run:
git clone <repo‑URL>

Cloning creates a full local copy of all files and history.


2. Create your own topic branch (≈2 min)

A topic branch lets you make changes without affecting the default main branch. GitHub recommends using a topic branch when making a pull request.

On your local machine:

git checkout -b <your‑first‑name>-topic
git push -u origin <your‑first‑name>-topic  # creates the branch on GitHub

Pick a branch name based on your first name (for example alex-topic).


3. Add a personal file, commit and push (≈5 min)

  1. In your cloned repository (on your topic branch), create a new text file named after yourself—e.g., alex.txt. Write a few sentences about yourself (major, hometown, a fun fact).

  2. Stage and commit the file:

    git add alex.txt
    git commit -m "Add personal bio"
    

    Good commit messages explain what changed.

  3. Push your commit to GitHub:

    git push
    

4. Create a pull request (PR) for your teammates to review (≈3 min)

  1. On GitHub, click Pull requests → New pull request.
  2. Set the base branch to main and the compare branch to your topic branch.
  3. Provide a clear title (e.g. “Add Alex’s bio”) and a short description of what you added. Creating a pull request lets your collaborators review and discuss your changes before merging them.
  4. Request reviews from your two teammates.

5. Review your teammates’ pull requests (≈4 min)

  1. Open each of your teammates’ PRs.
  2. On the Conversation or Files changed tab, leave at least one constructive comment (ask a question or suggest something you’d like them to add). You can comment on a specific line or leave a general comment.
  3. Submit your review with the Comment option. Pull request reviews can be comments, approvals, or requests for changes; you’re only commenting at this stage.

6. Address feedback by making another commit (≈3 min)

  1. Read the comments on your PR. Edit your text file locally in response to the feedback.

  2. Stage, commit, and push the changes:

    git add alex.txt
    git commit -m "Address feedback"
    git push
    

    Any new commits you push will automatically update the open pull request.

  3. Reply to the reviewer’s comment in the PR, explaining how you addressed their feedback.


7. Approve and merge pull requests (≈3 min)

  1. After each PR author has addressed the comments, revisit the PRs you reviewed.
    • Click Review changes → Approve to approve the updated PR.
  2. Once a PR has at least one approval, a teammate other than the author should merge it.
    -In the PR, scroll to the bottom and click Merge pull request, then Confirm merge.
  3. Delete the topic branch when prompted; keeping the branch list tidy is good practice.

Each student should merge one of the other students’ PRs so everyone practices.


8. Capture a snapshot for submission (≈3 min)

  1. One teammate downloads a snapshot of the final repository. On the repo’s main page, click Code → Download ZIP. GitHub generates a snapshot of the current branch or commit.
  2. Open the Commits page (click the “n commits” link) and take a screenshot showing the commit history.
  3. Go to Pull requests → Closed, and capture a screenshot showing the three closed PRs and their approval status. You can also use the Activity view to see a detailed history of pushes, merges, and branch changes.
  4. Upload the ZIP file and screenshots to Gradescope.

Tips

  • Use descriptive commit messages and branch names.
  • Each commit is a snapshot; keep commits focused on a single change.
  • Be polite and constructive in your feedback.
  • Delete merged branches to keep your repository clean.

This exercise walks you through the entire GitHub flow—creating a repository, branching, committing, creating a PR, reviewing, addressing feedback, merging, and capturing a snapshot. Completing these steps will help you collaborate effectively on future projects.

Hello Rust!

About This Module

This module provides your first hands-on experience with Rust programming. You'll write actual programs, understand basic syntax, and see how Rust's compilation process works. We'll focus on building confidence through practical programming while comparing key concepts to Python.

Prework

Prework Readings

Review this module.

Read the following Rust basics:

Optionally browse:

Pre-lecture Reflections

Before class, consider these questions:

  1. How does compiling code differ from running Python scripts directly?
  2. What might be the advantages of catching errors before your program runs?
  3. How does Rust's println! macro compare to Python's print() function?
  4. Why might explicit type declarations help prevent bugs?
  5. What challenges might you face transitioning from Python's flexibility to Rust's strictness?

Topics

  • Installing Rust
  • Compiled vs Interpretted Languages
  • Write and compile our first simple program

Installing Rust

Before we can write Rust programs, we need to install Rust on your system.

From https://www.rust-lang.org/tools/install:

On MacOS:

# Install Rust via rustup
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Question: can you interpret the shell command above?

On Windows:

Download and run rustup-init.exe (64-bit).

It will ask you some questions.

Download Visual Studio Community Edition Installer.

Open up Visual Studio Community Edition Installer and install the C++ core desktop features.

Verify Installation

From MacOS terminal or Windows CMD or PowerShell

rustc --version    # Should show Rust compiler version
cargo --version    # Should show Cargo package manager version
rustup --version   # Should show Rustup toolchain installer version

Troubleshooting Installation:

# Update Rust if already installed
rustup update

# Check which toolchain is active
rustup show

# Reinstall if needed (a last resort!!)
rustup self uninstall
# Then reinstall following installation steps above

Write and compile simple Rust program

Generally you would create a project directory for all your projects and then a subdirectory for each project.

Follow along now if you have Rust installed, or try at your first opportunity later.

$ mkdir ~/projects
$ cd ~/projects
$ mkdir hello_world
$ cd hello_world

All Rust source files have the extension .rs.

Create and edit a file called main.rs.

For example with the nano editor on MacOS

# From MacoS terminal
nano main.rs

or notepad on Windows

# From Windows CMD or PowerShell
notepad main.rs

and add the following code:

fn main() {
    println!("Hello, world!");
}

Note: Since our course notes are in mdbook, code cells like above can be executed right from the notes!

In many cases we make the code cell editable right on the web page!

If you created that file on the command line, then you compile and run the program with the following commands:

$ rustc main.rs    # compile with rustc which creates an executable

If it compiled correctly, you should have a new file in your directory

For example on MacOS or Linux you might see:

hello_world  % ls -l
total 880
-rwxr-xr-x  1 tgardos  staff  446280 Sep 10 21:03 main
-rw-r--r--  1 tgardos  staff      45 Sep 10 21:02 main.rs

Question: What is the new file? What do you observe about the file properties?

On Windows you'll see main.exe.

$ ./main           # run the executable
Hello, world!

Compiled (e.g. Rust) vs. Interpreted (e.g. Python)

Python: One Step (Interpreted)

python hello.py
  • Python reads your code line by line and executes it immediately
  • No separate compilation step needed

Rust: Two Steps (Compiled)

# Step 1: Compile (translate to machine code)
rustc hello.rs 

# Step 2: Run the executable
./hello
  • rustc is your compiler
  • rustc translates your entire program to machine code
  • Then you run the executable (why ./?)

The main() function

fn main() { ... }

is how you define a function in Rust.

The function name main is reserved and is the entry point of the program.

The println!() Macro

Let's look at the single line of code in the main function:

    println!("Hello, world!");

Rust convention is to indent with 4 spaces -- never use tabs!!

  • println! is a macro which is indicated by the ! suffix.
  • Macros are functions that are expanded at compile time.
  • The string "Hello, world!" is passed as an argument to the macro.

The line ends with a ; which is the end of the statement.

More Printing Tricks

Let's look at a program that prints in a bunch of different ways.

// A bunch of the output routines
fn main() {
    let x = 9;
    let y = 16;
    
    print!("Hello, DS210!\n");       // Need to include the newline character
    println!("Hello, DS210!\n");     // The newline character here is redundant

    println!("{} plus {} is {}", x, y, x+y);  // print with formatting placeholders
    //println!("{x} plus {y} is {x+y}");      // error: cannot use `x+y` in a format string
    println!("{x} plus {y} is {}\n", x+y);      // but you can put variable names in the format string
}

More on println!

  • first parameter is a format string
  • {} are replaced by the following parameters

print! is similar to println! but does not add a newline at the end.

To dig deeper on formatting strings:

Input Routines

Here's a fancier program. You don't have to worry about the details, but paste it into a file name.rs, run rustc name.rs and then ./name.

// And some input routines
// So this is for demo purposes
use std::io;
use std::io::Write;

fn main() {
    let mut user_input = String::new();
    print!("What's your name? ");
    io::stdout().flush().expect("Error flushing");  // flush the output and print error if it fails
    let _ =io::stdin().read_line(&mut user_input);  // read the input and store it in user_input
    println!("Hello, {}!", user_input.trim());
}

Project manager: cargo

Rust comes with a very helpful project and package manager: cargo

  • create a project: cargo new PROJECT-NAME

  • main file will be PROJECT-NAME/src/main.rs

  • to run: cargo run

  • to just build: cargo build

Cargo example

~ % cd ~/projects 

projects % cargo new cargo-hello
    Creating binary (application) `cargo-hello` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

projects % cd cargo-hello 

cargo-hello % tree
.
├── Cargo.toml
└── src
    └── main.rs

2 directories, 2 files

cargo-hello % cargo run
   Compiling cargo-hello v0.1.0 (/Users/tgardos/projects/cargo-hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
     Running `target/debug/cargo-hello`
Hello, world!

% tree -L 3
.
├── Cargo.lock
├── Cargo.toml
├── src
│   └── main.rs
└── target
    ├── CACHEDIR.TAG
    └── debug
        ├── build
        ├── cargo-hello
        ├── cargo-hello.d
        ├── deps
        ├── examples
        └── incremental

8 directories, 6 files

Cargo --release

By default, cargo makes a slower debug build that has extra debugging information.

We'll see more about that later.

Add --release to create a "fully optimized" version:

  • longer compilation
  • faster execution
  • some runtime checks not included (e.g., integer overflow)
  • debuging information not included
  • the executable in a different folder
cargo-hello (master) % cargo build --release
  Compiling cargo-hello v0.1.0 (/Users/tgardos/projects/cargo-hello)
   Finished `release` profile [optimized] target(s) in 0.38s
(.venv) √ cargo-hello (master) % tree -L 2
.
├── Cargo.lock
├── Cargo.toml
├── src
│   └── main.rs
└── target
   ├── CACHEDIR.TAG
   ├── debug
   └── release

5 directories, 4 files

Cargo check

If you just want to check if your current version compiles: cargo check

  • Much faster for big projects

Hello Rust Activity

  • Get in groups of 3+

  • Place the lines of code in order in two parts on the page: your shell, and your code file main.rs to make a reasonable sequence and functional code.

  • We'll take the last 5 minutes to share solutions

  • Don't stress if you didn't finish! Just paste what you have into GradeScope.

println!("Good work! Average: {:.1}", average);

cargo run

scores.push(88);

git push -u origin main

let average = total as f64 / scores.len() as f64;

cargo new hello_world

} else if average >= 80.0 {

nano src/main.rs

let total: i32 = scores.iter().sum();

if average >= 90.0 {

touch README.md

cd hello_world

fn main() {

git add src/main.rs

println!("Keep trying! Average: {:.1}", average);

let mut scores = vec![85, 92, 78, 96];

ls -la

echo "This is a grade average calculator" > README.md

} else {

git commit -m "Add calculator functionality"

}}

println!("Excellent! Average: {:.1}", average);

Overview of Programming languages

Learning Objectives

  • Programming languages
    • Describe the differences between a high level and low level programming language
    • Describe the differences between an interpreted and compiled language
    • Describe the differences between a static and dynamically typed language
    • Know that there are different programming paradigms such as imperative and functional
    • Describe the different memory management techniques
    • Be able to identify the the properties of a particular language such as rust.

Various Language Levels

  • Native code

    • usually compiled output of a high-level language, directly executable on target processor
  • Assembler

    • low-level but human readable language that targets processor
    • pros: as fine control as in native code
    • cons: not portable
  • High level languages

    • various levels of closeness to the architecture: from C to Prolog
    • efficiency:
      • varies
      • could optimize better
    • pros:
      • very portable
      • easier to build large projects
    • cons:
      • some languages are resource–inefficient

Assembly Language Examples

  ARM                          X86
. text                       section .text
.global _start                 global _start
_start:                      section .data
   mov r0, #1                msg db  'Hello, world!',0xa
   ldr r1, =message          len equ 0xe
   ldr r2, =len              section .text
   mov r7, #4                _start:
   swi 0                     mov edx,len ;message length
   mov r7, #1                mov ecx,msg ;message to write
                             mov ebx,1   ;file descriptor (stdout)
.data.                       mov eax,4   ;system call number (sys_write)
message:                     int 0x80    ;call kernel
   .asciz "hello world!\n"   mov ebx,0   ;process' exit code
len = .-message.             mov eax,1   ;system call number (sys_exit)
                             int 0x80    ;call kernel - this interrupt won't return

Interpreted vs. compiled

Interpreted:

  • An application (interpreter) reads commands one by one and executes them.
  • One step process to run an application:
    • python hello.py

("Fully") Compiled:

  • Translated to native code by compiler
  • Usually more efficient
  • Two steps to execute:
    1. Compile (Rust: rustc hello.rs)
    2. Run (Rust: ./hello)

Compiled to Intermediate Representation (IR):

  • Example: Java
    • Portable intermediate format
    • Needs another application, Java virtual machine, that knows how to interpret it
  • Example: Python
    • Under some circumstances Python bytecode is created and cached in __pycache__
    • Python bytecode is platform independent and executed by the Python Virtual Machine

Just-in-Time (JIT) compilation is an interesting wrinkle in that it can take interpreted and intermediate format languages and compile them down to machine code.

Type checking: static vs. dynamic

Dynamic (e.g., Python):

  • checks if an object can be used for specific operation during runtime
  • pros:
    • don't have to specify the type of object
    • procedures can work for various types
    • faster or no compilation
  • cons:
    • slower at runtime
    • problems are detected late

Consider the following python code.

def add(x,y):
    return x + y

print(add(2,2))
print(add("a","b"))
print(add(2,"b"))
    4
    ab

    ---------------------------------------------------------------------------

    TypeError                                 Traceback (most recent call last)

    Cell In[1], line 6
          4 print(add(2,2))
          5 print(add("a","b"))
    ----> 6 print(add(2,"b"))


    Cell In[1], line 2, in add(x, y)
          1 def add(x,y):
    ----> 2     return x + y


    TypeError: unsupported operand type(s) for +: 'int' and 'str'

There is optional typing specification, but it is not enforced, e.g. accepting ints.

import typing
def add(x:str, y:str) -> str:
    return x + y
print(add(2,2))    # doesn't complain about getting integer types
print(add("ab", "cd"))
#print(add(2,"n"))
    4
    abcd
  • You can use packages such as pyright or mypy as a type checker before running your programs
  • Supported by VSCode python extension

Type checking: static vs. dynamic

Static (e.g, C++, Rust, OCaml, Java):

  • checks if types of objects are as specified
  • pros:
    • faster at runtime
    • type mismatch detected early
  • cons:
    • often need to be explicit with the type
    • making procedures generic may be difficult
    • potentially slower compilation

C++:

int add(int x, int y) {
    return x + y;
}

Rust:

#![allow(unused)]
fn main() {
fn add(x:i32, y:i32) -> i32 {
    x + y
}
}

Type checking: static vs. dynamic

Note: some languages are smart and you don't have to always specify types (e.g., OCaml, Rust)

Rust:

#![allow(unused)]
fn main() {
let x : i32 = 7;
let y = 3;    // Implied to be default integer type
let z = x * y;  // Type of result derived from types of operands
}

Various programming paradigms

  • Imperative
  • Functional
  • Object-oriented
  • Declarative / programming in logic

Imperative

im·per·a·tive (adjective) -- give an authoritive command

# Python -- Imperative
def factorial(N):
    ret = 1
    for i in range(N):
        ret = ret * i
    return ret

Functional

; Scheme, a dialect of lisp -- functional

(define (factorial n) (cond ((= n 0) 1) 
                            (t (* n (factorial (- n 1)))))) 

Object Oriented

// C++ -- Object oriented pattern
class Factorial {
   private:
     int64 value;
   public:
     int64 factorial(int input) {
        int64 temp = 1;
        for(int i=1; i<=input; i++) {
            temp = temp * i;
        }
        value = temp
     }
     int64 get_factorial() {
        return value;
     }
}

Declarative/Logic

% Prolog -- declaritive / programming in logic
factorial(0,1).      % Base case
factorial(N,M) :-
    N>0,             % Ensure N is greater than 0
    N1 is N-1,       % Decrement N
    factorial(N1, M1),  % Recursive call
    M is N * M1.     % Calculate factorial

Memory management: manual vs. garbage collection

At least 3 kinds:

  1. Manual (e.g. C, C++)
  2. Garbage collection (e.g. Java, Python)
  3. Ownership-based (e.g. Rust)

Manual

  • Need to explicitly ask for memory and return it
  • pros:
    • more efficient
    • better in real–time applications
  • cons:
    • more work for the programmer
    • more prone to errors
    • major vector for attacks/hacking

Example below in C++.

Garbage collection

  • Memory freed automatically
  • pros:
    • less work for the programmer
    • more difficult to make mistakes
  • cons:
    • less efficient
    • can lead to sudden slowdowns

Ownership-Based

  • Keeps track of memory object ownership
    • Allows borrowing, references without borrowing, move ownership
  • When object goes out of scope, Rust automatically deallocates
  • Managed deterministically at compile-time, not run-time like garbage collection

We'll dive deeper into Rust ownership later.

Rust Language (Recap)

  • high–level

  • imperative

  • compiled

  • static type checking

  • ownership-based memory management

Most important difference between Python and Rust?

How do we denote blocks of code?

  • Python: indentation
  • Rust: {...}
Languageformattingscoping
Pythonindentationindentation
Rustindentationbraces, {}

Example in Rust

#![allow(unused)]
fn main() {
fn hi() {
    println!("Hello!");
    println!("How are you?");
}
}

Don't be afraid of braces!!! You'll encounter them in C, C++, Java, Javascript, PHP, Rust, ...

Memory Structure of an Executable Program

It's very helpful to have conceptual understanding of how memory is structured in executable programs.

The figure below illustrates a typical structure, where some low starting memory address is at the bottom and then memory addresses increase as you go up in the figure.

Program Memory

Here's a short description of each section starting from the bottom:

  1. text -- the code, e.g. program instructions
  2. initialized data -- explicitly initialized global/static variables
  3. uninitialized data (bss) -- uninitialized global/static variables, generally auto-initialied to zero. BSS -- Block Started by Symbol
  4. heap -- dynamically allocated memory. grows as structures are allocated
  5. stack -- used for local variables and function calls

Example of unsafe programming in C

Let's take a look at the problem with the following C program which asks you to guess a string and hints whether your guess was lexically less or greater.

  • Copy the code into a file unsafe.c
  • Compile with a local C compiler, for example, cc unsafe.c
  • Execute program, e.g. ./a.out

Try with the following length guesses:

  1. guesses of string length <= 20
  2. guesses of string length > 20
  3. guesses of string length >> 20

Pay attention to the printout of secretString!

Lecture Note: Switch to code

#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int main(){
    char loop_bool[20];
    char secretString[20];
    char givenString[20];
    char x;
    int i, ret;

    memset(&loop_bool, 0, 20);
    for (i=0;i<19;i++) {
      x = 'a' + random() % 26; 
      secretString[i] = x;
    }
    printf("secretString: %s\n", secretString);
    while (!loop_bool[0]) { 
        gets(givenString);
        ret = strncmp(secretString, givenString, 20);
        if (0 == ret) {
            printf("SUCCESS!\n");
	    break;
	}else if (ret < 0){
	    printf("LESS!\n");
	} else {
	    printf("MORE!\n");
        }
        printf("secretString: %s\n", secretString);
    }
    printf("secretString: %s\n", secretString);
    printf("givenString: %s\n", givenString);
    return 0;
}

A Brief Aside -- The people behind the languages

Who are these people?

  • Guido Van Rossum
  • Graydon Hoare
  • Bjarne Stroustrup
  • James Gosling
  • Brendan Eich
  • Brian Kernighan and Dennis Ritchie

Who are these people?

  • Guido Van Rossum -- Python
  • Graydon Hoare -- Rust
  • Bjarne Stroustrup -- C++
  • James Gosling -- Java
  • Brendan Eich -- Javascript
  • Brian Kernighan and Dennis Ritchie -- C

Recap

Guessing Game Part 1

Building a very small Rust application.

Guessing Game Part 1

We're going to build on "Hello Rust" to write a small guessing game program.

You're not expected to understand all the details of all the code, but rather start getting familiar with the language and with building applications.

Let's eat the cake 🍰 and then we'll learn the recipe👨‍🍳.

Tip: Follow along in your terminal or PowerShell window.

Learning objectives:

By the end of this module you should be able to:

  • Use basic cargo commands to create projects and compile rust code
  • Add external dependencies (crates) to a project
  • Recognize some useful syntax like Rust's Result type with .expect()
  • Recognize and fix some common Rust compilation errors

Keep Practicing with the Terminal

  • This is Part 1 where we use the terminal
  • In Part 2, we will start using VSCode which integrates
    • code editor
    • terminal window
    • compiler hints
    • AI assistance

Guessing game demo

Compiling review and reference

Option 1: Compile directly

  • put the content in file hello.rs
  • command line:
    • navigate to this folder
    • rustc hello.rs
    • run ./hello or hello.exe

Option 2: Use Cargo

  • create a project: cargo new PROJECT-NAME
  • main file will be PROJECT-NAME/src/main.rs
  • to build and run: cargo run
  • the machine code will be in : ./target/debug/PROJECT-NAME

Different ways to run Cargo

  • cargo run compiles, runs, and saves the binary/executable in /target/debug
  • cargo build compiles but does not run
  • cargo check checks if it compiles (fastest)
  • cargo run --release creates (slowly) "fully optimized" binary in /target/release

Back to the guessing game

In MacOS terminal or Windows PowerShell, go to the folder you created to hold all your projects:

cd ~/projects

Let's use cargo to create a project:

cargo new guessing-game

Replace the contents of src/main.rs with:

use std::io;

fn main() {
    println!("Guess the number!");
    println!("Please input your guess.");

    let mut guess = String::new();

    // This is all technically one line of code
    io::stdin()
      .read_line(&mut guess)
      .expect("Failed to read line");

    println!("You guessed: {}", guess);
}

And then:

cargo run

Question: Program doesn't do much? How can we improve it?

More on variables

Let's take a look at this variable assignment:

#![allow(unused)]
fn main() {
    let mut guess = String::new();
}

As we saw in the earlier module, we assign a variable with let as in

#![allow(unused)]
fn main() {
let count = 5;
}

But by default Rust variables are immutable.

Definition:
im·mu·ta·ble
adjective
unchanging over time or unable to be changed
"an immutable fact"

Try executing the following code cell.

fn main() {
  let count = 5;
  count = 7;
}

Rust compiler errors are pretty descriptive!

error[E0384]: cannot assign twice to immutable variable `count`
 --> src/main.rs:4:1
  |
3 | let count = 5;
  |     ----- first assignment to `count`
4 | count = 7;
  | ^^^^^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
3 | let mut count = 5;
  |     +++

For more information about this error, try `rustc --explain E0384`.
error: could not compile `playground` (bin "playground") due to 1 previous error

It often even tells you how to correct the error with the mut keyword to make the variable mutable.

#![allow(unused)]
fn main() {
let mut count = 5;
count = 7;
}

Question: Why might it be helpful to have variables be immutable by default?

.expect() - a tricky concept

We'll go into all of this more later, but:

  • read_line() returns a Result type which has two variants - Ok and Err
  • Ok means the operation succeeded, and returns the successful value
  • Err means something went wrong, and it returns the string you passed to .expect()

More in a few future module.

More on macros!

  • A macro is code that writes other code for you / expands BEFORE it compiles.
  • They end with ! like println!, vec!, or panic!

For example, println!("Hello"); roughly expands into

#![allow(unused)]
fn main() {
use std::io::{self, Write};
io::stdout().write_all(b"Hello\n").unwrap();
}

while println!("Name: {}, Age: {}", name, age); expands into

#![allow(unused)]
fn main() {
use std::io::{self, Write};
io::stdout().write_fmt(format_args!("Name: {}, Age: {}\n", name, age)).unwrap();
}

Rust Crates

In Rust, the collection files in a project form a "crate".

You can have:

  • binary or application crate, that you can execute directly, or a
  • library crate, which you can use in your application

Rust makes it super easy to publish and use crates.

See crates.io.

Using crates: generate a random number

We want to add a random number, so we need a way of generating them.

Rust doesn't have a random number generator in its standard library so we will use a crate called rand.

We can do that with the command:

cargo add rand

which will produce an output like...

Output
% cargo add rand
    Updating crates.io index
      Adding rand v0.9.2 to dependencies
             Features:
             + alloc
             + os_rng
             + small_rng
             + std
             + std_rng
             + thread_rng
             - log
             - nightly
             - serde
             - simd_support
             - unbiased
    Updating crates.io index
     Locking 17 packages to latest Rust 1.85.1 compatible versions
      Adding cfg-if v1.0.3
      Adding getrandom v0.3.3
      Adding libc v0.2.175
      Adding ppv-lite86 v0.2.21
      Adding proc-macro2 v1.0.101
      Adding quote v1.0.40
      Adding r-efi v5.3.0
      Adding rand v0.9.2
      Adding rand_chacha v0.9.0
      Adding rand_core v0.9.3
      Adding syn v2.0.106
      Adding unicode-ident v1.0.19
      Adding wasi v0.14.5+wasi-0.2.4
      Adding wasip2 v1.0.0+wasi-0.2.4
      Adding wit-bindgen v0.45.1
      Adding zerocopy v0.8.27
      Adding zerocopy-derive v0.8.27

Take a look at Cargo.toml now.

cat Cargo.toml
[package]
name = "guessing-game-part1"
version = "0.1.0"
edition = "2024"

[dependencies]
rand = "=0.8.5"

Note that the version number is captured.

Also take a look at Cargo.lock.

It's kind of like pip freeze or conda env export in that it fully specifies your environment down to the package versions.

Generate Random Number

So now that we've specified that we will use the rand crate, we add to our main.rs:

#![allow(unused)]
fn main() {
use rand::Rng;
}

after the use std::io, and add right after fn main() {

#![allow(unused)]
fn main() {
let secret_number = rand::rng().random_range(1..=100);
println!("The secret number is: {secret_number}");
}

Run your program. Whaddayathink so far?

Let's Check Guess

Obviously, we better compare the guess to the "secret number".

Add the following code to the end of your main function.

#![allow(unused)]
fn main() {
    if guess == secret_number {
        println!("You win!");
    } else {
        println!("You lose!");
    }
}

And run your program again. 🤔

In-Class Activity: Compiler Error Hints!

This activity is designed to teaching you to to not fear compiler errors and to show you that Rust's error messages are actually quite helpful once you learn to read them!

Please do NOT use VSCode yet! Open your files in nano, TextEdit / Notepad or another plain text editor.

Instructions

The code asks the user for a series of integers, one at a time, then counts the number, sum and average.

But there are four syntax errors in the code.

Working in pairs, fix the syntax errors based on the compiler error messages.

Put a comment (using double backslashes, e.g. \\ comment) either on the line before or at the end of the stating what you changed to fix the error.

Paste the corrected code into Gradescope.

I'll give you a 2 minute warning to wrap up in gradescope and then we'll review the errors.

Again Please do NOT use VSCode yet! It ruins the fun

Setup Instructions

Go to your projects folder and create a new Rust project.

cd ~/projects    # or whatever your main projects folder is called

cargo new compiler-errors

cd compiler-errors

cargo add rand

# quick test of the default project
cargo run

You should see "Hello World!" without any errors.

Starter Code (src/main.rs)

Replace the code in main.rs with the following code.

use std::io::{self, Write};

fn main() {
    println!("Enter integers, one per line. Empty line to finish.")

    let nums: Vec<i32> = Vec::new()

    loop {
        print!("> ");
        io::stdout().flush().unwrap();

        let mut input = String::new();
        if io::stdin().read_line(&mut input).is_err() { return; }

        let trimmed = input.trim();

        if trimmed.is_empty():
          break; 

        match trimmed.parse::<i32>() {
            Ok(n) => nums.push(n),
            Err(_) => println!("Please enter a valid integer."),
        }
    }

    if nums.is_empty() {
        println!("No numbers entered.");
    } else {
        let sum: i32 = nums.iter().sum();
        let avg = sum as f64 / nums.len() as f64;
        println!("Count = {nums.len()}, Sum = {sum}, Average = {avg:.2}");
    }
}
  • Compile the code
  • Read the compiler output, starting from the top
  • Fix the error
  • Repeat...

List of Errors

What did you find?

  • error 1:
  • error 2:
  • error 3:
  • error 4:

Recap

  • Variables in Rust are immutable by default - we need to explicitly mark them as mut to make them mutable
  • The let keyword is used for variable declaration and initialization in Rust
  • Rust has strong error handling with Result types that have Ok and Err variants
  • The .expect() method is used to handle potential errors by unwrapping the Result or panicking with a message
  • Basic I/O in Rust uses the std::io module for reading from stdin and writing to stdout

Topics

  1. Numbering Systems
  2. The Von Neumann Architecture
  3. Memory Hierarchy and Memory Concepts
  4. Trends, Sizes and Costs

Numbering Systems

  • Decimal (0-9) e.g. 1724
  • Binary (0-1) e.g. 0b011000 (24 decimal)
  • Octal (0-7) e.g. 0o131 (89 decimal)
  • Hexadecimal (0-9, A-F) e.g 0x13F (319 decimal)

Converting between numbering systems

For any base b to decimal. Assume number C with digits

Between octal and binary

Every octal digit corresponds to exactly 3 binary digits and the reverse. For example 0o34 = 0b011_100. Traverse numbers right to left and prepend with 0s if necessary.

Between hexadecimal and binary

Every hexadecimal digit corresponds to exactly 4 binary digits and the reverse. For example 0x3A = 0b0011_1010. Traverse numbers right to left and prepend with 0s if necessary.

Between decimal and binary (or any base b)

More complicated. Divide repeatedly by 2 (or the base b) and keep the remainder as the next most significant binary digit. Stop when the division returns 0.

i = 0 
while D > 0:
  C[i] = D % 2 # modulo operator -- or substitute 2 for any base b
  D = D // 2 # floor division -- or substitute 2 for any base b
  i += 1

What about between decimal and octal/hexadecimal

You can use the same logic as for binary or convert to binary and then use the binary to octal/hexadecimal simple conversions

The Von Neuman Architecture

Named after the First Draft of a Report on the EDVAC written by mathematician John von Neuman in 1945.

Most processor architectures are still based on this same model.

von Neumann Architecture

Key Components

  1. Central Processing Unit (CPU):
    • The CPU is the core processing unit responsible for executing instructions and performing computations. It consists of:
    • Control Unit (CU):
      • Directs the operations of the CPU by interpreting instructions and coordinating data flow between the components.
      • Controls the flow of data between the input, memory, and output devices.
    • Arithmetic/Logic Unit (ALU):
      • Performs arithmetic operations (e.g., addition, subtraction) and logical operations (e.g., AND, OR, NOT).
      • Acts as the computational engine of the CPU.
  2. Memory Unit:
    • Stores data and instructions needed for processing.
    • The memory serves as temporary storage for instructions being executed and intermediate data.
    • It communicates with both the CPU and input/output devices.
  3. Input Device:
    • Provides data or instructions to the CPU.
    • Examples include keyboards, mice, and sensors.
    • Data flows from the input device into the CPU for processing.
  4. Output Device:
    • Displays or transmits the results of computations performed by the CPU.
    • Examples include monitors, printers, and actuators.

Also known as the stored program architecture

Both data and program stored in memory and it's just convention which parts of memory contain instructions and which ones contain variables.

Two very special registers in the processor: Program Counter (PC) and Stack Pointer (SP)

PC: Points to the next instruction. Auto-increments by one when instruction is executed with the exception of branch and jmp instructions that explicitly modify it. Branch instructions used in loops and conditional statements. Jmp instructions used in function calls.

SP: Points to beginning of state (parameters, local variables, return address, old stackpointer etc) for current function call.

Intruction Decoding

Use the Program Counter to fetch the next instruction. After fetching you have to decode it, and subsequently to execute it.

Decoding instructions requires that you split the instruction number to the opcode (telling you what to do) and the operands (telling what data to operate one)

Opcode Format

Example from MIPS (Microprocessor without Interlocked Pipeline Stages) Intruction Set Architecture (ISA). MIPS is RISC (Reduced Instruction Set Computer).

The time cost of operations

Assume for example a processor clocked at 2 GHz, e.g. .

  • Executing an instruction ~ 0.5 ns (1 clock cycle)
  • Getting a value (4 bytes) from L1 cache ~1 ns
  • Branch mispredict ~3 ns
  • Getting a value from L2 cache ~4 ns
  • Send 1Kbyte of data over 1Gbps network (just send not arrive) ~ 16 ns
  • Get a value from main memory ~100 ns
  • Read 1MB from main memory sequentially ~1000 ns
  • Compress 1Kbyte (in L1 cache) with zippy ~2000 ns
  • Read 1MB from SSD ~49,000 µs
  • Send a ping pong packet inside a datacenter ~500,000 ns
  • Read 1Mbyte from HDD ~825,000 ns
  • Do an HDD seek ~2,000,000 ns
  • Send a packet from US to Europe and back ~150,000,000 ns

https://samwho.dev/numbers/

The memory hierarchy and memory concepts

We've talked about different kinds of memory. It's helpful to think of it in terms of a hierarchy.

  • As indicated above, registers are closest to the processor and fastest.
  • As you move farther away, the size gets larger but access gets slower

Storage Hierarchy

The following figure from Hennesy and Patterson is also very informative.

Memory Hierarchy From Hennesy and Patterson, Computer Architecture: A Quantitative Approach_.

When the CPU tries to read from a memory location it

  • First checks if that memory location is copied to L1 cache
    • if it is, then the value is returned
    • if it is not...
  • Then checks if the memory location is copied to L2 cache
    • if it is, then the value is copied to L1 cache and returned
    • if it is not...
  • Then checks if the memory location is copied to L3 cache
    • if it is, then the value is copied to L2, then L1 and returned
    • if it is not...
  • Go to main memory
    • fetch a cache line size of data, typically 64 bytes (why?)

More on Caches

  • Each cache line size of memory can be mapped to one of cache slots in each cache
  • we say such a cache is -way
  • if all slots are occupied, then we evict the Least Recently Used (LRU) slots

Cache Mapping

Direct mapped versus 2-way cache mapping. Wikipedia: CPU cache

We can see the cache configuration on a Linux system with the getconf command.

Here's the output from the MOC.

$ getconf -a | grep CACHE
LEVEL1_ICACHE_SIZE                 32768  (32KB)
LEVEL1_ICACHE_ASSOC                8
LEVEL1_ICACHE_LINESIZE             64

LEVEL1_DCACHE_SIZE                 32768  (32KB)
LEVEL1_DCACHE_ASSOC                8
LEVEL1_DCACHE_LINESIZE             64

LEVEL2_CACHE_SIZE                  1048576 (1MB)
LEVEL2_CACHE_ASSOC                 16
LEVEL2_CACHE_LINESIZE              64

LEVEL3_CACHE_SIZE                  23068672 (22MB)
LEVEL3_CACHE_ASSOC                 11
LEVEL3_CACHE_LINESIZE              64

LEVEL4_CACHE_SIZE                  0
LEVEL4_CACHE_ASSOC                 0
LEVEL4_CACHE_LINESIZE              0

How many way associative are they?

Why is 32kb not 32,000? When is K 1,000?

An 8-way associative cache with 32 KB of size and 64-byte blocks divides the cache into 64 sets, each with 8 cache lines. Memory addresses are mapped to specific sets.

Benefits of 8-Way Associativity:

  1. Reduces Conflict Misses:
    • Associativity allows multiple blocks to map to the same set, reducing the likelihood of eviction due to conflicts.
  2. Balances Complexity and Performance:
    • Higher associativity generally improves hit rates but increases lookup complexity. An 8-way cache strikes a good balance for most applications.

Cache Use Examples

Example from this blog post.

Contiguous read loop

Contiguous Reading

// cache1.cpp

#include <time.h>
#include <stdio.h>
#include <stdlib.h>

/*
 * Contiguous access loop
 * 
 * Example from https://mecha-mind.medium.com/demystifying-cpu-caches-with-examples-810534628d71
 *
 * compile with `clang cache.cpp -o cache`
 * run with `./cache`
 */

int main(int argc, char* argv[]) {
    const int length = 512 * 1024 * 1024;   // 512M
    const int cache_line_size = 16;  // size in terms of ints (4 bytes) so 16 * 4 = 64 bytes
    const int m = length/cache_line_size;  // 512M / 32 = 32M

    printf("Looping %d M times\n", m/(1024*1024));

    int *arr = (int*)malloc(length * sizeof(int)); // 512M length array

    clock_t start = clock();
    for (int i = 0; i < m; i++)   // loop 32M times with contiguous access
        arr[i]++;
    clock_t stop = clock();
    
    double duration = ((double)(stop - start)) / CLOCKS_PER_SEC * 1000;
    
    printf("Duration: %f ms\n", duration);

    free(arr);
    return 0;
}

When running on Apple M2 Pro.

% clang cache1.cpp -o cache1
% ./cache1
Looping 32 M times
Duration: 54.166000 ms




Now let's modify the loop to jump by intervals of cache_line_size

Noncontiguous Read Loop

Noncontiguous Read

// cache2.cpp

    for (int i = 0; i < m*cache_line_size; i+=cache_line_size) // non-contiguous access
        arr[i]++;
    clock_t stop = clock();
% ./cache2
Looping 32 M times
Duration: 266.715000 ms

About 5X slower. What happened?

Noncontiguous with 2x cache line jump

We loop half the amount of times!!

    for (int i = 0; i < m*cache_line_size; i+=2*cache_line_size) {
        arr[i]++;
        arr[i+cache_line_size]++;
    }

When running on Apple M2 Pro.

% ./cache3
Looping 16 M times
Duration: 255.551000 ms

Caches on multi-processor systems

For multi-processor systems (which are now standard), memory hierarchy looks something like this:

Caches on multicore systems

In other words, each core has it's own L1 and L2 cache, but the L3 cache and of course main memory is shared.

Virtual Memory, Page Tables and TLBs

  • The addressable memory address range is much larger than available physical memory
  • Every program thinks it can access every possible memory address.
    • And there has to exist some security to prevent one program from modifying the memory occupied by another.
  • The mechanism for that is virtual memory, paging and address translation
Virtual and physical addresses

Wikipedia: Page table

image.png From University of Illinois CS 241 lecture notes.

Page sizes are typically 4KB, 2MB or 1GB depending on the operating system.

If you access a memory address that is not paged into memory, there is a page fault while a page is possible evicted and a the memory is loaded from storage into memory.

We'll finish by looking at some representative costs, sizes and computing "laws."

Costs

  • Server GPU: \500-$1000
  • DRAM: \0.05-$.01/Gbyte
  • Disk: \0.02-$0.14/Gbyte

Sizes

For a typical server

2 X 2Ghz Intel/ADM processors
32-128Gbytes of memory
10-100 Tbytes of storage
10Gbps Network card
1-2 KWatts of power

For a typical datacenter

100K - 1M sercers
1+ MWatt of power 1-10 Pbbs of internal bandwidth, 1-10 Tbps of Internet facing bandwidth 1-10 Exabytes of storage

Computers grow fast so we have written some rules of thumb about them

  • Kryder's Law -- Storage density doubles every 12 months
  • Nielsen's Law -- Consumer Bandwidth doubles every 20 months
  • Moore's Law -- CPU capacity doubles every 18 months
  • Metcalfe's Law -- The value of a Network increases with the square of its members
  • Bell's Law -- Every 10 years the computing paradigm changes

In Class Poll

Guessing Game Part 2: VSCode & Completing the Game

Learning objectives

By the end of class today you should be able to:

  • Use VSCode with rust-analyzer and the integrated terminal for Rust development
  • Start using loops and conditional logic in Rust
  • Use match expressions and Ordering for comparisons
  • Keep your code tidy and readable with clippy, comments, and doc strings

Why VSCode for Rust?

  • Rust Analyzer: Real-time error checking, autocomplete, type hints
  • Integrated terminal: No more switching windows
  • Git integration: Visual diffs, staging, commits

Setting up VSCode for Rust

You'll need to have

  • Installed VSCode
  • Installed Rust
  • Installed the rust-analyzer extension

Opening our project

To make sure we're all at the same starting point, we'll recreate the project.

From MacOS terminal or Windows PowerShell (not git-bash):

# change to your projects folder
cd ~/projects

cargo new guessing_game

cd guessing_game

# check what the default branch name is
git branch

# if default branch is called `master`, rename it to `main`
git branch -m master main

# Add the `rand` crate to the project
cargo add rand

# start VS Code in the current directory
code .

or use File → Open Folder from VSCode and open ~/projects/guessing_game.

VSCode Features Demo

Side Panel

  • Explorer
    • single click and double click filenames
    • split editor views
  • Search,
  • Source Control,
  • Run and Debug,
  • Extensions
    • You should have rust-analyzer, not rust!

Integrated Terminal

  • View → Terminal, Terminal → New
  • You can have multiple terminals
  • Same commands as before: cargo run, cargo check

Rust Analyzer in Action

What you'll see:

  • Red squiggles - Compiler errors
  • Yellow squiggles - Warnings
  • Hover tooltips - Type information
  • Autocomplete - As you type suggestions
  • Format on save - Automatic code formatting

Let's see it in action!

Completing Our Guessing Game

Restore Guessing Game

Replace the content in main.rs with the following:

use std::io;
use rand::Rng;

fn main() {
    let secret_number = rand::rng().random_range(1..=100);
    //println!("The secret number is: {secret_number}");

    println!("Guess the number between 1 and 100!");
    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {}", guess);

}

Running from VSCode

You have different ways to run the program:

  1. cargo run from terminal
  2. Click the little Run that decorates above fn main() {

VSCode Git Integration

Visual Git Features:

  • Source Control panel - See changed files
  • Diff view - Side-by-side comparisons
  • Stage changes - Click the + button
  • Commit - Write message and commit

Still use terminal for:

  • git status - Quick overview
  • git log --oneline - Commit history
  • git push / git pull - Syncing

Create Git Commit

Let's use the visual interface to make our initi commit.

You can always do this via the integrated terminal instead.

Click the Source Control icon on the left panel.

Click + to stage each file or stage all changes.

Write the commit message: "Initial commit" and click Commit

Now you can see on the left pane that we have one commit.

Making it a real game:

  1. Remove the secret reveal - no cheating!
  2. Add a loop - keep playing until correct
  3. Compare numbers - too high? too low?
  4. Handle invalid input - what if they type "banana"?

But before we proceed, create a topic branch by

  • clicking on main in the bottom left
  • Select Create new branch...
  • Give it a name like compare

Step 1: Comparing Numbers

First, we need to convert the guess to a number and compare:

#![allow(unused)]
fn main() {
// add at top of file after other `use` statements
use std::cmp::Ordering;

// Add this after reading input:
let guess: u32 = guess.trim().parse().expect("Please enter a number!");

match guess.cmp(&secret_number) {
    Ordering::Less => println!("Too small!"),
    Ordering::Greater => println!("Too big!"),
    Ordering::Equal => println!("You win!"),
}
}

Now run the program to make sure it works.

If it does, then commit the changes to your topic branch.

Note how you can see the changes in the Source Control panel.

Merge Topic Branch

If you had a remote repo setup like on GitHub, you would then:

  • push your topic branch to the remote git push origin branch_name
  • ask someone to review and possible make changes and push those to the remote

But for now, we are just working locally.

git checkout main
# or use VSCode to switch to main

# merge changes from topic branch into main
git merge compare # replace 'compare' with your branch name

# delete your topic branch
git branch -d compare

Step 2: Adding the Loop

Now, we want to wrap the input/comparison in a loop.

But first create a new topic branch, e.g. loop

#![allow(unused)]
fn main() {
loop {
    println!("Please input your guess.");
    
    // ... input code ...
    
    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => {
            println!("You win!");
            break;  // Exit the loop
        }
    }
}
}

You can indent multiple lines of code by selecting all the lines and then pressing TAB.

Try the code and if it works, commit, checkout main, merge topic branch and then delete topic branch.

Step 3: Handling Invalid Input

Run the program again and then try typing a word instead of a number.

Not great behavior, right?

Replace .expect() with proper error handling, but first create a topic branch.

#![allow(unused)]
fn main() {
let guess: u32 = match guess.trim().parse() {
    Ok(num) => num,
    Err(_) => {
        println!("Please enter a valid number!");
        continue;  // Skip to next loop iteration
    }
};
}

Replace the relevant code, run and debug and do the git steps again.

You should end up on the main branch with all the changes merged and 4 commits.

Final Complete Game

use std::io;
use rand::Rng;
use std::cmp::Ordering;

fn main() {
    let secret_number = rand::rng().random_range(1..=100);
    //println!("The secret number is: {secret_number}");

    println!("Guess the number between 1 and 100!");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        println!("You guessed: {}", guess);

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => {
                println!("Please enter a valid number!");
                continue;  // Skip to next loop iteration
            }
        };

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;  // exit the loop
            }
        }
    }

}

Comments & Documentation Best Practices

What would happen if you came back to this program in a month?

Inline Comments (//)

  • Explain why, not what the code does
  • Bad: // Create a random number
  • Good: // Generate secret between 1-100 for balanced difficulty
  • If it's not clear what the code does you should edit the code!

Doc Comments (///)

  • Document meaningful chunks of code like functions, structs, modules
  • Show up in cargo doc and IDE tooltips
/// Prompts user for a guess and validates input
/// Returns the parsed number or continues loop on invalid input
fn get_user_guess() -> u32 {
    // implementation...
}

You can try putting a doc comment right before fn main() {

The Better Comments extension

See it on VS Code marketplace

  • Color-codes different types of comments in VSCode - let's paste it into main.rs and see
// TODO: Add input validation here
// ! FIXME: This will panic on negative numbers
// ? Why does this work differently on Windows?
// * Important: This function assumes sorted input

Hello VSCode and Hello Github Classroom!

Part 1: GitHub Classroom Set-up

Step 1: Accept the Assignment (One Person Per Group)

  1. Go here: https://classroom.github.com/a/XY-1jTAX
  2. Sign into GitHub if you aren't signed in, then select your name from the list
  3. Create or join a team:
    • If you're first in your group: Click "Create a new team" and name it (e.g., "team-alice-bob")
    • If teammate already started: Find and click on your team name
  4. Click "Accept this assignment"
  5. Click on repository URL to open it - it will look something like this:
    https://github.com/cdsds210-fall25-b1/activity6-team-alice-bob
    

Step 2: Clone the Repository (Everyone)

Open a terminal and navigate to where you keep your projects (optional, but recommended for organization).

cd path/to/your/projects/

In the GitHub webpage for your group, click the green "code" button, and copy the link.

Then clone the repo in your terminal. Your clone command will look like one of these:

git clone https://github.com/cdsds210-fall25-b1/your-team-repo-name.git # HTTPS

Troubleshooting:

  • If HTTPS asks for password: Use your GitHub username and a personal access token (not your GitHub password)

Step 3: Open in VSCode (Everyone)

cd your-team-repo-name
code .

You may see recommendations for a few extensions - do not install them. Carefully select extensions to install.

Step 4: VSCode Exploration

From within your project, open src/main.rs in the navigation sidebar.

Explore These Features:

  • Hover over variables - What type information do you see?
  • Type println! and wait - Notice the autocomplete suggestions
  • Introduce a typo (like printl!) - See the red squiggle error
  • Hover over rand to see definition pop up
  • Right-click on rand - Try "Go to Definition" to jump to code
  • Open integrated terminal (Ctrl+` or View -> Terminal)
  • Run cargo run from the VSCode terminal

Part 2: Making contributions

Step 1: Make a plan as a team

Take a look at src/main.rs the repo as a group and identify two errors using VSCode hints and/or cargo check (you may need to fix one bug first in order to find the other). Then divide up these tasks among your team:

  1. Fixing the bugs (could be one person or split among two people)
  2. Adding some comments into src/main.rs to explain how the code works
  3. Editing the README.md file to include a short summary of how you found the bugs, anything that was confusing or rewarding about this activity, or any other reflections

Step 2: Make individual branches

Make a branch that includes your name and what you're working on, eg. ryan-semicolon-bug-fix or kia-adding-comments

git checkout -b your-branch-name

Step 3: Fix your bug and/or add comments

Talk to each other if you need help!

Step 4: Commit and push

git add . # or add specific files
git commit -m "fix missing semicolon" # your own descriptive comment here 
git push -u origin ryan-semicolon-bug-fix # your own branch name here

Step 5: Create a Pull Request

  1. Go to your team's GitHub repository in your browser
  2. Click the yellow "Compare & pull request" button (or go to "Pull requests" → "New pull request")
  3. Make sure the base is main and compare is your branch
  4. Write a title like "Fix semicolon bug"
  5. Click "Create pull request"

Step 6: Review PRs and Merge

  1. Look at someone else's pull request (not your own!)
  2. Click "Files changed" to see their changes
  3. Leave feedback or request other changes if you want
  4. When you're ready, go to "Review changes" -> "Approve" -> "Submit review"
  5. Click "Merge pull request" -> "Confirm merge"

If you encounter "merge conflicts" try following these instructions.

Step 7: Is it working?

Run git checkout main and git pull when you're all done, and cargo run to see if your final code is working!

There's no "submit" button / step in GitHub Classroom - when you're done and your main branch is how you want it, you're done!

Wrap-up

What we've accomplished so far:

  • Can now use shell, git, and rust all in one place (VSCode)
  • We built a complete, functional game from scratch
  • Started learning key Rust concepts: loops, matching, error handling
  • We've practiced using GitHub Classroom - you'll use it for HW2!

Variables and Types in Rust

About This Module

This module covers Rust's type system and variable handling, including immutability by default, variable shadowing, numeric types, boolean operations, characters, and strings. Understanding these fundamentals is essential for all Rust programming.

Prework

Prework Readings

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. Why might immutable variables by default be beneficial for programming?
  2. What is the difference between variable shadowing and mutability?
  3. How do strongly typed languages like Rust prevent certain classes of bugs?
  4. What are the trade-offs between different integer sizes?
  5. Why might string handling be more complex than it initially appears?

Learning Objectives

By the end of this module, you should be able to:

  • Understand Rust's immutability-by-default principle
  • Use mutable variables when necessary
  • Apply variable shadowing appropriately
  • Choose appropriate numeric types for different use cases
  • Work with boolean and bitwise operations
  • Handle characters and strings properly in Rust
  • Understand type conversion and casting in Rust

Variables are by default immutable!

Take a look at the following code.

Note: we'll use a red border to indicate that the code is expected to fail compilation.

#![allow(unused)]
fn main() {
let x = 3;
x = x + 1; // <== error here
}

Run it and you should get the following error.

   Compiling playground v0.0.1 (/playground)
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:1
  |
3 | let x = 3;
  |     - first assignment to `x`
4 | x = x + 1; // <== error here
  | ^^^^^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
3 | let mut x = 3;
  |     +++

For more information about this error, try `rustc --explain E0384`.
error: could not compile `playground` (bin "playground") due to 1 previous error

The Rust compiler errors are quite helpful!

Use mut to make them mutable

#![allow(unused)]
fn main() {
// mutable variable
let mut x = 3;
x = x + 1;
println!("x = {}", x);
}

Assigning a different type to a mutable variable

What happens if you try to assign a different type to a mutable variable?

#![allow(unused)]
fn main() {
// mutable variable
let mut x = 3;
x = x + 1;
println!("x = {}", x);
x = 9.5;   // what happens here??
println!("x = {}", x);
}

Again, the Rust compiler error message is quite helpful!

Variable Shadowing

You can create a new variable with the same name as a previous variable!

fn main() {
let solution = "4";  // This is a string

// Create a new variable with same name and convert string to integer
let solution : i32 = solution.parse()
                     .expect("Not a number!");

// Create a third variable with the same name!
let solution = solution * (solution - 1) / 2;
println!("solution = {}",solution);

// Create a fourth variable with the same name!
let solution = "This is a string";
println!("solution = {}", solution);
}

In this example, you can't get back to the original variable, although it stays in memory until it goes of out scope.

Question: why would you want to do this?

Question: Can you use mut and avoid variable shadowing? Try it above.

Variable Shadowing and Scopes

Rust automatically deallocates variables when they go out of scope, such as when a program ends.

You can also use a block (bounded by {}) to limit the scope of a variable.

#![allow(unused)]
fn main() {
let x = 1;

{ // start of block scope

    let x = 2;  // shadows outer x
    println!("{}", x); // prints `2`

} // end of block scope

println!("{}", x);    // prints `1` again — outer `x` visible
}

Basic Types: unsigned integers

unsigned integers: u8, u16, u32, u64, u128

  • usize is the default unsigned integer size for your architecture

The number, e.g. 8, represents the number of bits in the type and the maximum value.

  • So unsigned integers range from to .
Unsigned IntegerUnsigned 8 bit binary
000000000
100000001
200000010
300000011

Here's how you convert from binary to decimal.

Basic Types: unsigned integers - min and max values

Rust lets us print the minimum and maximum values of each type.

#![allow(unused)]
fn main() {
println!("U8 min is {} max is {}", u8::MIN, u8::MAX);
println!("U16 min is {} max is {}", u16::MIN, u16::MAX);
println!("U32 min is {} max is {}", u32::MIN, u32::MAX);
println!("U64 min is {} max is {}", u64::MIN, u64::MAX);
println!("U128 min is {} max is {}", u128::MIN, u128::MAX);
println!("USIZE min is {} max is {}", usize::MIN, usize::MAX);
}

Verify u8::MAX on your own.

Question: What is the usize on your machine?

Basic Types: signed integers

Similarly, there are these signed integer types.

signed integers: i8, i16, i32 (default), i64, i128,

isize is the default signed integer size for your architecture

  • from to

Unsigned integers - min and max values

#![allow(unused)]
fn main() {
println!("I8 min is {} max is {}", i8::MIN, i8::MAX);
println!("I16 min is {} max is {}", i16::MIN, i16::MAX);
println!("I32 min is {} max is {}", i32::MIN, i32::MAX);
println!("I64 min is {} max is {}", i64::MIN, i64::MAX);
println!("I128 min is {} max is {}", i128::MIN, i128::MAX);
println!("ISIZE min is {} max is {}", isize::MIN, isize::MAX);
}

Signed integer representation

Signed integers are stored in two's complement format.

  • if the number is positive, the first bit is 0
  • if the number is negative, the first bit is 1
Signed IntegerSigned 8 bit binary
000000000
100000001
200000010
300000011
-111111111
-211111110
-311111101

Here's how you convert from binary to decimal.

If the first bit is 0, the number is positive. If the first bit is 1, the number is negative.

To convert a negative number to decimal:

  • take the sign of the first bit,
  • flip all the bits and add 1 (only for negative numbers!)

Exercise: Try that for -1

Converting between signed and unsigned integers

If you need to convert, use the as operator:

#![allow(unused)]
fn main() {
let x: i8 = -1;
let y: u8 = x as u8;
println!("{}", y);
}

Question: Can you explain the answer?

Why do we need ginormous i128 and u128?

They are useful for cryptography.

Don't use datatype sizes larger than you need.

Larger than architecture default generally takes more time.

i64 math operations might be twice as slow as i32 math.

Number literals

Rust lets us write number literals in a few different ways.

Number literalsExample
Decimal (base 10)98_222
Hex (base 16)0xff
Octal (base 8)0o77
Binary (base 2)0b1111_0000
Byte (u8 only)b'A'
#![allow(unused)]
fn main() {
let s1 = 2_55_i32;
let s2 = 0xff;
let s3 = 0o3_77;
let s4 = 0b1111_1111;

// print in decimal format
println!("{} {} {} {}", s1, s2, s3, s4);

// print in different bases
println!("{} 0x{:X} 0o{:o} 0b{:b}", s1, s2, s3, s4);
}

Be careful with math on ints

fn main() {
let x : i16 = 13;
let y : i32 = -17;

// won't work without the conversion
println!("{}", x * y);   // will not work
//println!("{}", (x as i32)* y); // this will work
}

Basic Types: floats

There are two kinds: f32 and f64

What do these mean?

  • This is the number of bits used in each type
  • more complicated representation than ints (see wikipedia)
  • There is talk about adding f128 to the language but it is not as useful as u128/i128.
fn main() {
let x = 4.0;
println!("x is of type {}", std::any::type_name_of_val(&x));

let z = 1.25;
println!("z is of type {}", std::any::type_name_of_val(&z));

println!("{:.1}", x * z);
}

Exercise: Try changing the type of x to f32 and see what happens: let x:f32 = 4.0;

Floats gotchas

Be careful with mixing f32 and f64 types.

You can't mix them without converting.

fn main() {
let x:f32 = 4.0;
println!("x is of type {}", std::any::type_name_of_val(&x));

let z:f64 = 1.25;
println!("z is of type {}", std::any::type_name_of_val(&z));


println!("{:.1}", x * z);

//println!("{:.1}", (x as f64) * z); // this will work
}

Floats: min and max values

Rust lets us print the minimum and maximum values of each type.

#![allow(unused)]
fn main() {
println!("F32 min is {} max is {}", f32::MIN, f32::MAX);
println!("F32 min is {:e} max is {:e}\n", f32::MIN, f32::MAX);
println!("F64 min is {:e} max is {:e}", f64::MIN, f64::MAX);
}

Exercise -- Integers and Floats

Create a program that:

  1. creates a u8 variable n with value 77
  2. creates an f32 variable x with value 1.25
  3. prints both numbers
  4. multiplies them and puts the results in an f64 variable result
  5. prints the result

Example output:

77
1.25
77 * 1.25 = 96.25

Get your code working here (or in your own editors) and then paste the result in Gradescope.

fn main() {

}

More Basic Types

Let's look at:

  • Booleans
  • Characters
  • Strings

Logical operators and bool

  • bool uses one byte of memory

Question: Why is bool one byte when all we need is one bit?

We can do logical operations on booleans.

#![allow(unused)]
fn main() {
let x = true;
println!("x uses {} bits", std::mem::size_of_val(&x) * 8);

let y: bool = false;
println!("y uses {} bits\n", std::mem::size_of_val(&y) * 8);

println!("{}", x && y); // logical and
println!("{}", x || y); // logical or
println!("{}", !y);    // logical not
}

Bitwise operators

There are also bitwise operators that look similar to logical operators but work on integers:

#![allow(unused)]
fn main() {
let x = 10;
let y = 7;

println!("{x:04b} & {y:04b} = {:04b}", x & y); // bitwise and
println!("{x:04b} | {y:04b} = {:04b}", x | y); // bitwise or
println!("!{y:04b} = {:04b} or {0}", !y); // bitwise not
}

Bitwise 'not' and signed integers

#![allow(unused)]
fn main() {
let y = 7;

println!("!{y:04b} = {:04b} or {0}", !y); // bitwise not
}

What's going on with that last line?

y is I32, so let's display all 32 bits.

#![allow(unused)]
fn main() {
let y = 7;
println!("{:032b}", y);
}

So when we do !y we get the bitwise negation of y.

#![allow(unused)]
fn main() {
let y = 7;
println!("{:032b}", !y);
}

It's still interpreted as a signed integer.

#![allow(unused)]
fn main() {
let y = 7;
println!("{}", !y);
}

Bitwise Operators on Booleans?

It's a little sloppy but it works.

#![allow(unused)]
fn main() {
let x = true;
println!("x is of type {}", std::any::type_name_of_val(&x));
println!("x uses {} bits", std::mem::size_of_val(&x) * 8);

let y: bool = false;
println!("y uses {} bits\n", std::mem::size_of_val(&y) * 8);

// x and (not y)
println!("{}", x & y);  // bitwise and
println!("{}", x | y);  // bitwise or
println!("{}", x ^ y);  // bitwise xor
}

Exercise -- Bitwise Operators on Integers

Create a program that:

  1. Creates an unsigned int x with value 12 and a signed int y with value -5
  2. Prints both numbers in binary format (use {:08b} for 8-bit display)
  3. Performs bitwise AND (&) and prints the result in binary
  4. Performs bitwise OR (|) and prints the result in binary
  5. Performs bitwise NOT (!) on both numbers and prints the results

Example output:

12: 00001100
-5: 11111011
12 & -5: 00001000
12 | -5: 11111101
!12: 11110011
!-5: 00000100
fn main() {

}

Characters

Note that on Mac, you can insert an emoji by typing Control-Command-Space and then typing the emoji name, e.g. 😜.

On Windows, you can insert an emoji by typing Windows-Key + . or Windows-Key + ; and then typing the emoji name, e.g. 😜.

#![allow(unused)]
fn main() {
let x: char = 'a';
println!("x is of type {}", std::any::type_name_of_val(&x));
println!("x uses {} bits", std::mem::size_of_val(&x) * 8);

let y = '🚦';
println!("y is of type {}", std::any::type_name_of_val(&y));
println!("y uses {} bits", std::mem::size_of_val(&y) * 8);

let z = '🦕';
println!("z is of type {}", std::any::type_name_of_val(&z));
println!("z uses {} bits", std::mem::size_of_val(&z) * 8);

println!("{} {} {}", x, y, z);
}

Strings and String Slices (&str)

In Rust, strings are not primitive types, but rather complex types built on top of other types.

String slices are immutable references to string data.

  • String is a growable, heap-allocated data structure

  • &str is an immutable reference to a string slice

  • String is a wrapper around Vec<u8> (More on Vec later)

  • &str is a wrapper around &[u8]

  • string slice defined via double quotes (not so basic actually!)

String and string slice examples

fn main() {
    let s1 = "Hello! How are you, 🦕?";  // type is immutable borrowed reference to a string slice: `&str`
    let s2 : &str = "Καλημέρα από την Βοστώνη και την DS210";  // here we make the type explicit
    
    println!("{}", s1);
    println!("{}\n", s2);
}

String and string slice examples

We have to explicitly convert a string slice to a string.

fn main() {

    // This doesn't work.  You can't do String = &str
    let s3: String = "Does this work?"; // <== error here
    
    let s3: String = "Does this work?".to_string();
    println!("{}", s3);
}

Comment out the error lines and run the code to see what happens.

String and string slice examples

We can't index directly into a string slice, because it is a complex data structure.

Different characters can take up different numbers of bytes in UTF-8.

fn main() {
    let s4: String = String::from("How about this?");
    println!("{}\n", s4);

    let s5: &str = &s3;
    println!("str reference to a String reference: {}\n", s5);
    
    // This won't work.  You can't index directly into a string slice. Why???
    println!("{}", s1[3]); // <== error here
    println!("{}", s2[3]); // <== error here

    // But you can index this way.
    println!("4th character of s1: {}", s1.chars().nth(3).unwrap());
    println!("4th character of s2: {}", s2.chars().nth(3).unwrap());
    println!("3rd character of s4: {}", s4.chars().nth(2).unwrap());
}

Comment out the error lines and run the code to see what happens.

Exercise -- String Slices

Create a program that:

  1. Creates a string slice containing your name
  2. Converts it to a String
  3. Gets the third character of your name using the .chars().nth() method
  4. Prints both the full name and the third character

Example output if your name is "Alice":

Alice
i
fn main() {


}

Recap

  • Variables are by default immutable
  • Use mut to make them mutable
  • Variable shadowing is a way to reuse the same name for a new variable
  • Booleans are one byte of memory
  • Bitwise operators work on integers
  • Characters are four bytes of memory
  • Strings are complex data structures
  • String slices are immutable references to string data

Conditional Expressions and Flow Control in Rust

About This Module

This module covers Rust's conditional expressions, including if statements, if expressions, and the unique ways Rust handles control flow. Understanding these concepts is fundamental for writing effective Rust programs and leveraging Rust's expression-based syntax.

Prework

Prework Readings

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. What is the difference between statements and expressions in programming?
  2. How might expression-based syntax improve code readability and safety?
  3. What are the advantages of mandatory braces in conditional statements?
  4. How do different languages handle ternary operations?
  5. What role does type consistency play in conditional expressions?

Learning Objectives

By the end of this module, you should be able to:

  • Use if statements for conditional execution
  • Leverage if expressions to assign values conditionally
  • Understand Rust's expression-based syntax
  • Apply proper type consistency in conditional expressions
  • Write clean, readable conditional code following Rust conventions
  • Understand the differences between Rust and other languages' conditional syntax

An Aside -- Approach to Learning New Languages

Systematic Language Learning Framework:

When learning any new programming language, consider these key areas:

  1. Data Types: What types of variables and data structures are available?
  2. Functions: What is the syntax for defining and calling functions?
  3. Build System: How do you compile and run code?
  4. Control Flow: Syntax for conditionals, loops, and branching
  5. Code Organization: How to structure programs (structs, modules, etc.)
  6. Language-Specific Features: Unique aspects of the language
  7. Additional Considerations: I/O, external libraries, ecosystem

Basic if Statements

Syntax:

if condition {
    DO-SOMETHING-HERE
} else {
    DO-SOMETHING-ELSE-HERE
}
  • else part optional
  • Compared to many C-like languages:
    • no parentheses around condition needed!
    • the braces mandatory

Example of if

Simple if statement.

fn main() {
    let x = 7;

    if x <= 15 {
        println!("x is not greater than 15");
    }
}
  • parentheses optional around condition -- try it with!
  • no semicolon after the if braces
fn main() {
    let threshold = 5;
    if x <= threshold {
        println!("x is at most {}",threshold);
    } else {
        println!("x is greater than {}", threshold);
    }
}

Using conditional expressions as values

In Python:

result = 100 if (x == 7) else 200 

C++:

result = (x == 7) ? 100 : 200

Rust:

fn main() {
    let x = 4;
    let result = if x == 7 {100} else {200};
    println!("{}",result);
}
fn main() {
// won't work: same type needed
    let x = 4;
    println!("{}",if x == 7 {100} else {1.2});
}
  • blocks can be more complicated
  • last expression counts (no semicolon after)
  • But please don't write this just because you can
#![allow(unused)]
fn main() {
let x = 4;
let z = if x == 4 {
    let t = x * x;
    t + 1
} else {
    x + 1
};
println!("{}",z);
}

Write this instead:

#![allow(unused)]
fn main() {
let x = 4;
let z;
if x == 4 { z = x*x+1 } else { z = x+1};
println!("{}", z)
}

Obscure Code Competition Winner

A winner of the most obscure code competition (https://www.ioccc.org/)

What does this program do?

#include <stdio.h> 

#define N(a)       "%"#a"$hhn"
#define O(a,b)     "%10$"#a"d"N(b)
#define U          "%10$.*37$d"
#define G(a)       "%"#a"$s"
#define H(a,b)     G(a)G(b)
#define T(a)       a a 
#define s(a)       T(a)T(a)
#define A(a)       s(a)T(a)a
#define n(a)       A(a)a
#define D(a)       n(a)A(a)
#define C(a)       D(a)a
#define R          C(C(N(12)G(12)))
#define o(a,b,c)   C(H(a,a))D(G(a))C(H(b,b)G(b))n(G(b))O(32,c)R
#define SS         O(78,55)R "\n\033[2J\n%26$s";
#define E(a,b,c,d) H(a,b)G(c)O(253,11)R G(11)O(255,11)R H(11,d)N(d)O(253,35)R
#define S(a,b)     O(254,11)H(a,b)N(68)R G(68)O(255,68)N(12)H(12,68)G(67)N(67)

char* fmt = O(10,39)N(40)N(41)N(42)N(43)N(66)N(69)N(24)O(22,65)O(5,70)O(8,44)N(
            45)N(46)N    (47)N(48)N(    49)N( 50)N(     51)N(52)N(53    )O( 28,
            54)O(5,        55) O(2,    56)O(3,57)O(      4,58 )O(13,    73)O(4,
            71 )N(   72)O   (20,59    )N(60)N(61)N(       62)N (63)N    (64)R R
            E(1,2,   3,13   )E(4,    5,6,13)E(7,8,9        ,13)E(1,4    ,7,13)E
            (2,5,8,        13)E(    3,6,9,13)E(1,5,         9,13)E(3    ,5,7,13
            )E(14,15,    16,23)    E(17,18,19,23)E(          20, 21,    22,23)E
            (14,17,20,23)E(15,    18,21,23)E(16,19,    22     ,23)E(    14, 18,
            22,23)E(16,18,20,    23)R U O(255 ,38)R    G (     38)O(    255,36)
            R H(13,23)O(255,    11)R H(11,36) O(254    ,36)     R G(    36 ) O(
            255,36)R S(1,14    )S(2,15)S(3, 16)S(4,    17 )S     (5,    18)S(6,
            19)S(7,20)S(8,    21)S(9    ,22)H(13,23    )H(36,     67    )N(11)R
            G(11)""O(255,    25 )R        s(C(G(11)    ))n (G(          11) )G(
            11)N(54)R C(    "aa")   s(A(   G(25)))T    (G(25))N         (69)R o
            (14,1,26)o(    15, 2,   27)o   (16,3,28    )o( 17,4,        29)o(18
            ,5,30)o(19    ,6,31)o(        20,7,32)o    (21,8,33)o       (22 ,9,
            34)n(C(U)    )N( 68)R H(    36,13)G(23)    N(11)R C(D(      G(11)))
            D(G(11))G(68)N(68)R G(68)O(49,35)R H(13,23)G(67)N(11)R C(H(11,11)G(
            11))A(G(11))C(H(36,36)G(36))s(G(36))O(32,58)R C(D(G(36)))A(G(36))SS

#define arg d+6,d+8,d+10,d+12,d+14,d+16,d+18,d+20,d+22,0,d+46,d+52,d+48,d+24,d\
            +26,d+28,d+30,d+32,d+34,d+36,d+38,d+40,d+50,(scanf(d+126,d+4),d+(6\
            -2)+18*(1-d[2]%2)+d[4]*2),d,d+66,d+68,d+70, d+78,d+80,d+82,d+90,d+\
            92,d+94,d+97,d+54,d[2],d+2,d+71,d+77,d+83,d+89,d+95,d+72,d+73,d+74\
            ,d+75,d+76,d+84,d+85,d+86,d+87,d+88,d+100,d+101,d+96,d+102,d+99,d+\
            67,d+69,d+79,d+81,d+91,d+93,d+98,d+103,d+58,d+60,d+98,d+126,d+127,\
            d+128,d+129

char d[538] = {1,0,10,0,10};

int main() {
    while(*d) printf(fmt, arg);
}

Best Practices

Formatting and Style:

  1. Use consistent indentation (4 spaces)
  2. Keep conditions readable - use parentheses for clarity when needed
  3. Prefer early returns in functions to reduce nesting
  4. Use else if for multiple conditions rather than nested if

Example of Good Style:

fn classify_temperature(temp: f64) -> &'static str {
    if temp > 30.0 {
        "Hot"
    } else if temp > 20.0 {
        "Warm"
    } else if temp > 10.0 {
        "Cool"
    } else {
        "Cold"
    }
}

fn main() {
    println!("{}", classify_temperature(35.0));
    println!("{}", classify_temperature(25.0));
    println!("{}", classify_temperature(15.0));
    println!("{}", classify_temperature(5.0));
}

Exercise

Write a function that takes a number and returns a string that says whether it is positive, negative, or zero.

Example output:

10 is positive
-5 is negative
0 is zero
// Your code here

Functions in Rust

About This Module

This module covers Rust function syntax, return values, parameters, and the unit type. Functions are fundamental building blocks in Rust programming, and understanding their syntax and behavior is essential for writing well-structured Rust programs.

Prework

Prework Readings

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. How do functions help organize and structure code?
  2. What are the benefits of explicit type annotations in function signatures?
  3. How do return values differ from side effects in functions?
  4. What is the difference between expressions and statements in function bodies?
  5. How might Rust's approach to functions differ from other languages you know?

Learning Objectives

By the end of this module, you should be able to:

  • Define functions with proper Rust syntax
  • Understand parameter types and return type annotations
  • Use both explicit return statements and implicit returns
  • Work with functions that return no value (unit type)
  • Apply best practices for function design and readability
  • Understand the difference between expressions and statements in function bodies

Function Syntax

Syntax:

fn function_name(argname_1:type_1,argname_2:type_2) -> type_ret {
    DO-SOMETHING-HERE-AND-RETURN-A-VALUE
}
  • No need to write "return x * y"
  • Last expression is returned
  • No semicolon after the last expression
fn multiply(x:i32, y:i32) -> i32 {
    // note: no need to write "return x * y"
    x * y
}

fn main() {
    println!("{}", multiply(10,20))
}

Exercise: Try putting a semicolon after the last expression. What happens?

Functions returns

  • But if you add a return then you need a semicolon
    • unless it is the last statement in the function
  • Recommend using returns and add semicolons everywhere.
    • It's easier to read.
fn and(p:bool, q:bool, r:bool) -> bool {
    if !p {
        println!("p is false");
        return false;
    }
    if !q {
        println!("q is false");
        return false;
    }
    println!("r is {}", r);
    r // return r without the semicolon also works here
}

fn main() {
    println!("{}", and(true,false,true))
}

Functions: returning no value

How: skip the type of returned value part

fn say_hello(who:&str) {
    println!("Hello, {}!",who);
}

fn main() {
    say_hello("world");
    say_hello("Boston");
    say_hello("DS210");
}

Nothing returned equivalent to the unit type, ()

fn say_good_night(who:&str) -> () {
    println!("Good night {}",who);
}

fn main() {
    say_good_night("room");
    say_good_night("moon");
    let z = say_good_night("cow jumping over the moon");
    println!("The function returned {:?}", z)
}

Unit Type Characteristics:

  • Empty tuple: ()
  • Zero size: Takes no memory
  • Default return: When no value is explicitly returned
  • Side effects only: Functions that only perform actions (printing, file I/O, etc.)

Parameter Handling

Multiple Parameters:

#![allow(unused)]
fn main() {
fn calculate_area(length: f64, width: f64) -> f64 {
    length * width
}

fn greet_person(first_name: &str, last_name: &str, age: u32) {
    println!("Hello, {} {}! You are {} years old.", 
             first_name, last_name, age);
}
}

Parameter Types:

  • Ownership: Parameters can take ownership (String)
  • References: Parameters can borrow (&str, &i32)
  • Primitive types: Copied by default (i32, bool, f64)

Function Design Principles

Single Responsibility:

// Good: Single purpose
fn calculate_tax(price: f64, tax_rate: f64) -> f64 {
    price * tax_rate
}

// Good: Clear separation of concerns
fn format_currency(amount: f64) -> String {
    format!("${:.2}", amount)
}

fn display_total(subtotal: f64, tax_rate: f64) {
    let tax = calculate_tax(subtotal, tax_rate);
    let total = subtotal + tax;
    println!("Total: {}", format_currency(total));
}

fn main() {
    display_total(100.0, 0.08);
}

Pure Functions vs. Side Effects:

#![allow(unused)]
fn main() {
// Pure function: No side effects, deterministic
fn add(x: i32, y: i32) -> i32 {
    x + y
}

// Function with side effects: Prints to console
fn add_and_print(x: i32, y: i32) -> i32 {
    let result = x + y;
    println!("{} + {} = {}", x, y, result);
    result
}
}

Common Patterns

Validation Functions:

#![allow(unused)]
fn main() {
fn is_valid_age(age: i32) -> bool {
    age >= 0 && age <= 150
}

fn is_valid_email(email: &str) -> bool {
    email.contains('@') && email.contains('.')
}
}

Conversion Functions:

#![allow(unused)]
fn main() {
fn celsius_to_fahrenheit(celsius: f64) -> f64 {
    celsius * 9.0 / 5.0 + 32.0
}

fn fahrenheit_to_celsius(fahrenheit: f64) -> f64 {
    (fahrenheit - 32.0) * 5.0 / 9.0
}
}

Helper Functions:

#![allow(unused)]
fn main() {
fn get_absolute_value(x: i32) -> i32 {
    if x < 0 { -x } else { x }
}

fn max_of_three(a: i32, b: i32, c: i32) -> i32 {
    if a >= b && a >= c {
        a
    } else if b >= c {
        b
    } else {
        c
    }
}
}

Function Naming Conventions

Rust Naming Guidelines:

  • snake_case: For function names
  • Descriptive names: Clear indication of purpose
  • Verb phrases: For functions that perform actions
  • Predicate functions: Start with is_, has_, can_

Examples:

#![allow(unused)]
fn main() {
fn calculate_distance(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 { /* ... */ }
fn is_prime(n: u32) -> bool { /* ... */ }
fn has_permission(user: &str, resource: &str) -> bool { /* ... */ }
fn can_access(user_level: u32, required_level: u32) -> bool { /* ... */ }
}

Exercise

Write a function called greet_user that takes a name and a time of day (morning, afternoon, evening) as parameters and returns an appropriate greeting string.

The function should:

  1. Take two parameters: name: &str and time: &str
  2. Return a String with a customized greeting
  3. Follow Rust naming conventions
  4. Use proper parameter types
  5. Include error handling for invalid times

Example output:

Good evening, Dumbledore!

Hint: You can format the string using the format! macro, which uses the same syntax as println!.

// Returns a String
format!("Good morning, {}!", name)
// Your code here


Loops and Arrays in Rust

About This Module

This module covers Rust's loop constructs (for, while, loop) and array data structures. Understanding loops and arrays is essential for processing collections of data and implementing algorithms in Rust.

Prework

Prework Readings

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. What are the different types of loops and when would you use each?
  2. How do arrays differ from more flexible data structures like vectors?
  3. What are the advantages of fixed-size arrays?
  4. How do ranges work in iteration and what are their bounds?
  5. When might you need labeled breaks and continues in nested loops?

Learning Objectives

By the end of this module, you should be able to:

  • Use for loops with ranges and collections
  • Work with while loops for conditional iteration
  • Understand loop for infinite loops with explicit breaks
  • Create and manipulate arrays in Rust
  • Use break and continue statements effectively
  • Apply loop labels for complex control flow
  • Understand array properties and limitations

For Loops and Ranges

Loops: for

Usage: loop over a range or collection

A range is (start..end), e.g. (1..5), where the index will vary as

Unless you use the notation (start..=end), in which case the index will vary as

#![allow(unused)]
fn main() {
// parentheses on the range are optional unless calling a method e.g. `.rev()`
// on the range
for i in 1..5 {
    println!("{}",i);
}
}
#![allow(unused)]
fn main() {
// inclusive range
for i in 1..=5 {
    println!("{}",i);
}
}
#![allow(unused)]
fn main() {
// reverse order. we need parentheses!
for i in (1..5).rev() {
    println!("{}",i)
}
}
#![allow(unused)]
fn main() {
// every other element 
for i in (1..5).step_by(2) {
    println!("{}",i);
}
}
#![allow(unused)]
fn main() {
println!("And now for the reverse");
for i in (1..5).step_by(2).rev() {
    println!("{}",i)
}
}
#![allow(unused)]
fn main() {
println!("But....");
for i in (1..5).rev().step_by(2) {
    println!("{}",i);
}
}

Arrays and for over an array

  • Arrays in Rust are of fixed length (we'll learn about more flexible Vec later)
  • All elements of the same type
  • You can not add or remove elements from an array (but you can change its value)
  • Python does not have arrays natively.

What's the closest thing in native python?

#![allow(unused)]
fn main() {
// simplest definition
// compiler guessing element type to be i32
// indexing starts at 0
let mut arr = [1,7,2,5,2];
arr[1] = 13;
println!("{} {}",arr[0],arr[1]);
}
#![allow(unused)]
fn main() {
let mut arr = [1,7,2,5,2];
// array supports sorting
arr.sort();

// loop over the array
for x in arr {
    println!("{}",x);
}
}
#![allow(unused)]
fn main() {
// create array of given length
// and fill it with a specific value
let arr2 = [15;3];
for x in arr2 {
    print!("{} ",x);  // notice print! instead of println!
}
}
#![allow(unused)]
fn main() {
// with type definition and shorthand to repeat values
let arr3 : [u8;3] = [15;3];

for x in arr3 {
    print!("{} ",x);
}
println!();

println!("arr3[2] is {}", arr3[2]);
}
#![allow(unused)]
fn main() {
let arr3 : [u8;3] = [15;3];
// get the length
println!("{}",arr3.len())
}

Loops: while

#![allow(unused)]
fn main() {
let mut number = 3;

while number != 0 {
    println!("{number}!");
    number -= 1;
}
println!("LIFT OFF!!!");

}

Infinite loop: loop

loop {
    // DO SOMETHING HERE
}

Need to use break to jump out of the loop!

#![allow(unused)]
fn main() {
let mut x = 1;
loop {
    if (x + 1) * (x + 1) >= 250 {break;}
    x += 1;
}
println!("{}",x)
}
  • loop can return a value!
  • break can act like return
#![allow(unused)]
fn main() {
let mut x = 1;
let y = loop {
    if x * x >= 250 {break x - 1;}
    x += 1;
};
println!("{}",y)
}
  • continue to skip the rest of the loop body and start the next iteration
#![allow(unused)]
fn main() {
// loop keyword similar to while (True) in Python
// break and continue keywords behave as you would expect
let mut x = 1;

let result = loop {  // you can capture a return value
    if x == 5 {
        x = x+1;
        continue;    // skip the rest of this loop body and start the next iteration
    }
    println!("X is {}", x);
    x = x + 1;
    if x==10 {
        break x*2;   // break with a return value
    }
};

println!("Result is {}", result);
}

Advanced break and continue

  • work in all loops
  • break: terminate the execution
    • can return a value in loop
  • continue: terminate this iteration and jump to the next one
    • in while, the condition will be checked
    • in for, there may be no next iteration
    • break and continue can use labels
#![allow(unused)]
fn main() {
for i in 1..=10 {
    if i % 3 != 0 {continue;}
    println!("{}",i);
};
}

You can also label loops to target with continue and break.

#![allow(unused)]
fn main() {
let mut x = 1;
'outer_loop: loop {
    println!("Hi outer loop");
    'inner_loop: loop {
        println!("Hi inner loop");
        x = x + 1;
        if x % 3 != 0 {
            continue 'outer_loop;  // skip the rest of the outer loop body and start the next iteration
        }
        println!("In the middle");
        if x >= 10 {
            break 'outer_loop;  // break the outer loop
        }
        println!("X is {}", x);
    }
    println!("In the end");
};

println!("Managed to escape! :-) with x {}", x);
}
#![allow(unused)]
fn main() {
let mut x = 1;
'outer_loop: loop {
    println!("Hi outer loop");
    'inner_loop: loop {
        println!("Hi inner loop");
        x = x + 1;
        if x % 3 != 0 {
            break 'inner_loop;  // break the inner loop, continue the outer loop
        }
        println!("In the middle");
        if x >= 10 {
            break 'outer_loop;  // break the outer loop
        }
        println!("X is {}", x);
    }
    println!("In the end");
};
println!("Managed to escape! :-) with x {}", x);
}
#![allow(unused)]
fn main() {
let x = 'outer_loop: loop {
    loop { break 'outer_loop 1234;}
};
println!("{}",x);
}

Loop Selection Guidelines

When to Use Each Loop Type:

For Loops:

  • Known range: Iterating over ranges or collections
  • Collection processing: Working with arrays, vectors, etc.
  • Counter-based iteration: When you need an index

While Loops:

  • Condition-based: Continue until some condition changes
  • Unknown iteration count: Don't know how many times to loop
  • Input validation: Keep asking until valid input

Loop (Infinite):

  • Event loops: Server applications, game loops
  • Breaking on complex conditions: When simple while condition isn't sufficient
  • Returning values: When loop needs to compute and return a result

Exercise

Here's an exam question from a previous semester. Analyze the code without any assistance to practice your skills for the next exam.

You are given the following Rust code

let mut x = 1;
'outer_loop: loop {
    'inner_loop: loop {
        x = x + 1;
        if x % 4 != 0 {
            continue 'outer_loop;
        }
        if x > 11 {
            break 'outer_loop;
        }
    }
};
println!("Managed to escape! :-) with x {}", x);

What is the value of x printed by the println! statement at the end?

Explain your answer.

Tuples in Rust

About This Module

This module covers Rust's tuple data structure, which allows grouping multiple values of different types into a single compound value. Tuples are fundamental for returning multiple values from functions and organizing related data.

Prework

Prework Readings

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. What advantages do tuples provide over separate variables?
  2. How might tuples be useful for function return values?
  3. What are the trade-offs between tuples and structs?
  4. How does pattern matching with tuples improve code readability?
  5. When would you choose tuples versus arrays for grouping data?

Learning Objectives

By the end of this module, you should be able to:

  • Create and use tuples with different data types
  • Access tuple elements using indexing and destructuring
  • Apply pattern matching with tuples
  • Use tuples for multiple return values from functions
  • Understand when to use tuples versus other data structures
  • Work with nested tuples and complex tuple patterns

What Are Tuples?

A general-purpose data structure that can hold multiple values of different types.

  • Syntax: (value_1,value_2,value_3)
  • Type: (type_1,type_2,type_3)
#![allow(unused)]
fn main() {
let mut tuple = (1,1.1);
let mut tuple2: (i32,f64) = (1,1.1);  // type annotation is optional in this case

println!("tuple: {:?}, tuple2: {:?}", tuple, tuple2);
}
#![allow(unused)]
fn main() {
let another = ("abc","def","ghi");
println!("another: {:?}", another);
}
#![allow(unused)]
fn main() {
let yet_another: (u8,u32) = (255,4_000_000_000);
println!("yet_another: {:?}", yet_another);
}

Aside: Debug formatting

Look carefully at the variable formatting:

fn main() {
let student = ("Alice", 88.5, 92.0, 85.5);
println!("student: {:?}", student);
//                  ^^
}

Rust uses the {:?} format specifier to print the variable in a debug format.

We'll talk more about what this means, but for now, just know that's often a good tool to use when debugging.

Accessing Tuple Elements

There are two ways to access tuple elements:

1. Accessing elements via index (0 based)

#![allow(unused)]
fn main() {
let mut tuple = (1,1.1);
println!("({}, {})", tuple.0, tuple.1);

tuple.0 = 2;

println!("({}, {})",tuple.0,tuple.1);

println!("Tuple is {:?}", tuple);
}

2. Pattern matching and deconstructing

fn main() {
let tuple = (1,1.1);
let (a, b) = tuple;
println!("a = {}, b = {}",a,b);
}

Best Practices

When to Use Tuples:

  • Small, related data: 2-4 related values
  • Temporary grouping: Short-lived data combinations
  • Function returns: Multiple return values
  • Pattern matching: When destructuring is useful

When to Avoid Tuples:

  • Many elements: More than 4-5 elements becomes unwieldy
  • Complex data: When you need named fields for clarity
  • Long-term storage: When data structure will evolve

Style Guidelines:

// Good: Clear, concise
let (width, height) = get_dimensions();

// Good: Descriptive destructuring
let (min_temp, max_temp, avg_temp) = analyze_temperatures(&data);

// Avoid: Too many elements
// let config = (true, false, 42, 3.14, "test", 100, false);  // Hard to read

// Avoid: Unclear meaning
// let data = (42, 13);  // What do these numbers represent?

In-Class Exercise

Exercise: Student Grade Tracker

Create a program that tracks student information and calculates grade statistics. Work through the following steps:

  1. Create a tuple to store a student's name (String) and three test scores (f64, f64, f64)

  2. Calculate the average of the three test scores and create a new tuple that includes the student's name and average grade

  3. Use pattern matching to destructure and display the student's name and average in a readable format

  4. Bonus: Create multiple student tuples and use pattern matching to find students with averages above 85.0

fn main() {
    // Step 1: Create a student tuple (name, score1, score2, score3)
    let student1 = ...
    
    // Step 2: Deconstruct the tuple into separate variables
    let ...

    // Step 2: Calculate average and create new tuple (name, average)
    let average = ...
    let student_grade = ...
    
    // Step 3: Deconstruct student_grade into variables 
    // student_name and avg_grade
    let ...
    println!("Student: {}, Average: {:.1}", student_name, avg_grade);
    
}

Expected Output:

Student: Alice, Average: 88.7

Recap

  • Tuples are a general-purpose data structure that can hold multiple values of different types
  • We can access tuple elements via index or by pattern matching and deconstructing
  • Pattern matching is a powerful tool for working with tuples
  • Tuples are often used for multiple return values from functions

Enums and Pattern Matching in Rust

About This Module

This module introduces Rust's enum (enumeration) types and pattern matching with match and if let. Enums allow you to define custom types by enumerating possible variants, and pattern matching provides powerful control flow based on enum values.

Prework

Prework Readings

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. How do enums help make code more expressive and type-safe?
  2. What advantages does pattern matching provide over traditional if-else chains?
  3. How might enums be useful for error handling in programs?
  4. What is the difference between enums in Rust and in other languages you know?
  5. When would you use match versus if let for pattern matching?

Learning Objectives

By the end of this module, you should be able to:

  • Define custom enum types with variants
  • Create instances of enum variants
  • Use match expressions for exhaustive pattern matching
  • Apply if let for simplified pattern matching
  • Store data in enum variants
  • Understand memory layout of enums
  • Use the #[derive(Debug)] attribute for enum display

Enums

enum is short for "enumeration" and allows you to define a type by enumerating its possible variants.

The type you define can only take on one of the variants you have defined.

Allows you to encode meaning along with data.

Pattern matching using match and if let allows you to run different code depending on the value of the enum.

Python doesn't have native support for enum, but it does have an enum module that let's do something similar by subclassing an Enum class.

Basic Enums

Let's start with a simple example:

// define the enum and its variants
enum Direction {
    North,   // <---- enum _variant_
    East,
    South,
    West,
    SouthWest,
}

fn main() {
    // create instances of the enum variants
    let dir_1 = Direction::North;   // dir is inferred to be of type Direction
    let dir_2: Direction = Direction::South; // dir_2 is explicitly of type Direction
}

The enum declaration is defining our new type, so now a type called Direction is in scope, similar to i32, f64, bool, etc., but it instances can only be one of the variants we have defined.

The let declarations are creating instances of the Direction type.

Aside: Rust Naming Conventions

Rust has a set of naming conventions that are used to make the code more readable and consistent.

You should follow these conventions when naming your enums, variants, functions, and other items in your code.

ItemConvention
Cratessnake_case (but prefer single word)
Modulessnake_case
Types (e.g. enums)UpperCamelCase
TraitsUpperCamelCase
Enum variantsUpperCamelCase
Functionssnake_case
Methodssnake_case
General constructorsnew or with_more_details
Conversion constructorsfrom_some_other_type
Local variablessnake_case
Static variablesSCREAMING_SNAKE_CASE
Constant variablesSCREAMING_SNAKE_CASE
Type parametersconcise UpperCamelCase, usually single uppercase letter: T
Lifetimesshort, lowercase: 'a

Using "use" as a shortcut

You can bring the variants into scope using use statements.

// define the enum and its variants
enum Direction {
    North,
    East,
    South,
    West,
    SouthWest,
}

// Bring the variant `East` into scope
use Direction::East;

fn main() {
    // we didn't have to specify "Direction::"
    let dir_3 = East;
}

Using "use" as a shortcut

You can bring multiple variants into scope using use statements.

// define the enum and its variants
enum Direction {
    North,
    East,
    South,
    West,
    SouthWest,
}

// Bringing two options into the current scope
use Direction::{East,West};

fn main() {
    let dir_4 = West;
}

Using "use" as a shortcut

You can bring all the variants into scope using use statements.

enum Direction {
    North,
    East,
    South,
    West,
}

// Bringing all options in
use Direction::*;

fn main() {
let dir_5 = South;
}

Question: Why might we not always want to bring all the variants into scope?

Name clashes

use <enum_name>::*; will bring all the variants into scope, but if you have a variable with the same name as a variant, it will clash.

Uncomment the use Prohibited::*; line to see the error.

enum Prohibited {
    MyVar,
    YourVar,
}

// what happens if we bring all the variants into scope?
// use Prohibited::*;

fn main() {
    let MyVar = "my string";

    let another_var = Prohibited::MyVar;

    println!("{MyVar}");
}

Aside: Quick Recap on Member Access

Different data structures have different ways of accessing their members.

fn main() {
    // Accessing an element of an array
    let arr = [1, 2, 3];
    println!("{}", arr[0]);

    // Accessing an element of a tuple
    let tuple = (1, 2, 3);
    println!("{}", tuple.0);
    let (a, b, c) = tuple;
    println!("{}, {}, {}", a, b, c);

    // Accessing a variant of an enum
    enum Direction {
        North,
        East,
        South,
        West,
    }
    let dir = Direction::East;
}

Using enums as parameters

We can also define a function that takes our new type as an argument.

enum Direction {
    North,
    East,
    South,
    West,
}

fn turn(dir: Direction) { return; } // this function doesn't do anything

fn main() {
    let dir = Direction::East;
    turn(dir);
}

Control Flow with match

Enums: Control Flow with match

The match statement is used to control flow based on the value of an enum.

enum Direction {
    North,
    East,
    South,
    West,
}

fn main() {
    let dir = Direction::East;

    // print the direction
    match dir {
        Direction::North => println!("N"),
        Direction::South => println!("S"),
        Direction::West => {  // can do more than one thing
            println!("Go west!");
            println!("W")
        }
        Direction::East => println!("E"),
    };
}

Take a close look at the match syntax.

Covering all variants with match

match is exhaustive, so we must cover all the variants.

// Won't compile

enum Direction {
    North,
    East,
    South,
    West,
}

fn main() {
    let dir_2: Direction = Direction::South;

    // won't work 
    match dir_2 {
        Direction::North => println!("N"),
        Direction::South => println!("S"),
        // East and West not covered
    };
}

But there is a way to match anything left.

Covering all variants with match

There's a special pattern, _, that matches anything.

enum Direction {
    North,
    East,
    South,
    West,
}

fn main() {
    let dir_2: Direction = Direction::North;

    match dir_2 {
        Direction::North => println!("N"),
        Direction::South => println!("S"),

        // match anything left
        _ => (),  // covers all the other variants but doesn't do anything
    }
}

Covering all variants with match

WARNING!!

The _ pattern has to be the last pattern in the match statement.

enum Direction {
    North,
    East,
    South,
    West,
}

fn main() {
    let dir_2: Direction = Direction::North;

    match dir_2 {
        _ => println!("anything else"),

        // will never get here!!
        Direction::North => println!("N"),
        Direction::South => println!("S"),
    }
}

Recap of match

  • Type of a switch statement like in C/C++ (Python doesn't have an equivalent)
  • Must be exhaustive though there is a way to specify default (_ =>)

Putting Data in an Enum Variant

  • Each variant can come with additional information
  • Let's put a few things together with an example
#[derive(Debug)]   // allows us to print the enum by having Rust automatically
                   // implement a Debug trait (more later)
enum DivisionResult {
    Ok(f32),    // This variant has an associated value of type f32
    DivisionByZero,
}

// Return a DivisionResult that handles the case where the division is by zero. 
fn divide(x:f32, y:f32) -> DivisionResult {
    if y == 0.0 {
        return DivisionResult::DivisionByZero;
    } else {
        return DivisionResult::Ok(x / y); // Prove a value with the variant
    }
}

fn show_result(result: DivisionResult) {
    match result {
        DivisionResult::Ok(result) => println!("the result is {}",result),
        DivisionResult::DivisionByZero => println!("noooooo!!!!"),
    }
}

fn main() {
    let (a,b) = (9.0,3.0);  // this is just short hand for let a = 9.0; let b = 3.0;

    println!("Dividing 9 by 3:");
    show_result(divide(a,b));

    println!("Dividing 6 by 0:");
    show_result(divide(6.0,0.0));

    // we can also call `divide`, store the result and print it
    let z = divide(5.0, 4.0);
    println!("The result of 5.0 / 4.0 is {:?}", z);
}

Variants with multiple values

We can have more than one associated value in a variant.

enum DivisionResultWithRemainder {
    Ok(u32,u32),  // Store the result of the integer division and the remainder
    DivisionByZero,
}

fn divide_with_remainder(x:u32, y:u32) -> DivisionResultWithRemainder {
    if y == 0 {
        DivisionResultWithRemainder::DivisionByZero
    } else {
        // Return the integer division and the remainder
        DivisionResultWithRemainder::Ok(x / y, x % y) 
    }
}

fn main() {
    let (a,b) = (9,4);

    println!("Dividing 9 by 4:");
    match divide_with_remainder(a,b) {
        DivisionResultWithRemainder::Ok(result,remainder) => {
                println!("the result is {} with a remainder of {}",result,remainder);
        }
        DivisionResultWithRemainder::DivisionByZero
            => println!("noooooo!!!!"),
    };
}

Getting the value out of an enum variant

We can use match to get the value out of an enum variant.

#[derive(Debug)]
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {
    let msg = Message::Write(String::from("Hello, world!"));
    
    // Extract values using match
    match msg {
        Message::Quit => println!("Quit message"),
        Message::Move { x, y } => println!("Move to ({}, {})", x, y),
        Message::Write(text) => println!("Write: {}", text),
        Message::ChangeColor(r, g, b) => println!("Color: RGB({}, {}, {})", r, g, b),
    }
    
    // Using if let for single variant extraction
    let msg2 = Message::Move { x: 10, y: 20 };
    if let Message::Move { x, y } = msg2 {
        println!("Extracted coordinates: x={}, y={}", x, y);
    }
}

A Note on the Memory Size of Enums

The size of the enum is related to the size of its largest variant, not the sum of the sizes.

Also stores a discriminant (tag) to identify which variant is stored.

use std::mem;

enum SuperSimpleEnum {
    First,
    Second,
    Third
}

enum SimpleEnum {
    A,           // No data
    B(i32),      // Contains an i32 (4 bytes)
    C(i32, i32), // Contains two i32s (8 bytes)
    D(i64)       // Contains an i64 (8 bytes)
}

fn main() {
    println!("Size of SuperSimpleEnum: {} bytes\n", mem::size_of::<SuperSimpleEnum>());

    println!("Size of SimpleEnum: {} bytes\n", mem::size_of::<SimpleEnum>());
    println!("Size of i32: {} bytes", mem::size_of::<i32>());
    println!("Size of (i32, i32): {} bytes", mem::size_of::<(i32, i32)>());
    println!("Size of (i64): {} bytes", mem::size_of::<(i64)>());
}

For variant C, it's possible that the compiler is aligning each i32 on an 8-byte boundary, so the total size is 16 bytes. Common for modern 64-bit machines.

More on memory size of enums

use std::mem::size_of;

enum Message {
    Quit,
    ChangeColor(u8, u8, u8),
    Move { x: i32, y: i32 },
    Write(String),
}

enum Status {
    Pending,
    InProgress,
    Completed,
    Failed,
}

fn main() {
    // General case (on a 64-bit machine)
    println!("Size of Message: {} bytes", size_of::<Message>());

    // C-like enum
    println!("Size of Status: {} bytes", size_of::<Status>()); // Prints 1

    // References are addresses which are 64-bit (8 bytes)
    println!("Size of &i32: {} bytes", size_of::<&i32>()); // Prints 8
}

Displaying enums

By default Rust doesn't know how to display a new enum type.

Here we try to debug print the Direction enum.

// won't compile

enum Direction {
    North,
    East,
    South,
    West,
}

fn main() {
    let dir = Direction::North;
    println!("{:?}",dir);
}

Displaying enums (#[derive(Debug)])

Adding the #[derive(Debug)] attribute to the enum definition allows Rust to automatically implement the Debug trait.

#[derive(Debug)]
enum Direction {
    North,
    East,
    South,
    West,
}

use Direction::*;

fn main() {
    let dir = Direction::North;
    println!("{:?}",dir);
}

match as expression

The result of a match can be used as an expression.

Each branch (arm) returns a value.

#[derive(Debug)]
enum Direction {
    North,
    East,
    South,
    West,
}

use Direction::*;

fn main() {
    // swap east and west
    let mut dir_4 = North;
    println!("{:?}", dir_4);

    dir_4 = match dir_4 {
        East => West,
        West => {
            println!("Switching West to East");
            East
        }
        // variable mathching anything else
        _ => West,
    };

    println!("{:?}", dir_4);
}

Simplifying matching

Consider the following example (in which we want to use just one branch):

#[derive(Debug)]
enum DivisionResult {
    Ok(u32,u32),
    DivisionByZero,
}

fn divide(x:u32, y:u32) -> DivisionResult {
    if y == 0 {
        DivisionResult::DivisionByZero
    } else {
        DivisionResult::Ok(x / y, x % y)
    }
}

fn main() {
    match divide(8,3) {
        DivisionResult::Ok(result,remainder) => 
            println!("{} (remainder {})",result,remainder),
        _ => (), // <--- how to avoid this?
    };
}

This is a common enough pattern that Rust provides a shortcut for it.

Simplified matching with if let

if let allows for matching just one branch (arm)

#[derive(Debug)]
enum DivisionResult {
    Ok(u32,u32),
    DivisionByZero,
}

fn divide(x:u32, y:u32) -> DivisionResult {
    if y == 0 {
        DivisionResult::DivisionByZero
    } else {
        DivisionResult::Ok(x / y, x % y)
    }
}

fn main() {
    if let DivisionResult::Ok(result,reminder) = divide(8,7) { 
        println!("{} (remainder {})",result,reminder);
    };
}

Simplified matching with if let

Caution!

The single = is both an assignment and a pattern matching operator.

#[derive(Debug)]
enum Direction {
    North,
    East,
    South,
    West,
}

use Direction::*;

fn main() {
    let dir = North;
    if let North = dir {
            println!("North");
        }
}

if let with else

You can use else to match anything else.

#[derive(Debug)]
enum Direction {
    North,
    East,
    South,
    West,
}

use Direction::*;

fn main() {
    let dir = North;
    if let West = dir {
        println!("North");
    } else {
        println!("Something else");
    };
}

Enum variant goes on the left side

Caution!

You don't get a compile error, you get different behavior!

#[derive(Debug)]
enum Direction {
    North,
    East,
    South,
    West,
}

use Direction::*;

fn main() {
    let dir = North;

    // But it is important to have the enum
    // on the left hand side
    // if let West = dir {
    if let dir = West {
        println!("West");
    } else {
        println!("Something else");
    };
}

Single = for pattern matching

Remember to use the single = for pattern matching, not the double == for equality.

#[derive(Debug)]
enum Direction {
    North,
    East,
    South,
    West,
}

use Direction::*;

fn main() {
    let dir = North;

    // Don't do this.
    if let North == dir {
        println!("North");
    }
}

Best Practices

When to Use Enums:

  • State representation: Modeling different states of a system
  • Error handling: Representing success/failure with associated data
  • Variant data: When you need a type that can be one of several things
  • API design: Making invalid states unrepresentable

Design Guidelines:

  • Use descriptive names: Make variants self-documenting
  • Leverage associated data: Store relevant information with variants
  • Prefer exhaustive matching: Avoid catch-all patterns when possible
  • Use if let for single variant: When you only care about one case

In-Class Activity: "Traffic Light State Machine"

Activity Overview

Work in snall teams to create a simple traffic light system using enums and pattern matching.

Activity Instructions

You're given a TrafficLight enum.

Task:

  • Create a function next_light that takes a TrafficLight and returns the next state in the sequence: Red → Green(30) → Yellow(5) → Red(45) with the seconds remaining till the next light.
  • Create a function get_light_color that takes a reference to a TrafficLight (&TrafficLight) and returns a string slice representation (&str) of the current light state
  • Create a function get_time_remaining that takes a reference to a TrafficLight (&TrafficLight) and returns the time remaining till the next light as a u32
  • Call next_light, and print the light color and the time remaining till the next light.
  • Repeat this process 3 times.
#![allow(unused_variables)]
#![allow(dead_code)]

#[derive(Debug)]
enum TrafficLight {
    Red(u32),    // seconds remaining
    Yellow(u32), // seconds remaining  
    Green(u32),  // seconds remaining
}

// Your code here

Discussion Points

  • How do we get the value out of the enum variants?
  • How do we match on the enum variants?

A1 Midterm 1 Review

Table of Contents:

Revision 1 Posted Oct 7.

Changes:

  • Enabled Rust playground for all code blocks
  • In Loops and Arrays, modified what is not important and important
  • Deleted quesiton 15 about Some(x) and renumbered remaining questions
  • Updated code slightly in new question number 16

Reminders about the exam

  • Practice exam posted on Piazza
  • Up to 5 pages of notes, double sided, any font size
  • No electronic devices
  • Bring a pencil!
  • Spread out -- don't sit beside or in front or behind anyone

Development Tools

Shell/Terminal Commands

For the midterm, you should recognize and recall:

  • pwd - where am I?
  • ls - what's here?
  • ls -la - more info and hidden files
  • mkdir folder_name - make a folder
  • cd folder_name - move into a folder
  • cd .. - move up to a parent folder
  • cd ~ - return to the home directory
  • rm filename - delete a file

You DON'T need to: Memorize complex command flags or advanced shell scripting

Git Commands

For the midterm, you should recognize and recall:

  • git clone - get a repository, pasting in the HTTPS or SSH link
  • git status - see what's changed
  • git log - see the commit history
  • git branch - list all branches
  • git checkout branch_name - switch to a different branch
  • git checkout -b new_branch - create a branch called new_branch and switch to it
  • git add . - stage all recent changes
  • git commit -m "my commit message" - create a commit with staged changes
  • git push - send what's on my machine to GitHub
  • git pull - get changes from GitHub to my machine
  • git merge branch_name - merge branch branch_name into the current branch

You DON'T need to: revert, reset, resolving merge conflicts, pull requests

Cargo Commands

For the midterm, you should recognize and recall:

  • cargo new project_name - create project
  • cargo run - compile and run
  • cargo run --release - compile and run with optimizations (slower to compile, faster to run)
  • cargo build - just compile without running
  • cargo check - just check for errors without compiling
  • cargo test - run tests

You DON'T need to know: Cargo.toml syntax, how Cargo.lock works, advanced cargo features

Quick Questions: Tools

Question 1

Which command shows your current location on your machine?

Question 2

What's the correct order for the basic Git workflow?

  • A) add → commit → push
  • B) commit → add → push
  • C) push → add → commit
  • D) add → push → commit

Question 3

Which cargo command compiles your code without running it?

Rust Core Concepts

Compilers vs Interpreters

Key Concepts

  • Compiled languages (like Rust): Code is transformed into machine code before running
  • Interpreted languages (like Python): Code is executed line-by-line at runtime
  • The compiler checks your code for errors and translates it into machine code
  • The machine code is directly executed by your computer - it isn't Rust anymore!
  • A compiler error means your code failed to translate into machine code
  • A runtime error means your machine code crashed while running

Rust prevents runtime errors by being strict at compile time!

Variables and Types

Key Concepts

  • Defining variables: let x = 5;
  • Mutability: Variables are immutable by default, use let mut to allow them to change
  • Shadowing: let x = x + 1; creates a new x value without mut and lets you change types
  • Basic types: i32, f64, bool, char, &str, String
  • Rough variable sizes: Eg. i32 takes up 32-bits of space and its largest positive value is about half of u32's largest value
  • Type annotations: Rust infers types (let x = 5) or you can specify them (let x: i32 = 5)
  • Tuples: Creating (let x = (2,"hi")), accessing (let y = x.0 + 1), destructuring (let (a,b) = x)
  • Arrays: Creating (let x = [1,2,3]), accessing (let y = x[1])
  • Accessing and indexing elements of arrays, tuples and enums.

What's Not Important

  • Calculating exact variable sizes and max values
  • 2's complement notation for negative integers
  • Complex string manipulation details

String vs &str

Quick explanation

  • String = a string = owned text data (like a text file you own)
  • &str = a "string slice = borrowed text data (like looking at someone else's text)
  • A string literal like "hello" is a &str (you don't own it, it's baked into your program)
  • To convert from an &str to a String, use "hello".to_string() or String::from("hello")
  • To convert from a String to an &str, use &my_string (to create a "reference")

Don't stress! You can do most things with either one, and we won't make you do anything crazy with these.

Quick Questions: Rust basics

Question 4

What happens with this code?

#![allow(unused)]
fn main() {
let x = 5;
x = 10;
println!("{}", x);
}
  • A) Prints 5
  • B) Prints 10
  • C) Compiler error
  • D) Runtime error

Question 5

What's the type of x after this code?

#![allow(unused)]
fn main() {
let x = 5;
let x = x as f64;
let x = x > 3.0;
}
  • A) i32
  • B) f64
  • C) bool
  • D) Compiler error

Question 6

How do you access the second element of tuple t = (1, 2, 3)?

  • A) t[1]
  • B) t.1
  • C) t.2
  • D) t(2)

Functions

Key Concepts

  • Function signature: fn name(param1: type1, param2: type2) -> return_type, returned value must match return_type
  • Expressions and statements: Expressions reduce to values (no semicolon), statements take actions (end with semicolon)
  • Returning with return or an expression: Ending a function with return x; and x are equivalent
  • {} blocks are scopes and expressions: They reduce to the value of the last expression inside them
  • Unit type: Functions without a return type return ()
  • Best practices: Keep functions small and single-purpose, name them with verbs

What's Not Important

  • Ownership/borrowing mechanics (we'll cover this after the midterm)
  • Advanced function patterns

Quick Questions: Functions

Question 7

What is the value of mystery(x)?

#![allow(unused)]
fn main() {
fn mystery(x: i32) -> i32 {
    x + 5;
}
let x = 1;
mystery(x)
}
  • A) 6
  • B) i32
  • C) ()
  • D) Compiler error

Question 8

Which is a correct function signature for a function that takes two integers and returns their sum?

Question 9

Which version will compile

#![allow(unused)]
fn main() {
// Version A
fn func_a() {
    42
}

// Version B
fn func_b() {
    42;
}
}
  • A) A
  • B) B
  • C) Both
  • D) Neither

Question 10

What does this print?

#![allow(unused)]
fn main() {
let x = println!("hello");
println!("{:?}", x);
}
  • A) hello \n hello
  • B) hello \n ()
  • C) hello
  • D) ()
  • E) Compiler error
  • F) Runtime error

Loops and Arrays

Key Concepts

  • Ranges: 1..5 vs 1..=5
  • Arrays: Creating ([5,6] vs [5;6]), accessing (x[i]), 0-indexing
  • If/else: how to write if / else blocks with correct syntax
  • Loop types: for, while, loop - how and when to use each
  • break and continue: For controlling loop flow
  • Basic enumerating for (i, val) in x.iter().enumerate()
  • Compact notation (let x = if y ... or let y = loop {...)
  • Labeled loops, breaking out of an outer loop

What's Not Important

  • Enumerating over a string array with for (i, &item) in x.iter().enumerate()

Quick Questions: Loops & Arrays

Question 11

What's the difference between 1..5 and 1..=5?

Question 12

What does this print?

#![allow(unused)]
fn main() {
for i in 0..3 {
    if i == 1 { continue; }
    println!("{}", i);
}
}

Question 13

How do you get both index and value when looping over an array?

Enums and Pattern Matching

Key Concepts

  • Enum definition: Creating custom types with variants
  • Data in variants: Enums can hold data
  • match expressions: syntax by hand, needs to be exhaustive, how to use a catch-all (_)
  • #[derive(Debug)]: For making enums printable
  • Data extraction: Getting values out of enum variants with match, unwrap, or expect

Quick Questions: Enums & Match

Question 14

What's wrong with this code?

#![allow(unused)]
fn main() {
enum Status {
    Loading,
    Complete,
    Error,
}

let stat = Status::Loading;

match stat {
    Status::Loading => println!("Loading..."),
    Status::Complete => println!("Done!"),
}
}

Question 15

What does #[derive(Debug)] do?

Question 16

What does this return when called with divide_with_remainder(10, 2)?

How about with divide_with_remainder(10, 0)?

#![allow(unused)]
fn main() {
enum MyResult {
    Ok(u32,u32),  // Store the result of the integer division and the remainder
    DivisionByZero,
}
fn divide_with_remainder(a: u32, b: u32) -> MyResult {
    if b == 0 {
        MyResult::DivisionByZero
    } else {
        MyResult::Ok(a / b, a % b)
    }
}
}

Midterm Strategy

  • Focus on concepts: Understand the "why" behind the syntax and it will be easier to remember
  • Practice with your hands: Literally and figuratively - practice solving problems, and practice on paper
  • Take bigger problems step-by-step: Understand each line of code before reading the next. And make a plan before you start to hand-code

Good Luck!

Structs in Rust

About This Module

This module introduces Rust's struct (structure) types, which allow you to create custom data types by grouping related values together with named fields. Structs provide more semantic meaning than tuples by giving names to data fields and are fundamental for building complex data models.

Prework

Prework Readings

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. How do structs provide more semantic meaning than tuples?
  2. What are the advantages of named fields over positional access?
  3. How do tuple structs combine benefits of both tuples and structs?
  4. When would you choose structs over other data structures?
  5. How do structs help with type safety and preventing logical errors?

Learning Objectives

By the end of this module, you should be able to:

  • Define and instantiate regular structs with named fields
  • Create and use tuple structs for type safety
  • Access and modify struct fields
  • Use struct update syntax for efficient instantiation
  • Understand when to use structs vs. tuples vs. other data types
  • Apply structs in enum variants for complex data modeling
  • Design data structures using struct composition

What Are Structs?

Definition:

A struct (structure) is a custom data type that lets you name and package together multiple related values. Unlike tuples, structs give meaningful names to each piece of data.

Key Benefits:

  • Semantic meaning: Named fields make code self-documenting
  • Type safety: Prevent mixing up different types of data
  • Organization: Group related data logically
  • Maintainability: Changes are easier when fields have names

Structs

Previously we saw tuples, e.g., (12, 1.7, true), where we can mix different types of data.

Structs compared to tuples:

  • Similar: can hold items of different types
  • Different: the items have names
#![allow(unused)]
fn main() {
// Definition: list items (called fields)
//             and their types

struct Person {
    name: String,
    year_born: u16,
    time_100m: f64,
    likes_ice_cream: bool,
}
}

Struct Instantiation

  • Replace types with values
struct Person {
    name: String,
    year_born: u16,
    time_100m: f64,
    likes_ice_cream: bool,
}

fn main() {
    let mut cartoon_character: Person = Person {
        name: String::from("Tasmanian Devil"),
        year_born: 1954,
        time_100m: 7.52,
        likes_ice_cream: true,
    };
}

Struct Field Access

  • Use "." to access fields
struct Person {
    name: String,
    year_born: u16,
    time_100m: f64,
    likes_ice_cream: bool,
}

fn main() {
    let mut cartoon_character: Person = Person {
        name: String::from("Tasmanian Devil"),
        year_born: 1954,
        time_100m: 7.52,
        likes_ice_cream: true,
    };

    // Accessing fields: use ".field_name"
    println!("{} was born in {}", 
        cartoon_character.name, cartoon_character.year_born);
    
    cartoon_character.year_born = 2022;
    println!("{} was born in {}",
        cartoon_character.name, cartoon_character.year_born);
}

Challenge: How would we update the last println! statement to print
Tasmanian Devil was born in 2022, can run a mile in 7.52 seconds and likes ice cream ?

Tuple Structs

Example: The tuple (f64,f64,f64) could represent:

  • box size (e.g., height width depth)
  • Euclidean coordinates of a point in 3D

We can use tuple structs to give a name to a tuple and make it more meaningful.

fn main() {
    struct BoxSize(f64,f64,f64);
    struct Point3D(f64,f64,f64);

    let mut my_box = BoxSize(3.2,6.0,2.0);
    let mut p : Point3D = Point3D(-1.3,2.1,0.0);
}

Tuple Structs, cont.

  • Impossible to accidentally confuse different types of triples.
  • No runtime penalty! Verified at compilation.
fn main() {
    struct BoxSize(f64,f64,f64);
    struct Point3D(f64,f64,f64);

    let mut my_box = BoxSize(3.2,6.0,2.0);
    let mut p : Point3D = Point3D(-1.3,2.1,0.0);

    // won't work
    my_box = p;
}

Tuple Structs, cont.

  • Acessing via index
  • Destructuring
fn main() {
    struct Point3D(f64,f64,f64);

    let mut p : Point3D = Point3D(-1.3,2.1,0.0);

    // Acessing via index
    println!("{} {} {}",p.0,p.1,p.2);
    p.0 = 17.2;

    // Destructuring
    let Point3D(first,second,third) = p;
    println!("{} {} {}", first, second, third);
}

Named structs in enums

Structs with braces and exchangable with tuples in many places

enum LPSolution {
    None,
    Point{x:f64,y:f64}
}

fn main() {
    let example = LPSolution::Point{x:1.2, y:4.2};

    if let LPSolution::Point{x:first,y:second} = example {
        println!("coordinates: {} {}", first, second);
    };
}

How is that different from enum variants with values?

enum LPSolution2 {
    None,
    Point(f64,f64)
}

fn main() {
    let example = LPSolution2::Point(1.2, 4.2);

    if let LPSolution2::Point(first,second) = example {
        println!("coordinates: {} {}", first, second);
    };
}

Recap and Next Steps

Recap

  • Structs are a way to group related data together
  • Tuple structs are a way to give a name to a tuple
  • Named structs in enums are a way to group related data together
  • Structs are critical to Rust's OO capabilities

Next Steps

  • We will see how connect structs to methods (e.g. functions)
  • Important step towards Object-Oriented style of programming in Rust

Method Syntax

About This Module

This module introduces method syntax in Rust, which brings aspects of object-oriented programming to the language by combining properties and methods in one object. You'll learn how methods are functions defined within the context of a struct and how to use impl blocks to define methods.

Prework

Prework Readings

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. How do methods differ from regular functions in Rust?
  2. What is the significance of the self parameter in method definitions?
  3. When would you choose to use associated functions vs. methods?
  4. How do methods help with code organization and encapsulation?
  5. What are the benefits of the impl block approach compared to other languages?

Learning Objectives

By the end of this module, you should be able to:

  • Define methods within impl blocks for structs
  • Understand the role of self in method definitions
  • Create associated functions that don't take self
  • Use methods to encapsulate behavior with data
  • Apply method syntax for cleaner, more readable code

Method Syntax Overview

Brings aspects of object-oriented programming to Rust: combine properties and methods in one object.

Methods are functions that are defined within the context of a struct.

The first parameter is always self, which refers to the instance of the struct the method is being called on.

Use and impl (implementation) block on the struct to define methods.

struct Point {  // stores x and y coordinates
    x: f64,
    y: f64,
}

struct Rectangle {  // store upper left and lower right points
    p1: Point,
    p2: Point,
}

impl Rectangle {
    // This is a method
    fn area(&self) -> f64 {
        // `self` gives access to the struct fields via the dot operator
        let Point { x: x1, y: y1 } = self.p1;
        let Point { x: x2, y: y2 } = self.p2;

        // `abs` is a `f64` method that returns the absolute value of the
        // caller
        ((x1 - x2) * (y1 - y2)).abs()
    }

    fn perimeter(&self) -> f64 {
        let Point { x: x1, y: y1 } = self.p1;
        let Point { x: x2, y: y2 } = self.p2;

        2.0 * ((x1 - x2).abs() + (y1 - y2).abs())
    }
}

fn main() {
    let rectangle = Rectangle {
        p1: Point{x:0.0, y:0.0},
        p2: Point{x:3.0, y:4.0},
    };

    println!("Rectangle perimeter: {}", rectangle.perimeter());
    println!("Rectangle area: {}", rectangle.area());
}

Associated Functions without self parameter

Useful as constructors.

You can have more than one impl block on the same struct.

struct Point {  // stores x and y coordinates
    x: f64,
    y: f64,
}

struct Rectangle {  // store upper left and lower right points
    p1: Point,
    p2: Point,
}

impl Rectangle {
    // This is a method
    fn area(&self) -> f64 {
        // `self` gives access to the struct fields via the dot operator
        let Point { x: x1, y: y1 } = self.p1;
        let Point { x: x2, y: y2 } = self.p2;

        // `abs` is a `f64` method that returns the absolute value of the
        // caller
        ((x1 - x2) * (y1 - y2)).abs()
    }

    fn perimeter(&self) -> f64 {
        let Point { x: x1, y: y1 } = self.p1;
        let Point { x: x2, y: y2 } = self.p2;

        2.0 * ((x1 - x2).abs() + (y1 - y2).abs())
    }
}

impl Rectangle {
    fn new(p1: Point, p2: Point) -> Rectangle {
        Rectangle { p1, p2 }  // instantiate a Rectangle struct and return it
    }
}

fn main() {
    // instantiate a Rectangle struct and return it
    let rect = Rectangle::new(Point{x:0.0, y:0.0}, Point{x:3.0, y:4.0});  
    println!("Rectangle area: {}", rect.area());
}

Common Patterns

Builder Pattern with Structs:

struct Config {
    host: String,
    port: u16,
    debug: bool,
    timeout: u32,
}

impl Config {
    fn new() -> Self {
        Config {
            host: String::from("localhost"),
            port: 8080,
            debug: false,
            timeout: 30,
        }
    }
    
    fn with_host(mut self, host: &str) -> Self {
        self.host = String::from(host);
        self
    }
    
    fn with_debug(mut self, debug: bool) -> Self {
        self.debug = debug;
        self
    }
}

fn main() {
    // Usage
    let config = Config::new()
            .with_host("api.example.com")
            .with_debug(true);
}

Methods Continued

About This Module

This module revisits and expands on method syntax in Rust, focusing on different types of self parameters and their implications for ownership and borrowing. You'll learn the differences between self, &self, and &mut self, and when to use each approach for method design.

Prework

Prework Readings

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. What are the implications of using self vs &self vs &mut self in method signatures?
  2. How does method call syntax relate to function call syntax with explicit references?
  3. When would you design a method to take ownership of self?
  4. How do method calls interact with Rust's borrowing rules?
  5. What are the trade-offs between different self parameter types?

Learning Objectives

By the end of this module, you should be able to:

  • Distinguish between self, &self, and &mut self parameter types
  • Understand when methods take ownership vs. borrow references
  • Design method APIs that appropriately handle ownership and mutability
  • Apply method call syntax with different reference types
  • Recognize the implications of different self parameter choices

Method Review

We saw these in the previous lecture.

  • We can add functions that are directly associated with structs and enums!
    • Then we could call them: road.display() or road.update_speed(25)
  • How?
    • Put them in the namespace of the type
    • make self the first argument
#[derive(Debug)]
struct Road {
    intersection_1: u32,
    intersection_2: u32,
    max_speed: u32,
}

impl Road {
    
    // constructor
    fn new(i1:u32,i2:u32,speed:u32) -> Road {
        Road {
            intersection_1: i1,
            intersection_2: i2,
            max_speed: speed,
        }
    }
    // note &self: immutable reference
    fn display(&self) {
        println!("{:?}",*self);
    }
}

// You can invoke the display method on the road instance
// or on a reference to the road instance.

fn main() {
    let mut road = Road::new(1,2,35);

    road.display();
    &road.display();
    (&road).display();
}

In C++ the syntax is different. It would be something like:

road.display();
(&road)->display();

Method with immutable self reference

Rember that self is a reference to the instance of the struct.

By default, self is an immutable reference, so we can't modify the struct.

The following will cause a compiler error.

#![allow(unused)]
fn main() {
struct Road {
    intersection_1: u32,
    intersection_2: u32,
    max_speed: u32,
}

// ERROR
impl Road {
    fn update_speed(&self, new_speed:u32) {
        self.max_speed = new_speed;
    }
}
}

Method with mutable self reference

Let's change it to a mutable reference.

#[derive(Debug)]
struct Road {
    intersection_1: u32,
    intersection_2: u32,
    max_speed: u32,
}

impl Road {
    // constructor
    fn new(i1:u32,i2:u32,speed:u32) -> Road {
        Road {
            intersection_1: i1,
            intersection_2: i2,
            max_speed: speed,
        }
    }

    // note &self: immutable reference
    fn display(&self) {
        println!("{:?}",*self);
    }

    fn update_speed(&mut self, new_speed:u32) {
        self.max_speed = new_speed;
    }
}

fn main() {
    let mut road = Road::new(1,2,35);

    road.display();
    road.update_speed(45);
    road.display();
}

Methods that take ownership of self

There are some gotchas to be aware of.

Consider the following code:

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Road {
    intersection_1: u32,
    intersection_2: u32,
    max_speed: u32,
}

impl Road {
    
    fn this_will_move(self) -> Road {   // this will take ownership of the instance of Road
        self
    }
    
    fn this_will_not_move(&self) -> &Road {  // this will _not_ take ownership of the instance of Road
        self
    }
}
}

We'll talk about ownership and borrowing in more detail later.

Methods that borrow self

Let's experiment a bit.

#![allow(unused_variables)]

#[derive(Debug)]
struct Road {
    intersection_1: u32,
    intersection_2: u32,
    max_speed: u32,
}

impl Road {
    // constructor
    fn new(i1:u32,i2:u32,speed:u32) -> Road {
        Road {
            intersection_1: i1,
            intersection_2: i2,
            max_speed: speed,
        }
    }

    // note &self: immutable reference
    fn display(&self) {
        println!("{:?}",*self);
    }

    fn update_speed(&mut self, new_speed:u32) {
        self.max_speed = new_speed;
    }

    fn this_will_move(self) -> Road {   // this will take ownership of the instance of Road
        self
    }
    
    fn this_will_not_move(&self) -> &Road {
        self
    }
}

fn main() {
  let r = Road::new(1,2,35);       // create a new instance of Road, r
  let r3 = r.this_will_not_move(); // create a new reference to r, r3

  // run the code with the following line commented, then try uncommenting it
  //let r2 = r.this_will_move();  // this will take ownership of r

  r.display();

  // r2.display();
  r3.display();
}

Methods (summary)

  • Make first parameter self
  • Various options:
    • self: move will occur
    • &self: self will be immutable reference
    • &mut self: self will be mutable reference

In-Class Poll

A1 Piazza Poll:

Select ALL statements below that are true. Multiple answers may be correct.

  • Structs can hold items of different types, similar to tuples
  • Tuple structs provide type safety by preventing confusion between different tuple types
  • Methods with &self allow you to modify the struct's fields
  • You can have multiple impl blocks for the same struct
  • Associated functions without self are commonly used as constructors
  • Enum variants can contain named struct-like data using curly braces {}
  • Methods are called using :: syntax, like rectangle::area()

In-Class Activity

Coding Exercise: Student Grade Tracker (15 minutes)

Objective: Practice defining structs and implementing methods with different types of self parameters.

Scenario: You're building a simple grade tracking system for a course. Create a Student struct and implement various methods to manage student information and grades.

You can work in teams of 2-3 students. Suggest cargo new grades-struct to create a new project and then work in VS Code.

Copy your answer into Gradescope.

Part 1: Define the Struct (3 minutes)

Create a Student struct with the following fields:

  • name: String (student's name)
  • id: u32 (student ID number)
  • grades: [f64; 5] (array of up to 5 grades)
  • num_grades: usize (number of grades added)

Part 2: Implement Methods (10 minutes)

Implement the following methods in an impl block:

  1. Constructor (associated function):

    • new(name: String, id: u32) -> Student
    • Creates a new student with grades initialized to [0.0; 5] and num_grades set to 0
  2. Immutable reference methods (&self):

    • display(&self) - debug prints the Student struct
    • average_grade(&self) -> f64 - returns average grade
    • Optional: get_letter_grade(&self) -> Option<char> - returns 'A' (≥90), 'B' (≥80), 'C' (≥70), 'D' (≥60), or 'F' (<60)
  3. Mutable reference methods (&mut self):

    • add_grade(&mut self, grade: f64) - adds a grade to the student's record

Part 3: Test Your Implementation (2 minutes)

Write a main function that creates a new student.

We provide code to:

  • Add several grades
  • Displays the student info, average and letter grade

Expected Output Example:

Student { name: "Alice Smith", id: 12345, grades: [85.5, 92.0, 78.5, 88.0, 0.0], num_grades: 4 }
Average grade: 86
Letter grade: B

Starter Code:

#![allow(unused)]

#[derive(Debug)]
struct Student {
    // TODO: Add fields
}

impl Student {
    // TODO: Implement methods
}

fn main() {
    let mut student = ...  // TODO: Create a new student

    // Add several grades
    student.add_grade(85.5);
    student.add_grade(92.0);
    student.add_grade(78.5);
    student.add_grade(88.0);

    // Display initial information
    student.display();
    println!();
}

Ownership and Borrowing in Rust

Introduction

  • Rust's most distinctive feature: ownership system
  • Enables memory safety without garbage collection
  • Compile-time guarantees with zero runtime cost
  • Three key concepts: ownership, borrowing, and lifetimes

Prework

Prework Readings

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. What problems does Rust's ownership system solve compared to manual memory management?
  2. How does ownership differ from garbage collection in other languages?
  3. What is the difference between moving and borrowing a value?
  4. When would you use Box<T> instead of storing data on the stack?
  5. How do mutable and immutable references help prevent data races?

Memory Layout: Stack vs Heap

Stack:

  • Fast, fixed-size allocation
  • LIFO (Last In, First Out) structure
  • Stores data with known, fixed size at compile time
  • Examples: integers, booleans, fixed-size arrays

Heap:

  • Slower, dynamic allocation
  • For data with unknown or variable size
  • Allocator finds space and returns a pointer
  • Examples: String, Vec, Box

Stack Memory Example

fn main() {
    let x = 5;           // stored on stack
    let y = true;        // stored on stack
    let z = x;           // copy of value on stack
    println!("{}, {}", x, z);  // both still valid
}
  • Simple types implement Copy trait
  • Assignment creates a copy, both variables remain valid

String and the Heap

Heap Memory: The String Type

Let's look more closely at the String type.

#![allow(unused)]
fn main() {
let s1 = String::from("hello");
}
  • String stores pointer, length, capacity on stack
  • Actual string data stored on heap

In fact we can inspect the memory layout of a String:

#![allow(unused)]
fn main() {
let mut s = String::from("hello");

println!("&s:{:p}", &s);
println!("ptr: {:p}", s.as_ptr());
println!("len: {}", s.len());
println!("capacity: {}\n", s.capacity());

// Let's add some more text to the string
s.push_str(", world!");
println!("&s:{:p}", &s);
println!("ptr: {:p}", s.as_ptr());
println!("len: {}", s.len());
println!("capacity: {}", s.capacity());
}

Shallow Copy with Move

fn main() {
    let s1 = String::from("hello");
    // s1 has three parts on stack:
    // - pointer to heap data
    // - length: 5
    // - capacity: 5
    
    let s2 = s1;  // shallow copy of stack data

    println!("{}", s1);  // ERROR! s1 is no longer valid
    println!("{}", s2);     // OK
}
  • String stores pointer, length, capacity on stack
  • Actual string data stored on heap

Shallow Copy:

  • Copying the pointer, length, and capacity
  • The actual string data is not copied
  • The owner of the string data is transferred to the new structure

#![allow(unused)]
fn main() {
let s1 = String::from("hello");

println!("&s1:{:p}", &s1);
println!("ptr: {:p}", s1.as_ptr());
println!("len: {}", s1.len());
println!("capacity: {}\n", s1.capacity());

let s2 = s1;

println!("&s2:{:p}", &s2);
println!("ptr: {:p}", s2.as_ptr());
println!("len: {}", s2.len());
println!("capacity: {}", s2.capacity());
}

The Ownership Rules

  1. Each value in Rust has an owner
  2. There can only be one owner at a time
  3. When the owner goes out of scope, the value is dropped

These rules prevent:

  • Double free errors
  • Use after free
  • Data races

Ownership Transfer: Move Semantics

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // ownership moves from s1 to s2
    
    // s1 is now invalid - compile error if used
    println!("{}", s2);  // OK
    
    // When s2 goes out of scope, memory is freed
}
  • Move prevents double-free
  • Only one owner can free the memory

Clone: Deep Copy

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();  // deep copy of heap data
    
    println!("s1 = {}, s2 = {}", s1, s2);  // both valid
}
  • clone() creates a full copy of heap data
  • Both variables are independent owners
  • More expensive operation

Vec and the Heap

Vec: Dynamic Arrays on the Heap

What is Vec?

  • Vec<T> is Rust's growable, heap-allocated array type
  • Generic over type T (e.g., Vec<i32>, Vec<String>)
  • Contiguous memory allocation for cache efficiency
  • Automatically manages capacity and growth

Three ways to create a Vec:

#![allow(unused)]
fn main() {
// 1. Empty vector with type annotation
let v1: Vec<i32> = Vec::new();

// 2. Using vec! macro with initial values
let v2 = vec![1, 2, 3, 4, 5];

// 3. With pre-allocated capacity
let v3: Vec<i32> = Vec::with_capacity(10);
}

Vec Memory Structure

#![allow(unused)]
fn main() {
let mut v = Vec::new();
v.push(1);
v.push(2);
v.push(3);
    
// Vec structure (on stack):
// - pointer to heap buffer
// - length: 3 (number of elements)
// - capacity: (at least 3, often more)

println!("&v:{:p}", &v);
println!("ptr: {:p}", v.as_ptr());
println!("Length: {}", v.len());
println!("Capacity: {}", v.capacity());

}
  • Pointer: points to heap-allocated buffer
  • Length: number of initialized elements
  • Capacity: total space available before reallocation

Vec Growth and Reallocation

fn main() {
    let mut v = Vec::new();
    println!("Initial capacity: {}", v.capacity());  // 0
    
    v.push(1);
    println!("After 1 push: {}", v.capacity());      // typically 4
    
    v.push(2);
    v.push(3);
    v.push(4);
    v.push(5);  // triggers reallocation
    println!("After 5 pushes: {}", v.capacity());    // typically 8
}
  • Capacity doubles when full (amortized O(1) push)
  • Reallocation: new buffer allocated, old data copied
  • Pre-allocate with with_capacity() to avoid reallocations

Accessing Vec Elements

fn main() {
    let v = vec![10, 20, 30, 40, 50];
    
    // Indexing - panics if out of bounds
    let third = v[2];
    println!("Third element: {}", third);
    
    // Using get() - returns Option<T>
    // Safely handles out of bounds indices
    match v.get(2) {
        Some(value) => println!("Third element: {}", value),
        None => println!("No element at index 2"),
    }
}

Option<T>

Option<T> is an enum that can be either Some(T) or None.

Defined in the standard library as:

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

Let's you handle the case where there is no return value.

fn main() {
    let v = vec![1, 2, 3, 4, 5];
    match v.get(0) {
        Some(value) => println!("Element: {}", value),
        None => println!("No element at index"),
    }
}

Modifying Vec Elements

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];
    
    // Direct indexing for modification
    v[0] = 10;
    
    // Adding elements
    v.push(6);           // add to end
    
    // Removing elements
    let last = v.pop();  // remove from end, returns Option<T>
    
    // Insert/remove at position
    v.insert(2, 99);     // insert 99 at index 2
    v.remove(1);         // remove element at index 1
    
    println!("{:?}", v);
}

Vec Ownership

fn main() {
    let v1 = vec![1, 2, 3, 4, 5];
    let v2 = v1;  // ownership moves
    
    // println!("{:?}", v1);  // ERROR!
    println!("{:?}", v2);     // OK
    
    let v3 = v2.clone();      // deep copy
    println!("{:?}, {:?}", v2, v3);  // both OK
}
  • Vec follows same ownership rules as String
  • Move transfers ownership of heap allocation

Functions and Ownership

Functions and Ownership

fn takes_ownership(s: String) {
    println!("{}", s);
}  // s is dropped here

fn main() {
    let s = String::from("hello");
    takes_ownership(s);
    // println!("{}", s);  // ERROR! s was moved
}
  • Passing to function transfers ownership
  • Original variable becomes invalid

Returning Ownership

fn gives_ownership(s: String) -> String {
    let new_s = s + " world";
    new_s  // ownership moves to caller
}

fn main() {
    let s1 = String::from("hello");
    let s2 = gives_ownership(s1);
    println!("{}", s2);  // OK
}
  • Return value transfers ownership out of function
  • Caller becomes new owner

References: Borrowing Without Ownership

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);  // borrow with &
    
    println!("'{}' has length {}", s1, len);  // s1 still valid!
}

fn calculate_length(s: &String) -> usize {
    s.len()
}  // s goes out of scope, but doesn't own data
  • & creates a reference (borrow)
  • Original owner retains ownership
  • Reference allows reading data

Immutable References

fn main() {
    let s = String::from("hello");
    
    let r1 = &s;  // immutable reference
    let r2 = &s;  // another immutable reference
    let r3 = &s;  // yet another
    
    println!("{}, {}, {}", r1, r2, r3);  // all valid

    // Let's take a look at the memory layout
    println!("&s: {:p}, s.as_ptr(): {:p}", &s, s.as_ptr());
    println!("&r1: {:p}, r1.as_ptr(): {:p}", &r1, r1.as_ptr());
    println!("&r2: {:p}, r2.as_ptr(): {:p}", &r2, r2.as_ptr());
    println!("&r3: {:p}, r3.as_ptr(): {:p}", &r3, r3.as_ptr());
}
  • Multiple immutable references allowed simultaneously
  • Cannot modify through immutable reference
// ERROR
fn main() {
    let s = String::from("hello");
    change(&s);
    println!("{}", s);
}

fn change(s: &String) {
    s.push_str(", world");
}

Mutable References

fn main() {
    let mut s = String::from("hello");
    
    change(&mut s);  // mutable reference with &mut
    println!("{}", s);  // prints "hello, world"
}

fn change(s: &mut String) {
    s.push_str(", world");
}
  • &mut creates mutable reference
  • Allows modification of borrowed data

Mutable Reference Restrictions

fn main() {
    let mut s = String::from("hello");
    
    let r1 = &mut s;
    let r2 = &mut s;  // ERROR! Only one mutable reference
    
    println!("{}", r1);
}
  • Only ONE mutable reference at a time
  • Prevents data races at compile time
  • No simultaneous readers when there's a writer

Mixing References: Not Allowed

fn main() {
    let mut s = String::from("hello");
    
    let r1 = &s;      // immutable
    let r2 = &s;      // immutable
    let r3 = &mut s;  // ERROR! Can't have mutable with immutable
    
    println!("{}, {}", r1, r2);
}
  • Cannot have mutable reference while immutable references exist
  • Immutable references expect data won't change

Reference Scopes and Non-Lexical Lifetimes

fn main() {
    let mut s = String::from("hello");
    
    let r1 = &s;
    let r2 = &s;
    println!("{}, {}", r1, r2);
    // r1 and r2 no longer used after this point
    
    let r3 = &mut s;  // OK! Previous references out of scope
    println!("{}", r3);
}
  • Reference scope: from introduction to last use, rather than lexical scope (till end of block)
  • Non-lexical lifetimes allow more flexible borrowing

Vec with References

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];
    
    let first = &v[0];  // immutable borrow
    
    // v.push(6);  // ERROR! Can't mutate while borrowed
    
    println!("First element: {}", first);
    
    v.push(6);  // OK now, first is out of scope
}
  • Borrowing elements prevents mutation of Vec
  • Protects against invalidation (reallocation)

Function Calls: Move vs Reference vs Mutable Reference

fn process_string(s: String) { }         // takes ownership (move)
fn read_string(s: &String) { }           // immutable borrow
fn modify_string(s: &mut String) { }     // mutable borrow

fn main() {
    let mut s = String::from("hello");
    
    read_string(&s);        // borrow
    modify_string(&mut s);  // mutable borrow
    read_string(&s);        // borrow again
    process_string(s);      // move
    // s is now invalid
}

Method Calls with Different Receivers

#![allow(unused)]
fn main() {
impl String {
    // Takes ownership: self
    fn into_bytes(self) -> Vec<u8> { /* ... */ }
    
    // Immutable borrow: &self
    fn len(&self) -> usize { /* ... */ }
    
    // Mutable borrow: &mut self
    fn push_str(&mut self, s: &str) { /* ... */ }
}
}
  • self: method takes ownership (consuming)
  • &self: method borrows immutably
  • &mut self: method borrows mutably

Method Call Examples

  • It can be difficult to understand which ownership rules are being applied to a method call.
fn main() {
    let mut s = String::from("hello");
    
    let len = s.len();           // &self - immutable borrow
    println!("{}, length: {}", s, len);

    s.push_str(" world 🌎");        // &mut self - mutable borrow
    let len = s.len();          // &self - immutable borrow
    println!("{}, length: {}", s, len);
    
    let bytes = s.into_bytes();  // self - takes ownership
    // s is now invalid
    println!("{:?}", bytes);

    let t = String::from_utf8(bytes).unwrap();
    println!("{}", t);
}

Vec Method Patterns

fn main() {
    let mut v = vec![1, 2, 3];
    
    v.push(4);              // &mut self
    let last = v.pop();     // &mut self, returns Option<T>
    let len = v.len();      // &self
    
    // Immutable iteration
    // What happens if you take away the &?
    for item in &v {        // iterate with &Vec
        println!("{}", item);
    }
    
    // Mutable iteration
    for item in &mut v {    // iterate with &mut Vec
        *item *= 2;
        println!("{}", item);
    }
    println!("{:?}", v);

    // Taking ownership
    for item in v {
        println!("{}", item);
    }
    //println!("{:?}", v);  // ERROR! v is now invalid
}

Note: It is instructive to create a Rust project and put this mode in main.rs then look at it in VSCode with the Rust Analyzer extension. Note the datatype decorations that VSCode places next to the variables.

Note #2: The println! macro is pretty flexible in the types of arguments it can take. In the example above, we are passing it a &i32, a &mut i32, and a i32.

Key Takeaways

  • Stack: fixed-size, fast; Heap: dynamic, flexible
  • Ownership ensures memory safety without garbage collection
  • Move semantics prevent double-free
  • Borrowing allows temporary access without ownership transfer
  • One mutable reference XOR many immutable references
  • References must be valid (no dangling pointers)
  • Compiler enforces these rules at compile time

Best Practices

  1. Prefer borrowing over ownership transfer when possible
  2. Use immutable references by default
  3. Keep mutable reference scope minimal
  4. Let the compiler guide you with error messages
  5. Clone only when necessary (performance cost)
  6. Understand whether functions need ownership or just access

In-Class Exercise (10 minutes)

Challenge: Fix the Broken Code

The following code has several ownership and borrowing errors. Your task is to fix them so the code compiles and runs correctly.

I'll call on volunteers to present their solutions.

fn main() {
    let mut numbers = vec![1, 2, 3, 4, 5];
    
    // Task 1: Calculate sum without taking ownership
    let total = calculate_sum(numbers);
    
    // Task 2: Double each number in the vector
    double_values(numbers);
    
    // Task 3: Print both the original and doubled values
    println!("Original sum: {}", total);
    println!("Doubled values: {:?}", numbers);
    
    // Task 4: Add new numbers to the vector
    add_numbers(numbers, vec![6, 7, 8]);
    println!("After adding: {:?}", numbers);
}

fn calculate_sum(v: Vec<i32>) -> i32 {
    let mut sum = 0;
    for num in v {
        sum += num;
    }
    sum
}

fn double_values(v: Vec<i32>) {
    for num in v {
        num *= 2;
    }
}

fn add_numbers(v: Vec<i32>, new_nums: Vec<i32>) {
    for num in new_nums {
        v.push(num);
    }
}

Hints:

  • Think about which functions need ownership vs borrowing
  • Consider when you need & vs &mut
  • Remember: you can't modify through an immutable reference
  • The original vector should still be usable in main after function calls

Let's Review

Review solutions.

Slices in Rust

About This Module

This module introduces slices, a powerful feature in Rust that provides references to contiguous sub-sequences of collections. We'll explore how slices work with arrays and vectors, their memory representation, and how they interact with Rust's borrowing rules.

Prework

Prework Reading

Read the following sections from "The Rust Programming Language" book:

You might want to go back and review:

Pre-lecture Reflections

Before class, consider these questions:

  1. How do slices provide safe access to sub-sequences without copying data?
  2. What are the advantages of slices over passing entire arrays or vectors?
  3. How do borrowing rules apply to slices and prevent data races?
  4. When would you use slices instead of iterators for processing sub-sequences?
  5. What are the memory efficiency benefits of slices compared to copying data?

Learning Objectives

By the end of this module, you should be able to:

  • Create and use immutable and mutable slices from arrays and vectors
  • Understand slice syntax and indexing operations
  • Apply borrowing rules correctly when working with slices
  • Analyze the memory representation of slices
  • Use slices for efficient sub-sequence processing without data copying
  • Design functions that work with slice parameters for flexibility

Slices (§4.3)

Slice = reference to a contiguous sub-sequence of elements in a collection

Slices of an array:

  • array of type [T, _], e.g. datatype and length
  • slice of type &[T] (immutable) or &mut [T] (mutable)
fn main() {
    let arr: [i32; 5] = [0,1,2,3,4];
    println!("arr: {:?}", arr);

    // immutable slice of an array
    let slice: &[i32] = &arr[1..3];
    println!("slice: {:?}",slice);
    println!("slice[0]: {}", slice[0]);
}

The slice slice is a reference to the array arr from index 1 to 3 and hence is borrowed from arr.

Immutable slices

Note:

  • The slice is a reference to the array, which by default is immutable.
  • Even if the source array is mutable, the slice is immutable.
fn main() {
    let mut arr: [i32; 5] = [0,1,2,3,4];
    println!("arr: {:?}", arr);

    // immutable slice of an array
    let slice: &[i32] = &arr[1..3];
    println!("slice: {:?}",slice);
    println!("slice[0]: {}", slice[0]);

    slice[0] = 100;  // ERROR! Cannot modify an immutable slice
    println!("slice: {:?}", slice);
    println!("slice[0]: {}", slice[0]);
}

Mutable slices

We can create a mutable slice from a mutable array which borrows from arr mutably.

fn main(){
    // mutable slice of an array
    let mut arr = [0,1,2,3,4];
    println!("arr: {:?}", arr);

    let mut slice = &mut arr[2..4];
    println!("slice: {:?}",slice);

    // ERROR: Cannot modify the source array after a borrow
    //arr[0] = 10;
    //println!("arr: {:?}", arr);

    println!("\nLet's modify the slice[0]");
    slice[0] = slice[0] * slice[0];
    println!("slice[0]: {}", slice[0]);
    println!("slice: {:?}", slice);

    println!("arr: {:?}", arr);
}

What about this?

What's happening here?!?!?

Why are we able to modify the array after the slice is created?

fn main() {
    let mut arr: [i32; 5] = [0,1,2,3,4];
    println!("arr: {:?}", arr);

    // immutable slice of an array
    let slice: &[i32] = &arr[1..3];
    println!("slice: {:?}",slice);
    println!("slice[0]: {}", slice[0]);

    arr[0] = 10;  // OK! We can modify the array
    println!("arr: {:?}", arr);

    // What happens if you uncomment this line?
    //println!("slice: {:?}", slice);

}

Answer:

Slices with Vectors

Work for vectors too!

fn main() {
let mut v = vec![0,1,2,3,4];
{
    let slice = &v[1..3];
    println!("{:?}",slice);
}

{
    let mut slice = &mut v[1..3];
    
    // iterating over slices works as well
    for x in slice {
        *x *= 1000;
    }
};
println!("{:?}",v);
}

Slices are references: all borrowing rules still apply!

  • At most one mutable reference at a time
  • No immutable references allowed with a mutable reference
  • Many immutable references allowed simultaneously
#![allow(unused)]
fn main() {
// this won't work!
let mut v = vec![1,2,3,4,5,6,7];
{
    let ref_1 = &mut v[2..5];
    let ref_2 = &v[1..3];
    ref_1[0] = 7;
    println!("{}",ref_2[1]);
}
}
#![allow(unused)]
fn main() {
// and this reordering will
let mut v = vec![1,2,3,4,5,6,7];
{
    let ref_1 = &mut v[2..5];
    ref_1[0] = 7;   // ref_1 can be dropped
    let ref_2 = &v[1..3];
    println!("{}",ref_2[1]);
}
}

Memory representation of slices

  • Pointer
  • Length

Memory representation of slices

Let's return to &str?

&str is slice

  • &str can be a slice of a string literal or a slice of a String

  • &str itself (the reference) is stored on the stack,

  • but the string data it points to can be in different locations depending on the context.

Let's break this down:

The &str Data (Various Locations)

The actual string data that &str points to can be in:

  1. Binary's read-only data segment (most common for string literals):
#![allow(unused)]
fn main() {
let s: &str = "hello";  // "hello" is in read-only memory

println!("&s:{:p}", &s);
println!("ptr: {:p}", s.as_ptr());
println!("len: {}", s.len());
// println!("capacity: {}\n", s.capacity()); // ERROR! Not applicable
}
  1. Heap (when it's a slice of a String):
#![allow(unused)]
fn main() {
let string = String::from("hello");
let s: &str = &string;  // points to heap-allocated data

println!("&s:{:p}", &s);
println!("ptr: {:p}", s.as_ptr());
println!("len: {}", s.len());
}

True/False Statements on Rust Slices

A slice of type `&[i32]` is always immutable, even if it's created from a mutable array.

TRUE - "The slice is a reference to the array, which by default is immutable. Even if the source array is mutable, the slice is immutable." To get a mutable slice, you need to explicitly use `&mut [T]` syntax.


Slices in Rust consist of two components in memory: a pointer to the data and a length.

TRUE


You can have both an immutable slice and a mutable slice of the same vector active at the same time.

FALSE - Slices are references: all borrowing rules still apply!


The `&str` type is a slice, and the actual string data it points to is always stored in the binary's read-only data segment.

FALSE. While `&str` is indeed a slice, the string data it points to can be in different locations depending on the context, including the binary's read-only data segment (for string literals) or the heap (when it's a slice of a `String`).


Slices work with both arrays and vectors in Rust.

TRUE

Enter your answers into piazza poll.

Summary

  • Slices are references to contiguous sub-sequences of elements in a collection
  • Slices are immutable by default
  • We can create mutable slices from mutable arrays
  • Slices are references: all borrowing rules still apply!
  • &str is a slice of a string literal or a slice of a String
  • &str itself (the reference) is stored on the stack, but the string data it points to can be in different locations depending on the context.

Modules and Organization

About This Module

This module introduces Rust's module system for organizing code into logical namespaces. You'll learn how to create modules, control visibility with public/private access, navigate module hierarchies, and organize code across multiple files.

Prework

Prework Readings

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. Why is code organization important in larger software projects?
  2. What are the benefits of controlling which parts of your code are public vs. private?
  3. How do namespaces prevent naming conflicts in large codebases?
  4. When would you organize code into separate files vs. keeping it in one file?
  5. How do module systems help with code maintainability and collaboration?

Learning Objectives

By the end of this module, you should be able to:

  • Create and organize code using Rust's module system
  • Control access to code using pub and private visibility
  • Navigate module hierarchies using paths and use statements
  • Organize modules across multiple files and directories
  • Design clean module interfaces for code reusability
  • Apply module patterns to structure larger programs

Introduction to Modules

Up to now: our functions and data types (mostly) in the same namespace:

  • exception: functions in structs and enums

Question: What is a namespace?

One can create a namespace, using mod

mod things_to_say {
    fn say_hi() {
        say("Hi");
    }
    
    fn say_bye() {
        say("Bye");
    }
    
    fn say(what: &str) {
        println!("{}!",what);
    }
}
fn main() {}

Intro, continued...

You have to use the module name to refer to a function.

That's necessary, but not sufficient!

mod things_to_say {
    fn say_hi() {
        say("Hi");
    }
    
    fn say_bye() {
        say("Bye");
    }
    
    fn say(what: &str) {
        println!("{}!",what);
    }
}

fn main() {
    // ERROR: function `say_hi` is private
    things_to_say::say_hi();
}

Module Basics

  • By default, all definitions in the namespace are private.

  • Advantage: Can hide all internally used code and control external interface

  • Use pub to make functions or types public

mod things_to_say {
    pub fn say_hi() {
        say("Hi");
    }
    
    pub fn say_bye() {
        say("Bye");
    }
    
    fn say(what: &str) {
        println!("{}!",what);
    }
}

fn main() {
    things_to_say::say_hi();
    things_to_say::say_bye();

    // ERROR: function `say` is private
    //things_to_say::say("Say what??");
}

Why modules?

  • limit number of additional identifiers in the main namespace

  • organize your codebase into meaningful parts

  • hide auxiliary internal code

  • By default, all definitions in the namespace are private.

  • Advantage: one can hide all internally used code and publish an external interface

  • Ideally you semantically version your external interface. See https://semver.org

  • Use pub to make functions or types public

Nesting possible

mod level_1 {

    mod level_2_1 {

        mod level_3 {

            pub fn where_am_i() {println!("3");}

        }

        pub fn where_am_i() {println!("2_1");}
        
    }
    
    mod level_2_2 {
        
        pub fn where_am_i() {println!("2_2");}
        
    }
    
    pub fn where_am_i() {println!("1");}
    
}

fn main() {
    level_1::level_2_1::level_3::where_am_i();
}

Nesting, continued...

But all parent modules have to be public as well.

mod level_1 {

    pub mod level_2_1 {

        pub mod level_3 {

            pub fn where_am_i() {println!("3");}

        }

        pub fn where_am_i() {println!("2_1");}
        
    }
    
    pub mod level_2_2 {
        
        pub fn where_am_i() {println!("2_2");}
        
    }
    
    pub fn where_am_i() {println!("1");}
    
}

fn main() {
    level_1::level_2_2::where_am_i();
}

Module Hierarchy

level_1
├── level_2_1
│   └── level_3
│       └── where_am_i
│   └── where_am_i
├── level_2_2
│   └── where_am_i
└── where_am_i

Paths to modules

pub mod level_1 {
    pub mod level_2_1 {
        pub mod level_3 {
            pub fn where_am_i() {println!("3");}
            pub fn call_someone_else() {
                where_am_i();
            }
        }
        pub fn where_am_i() {println!("2_1");}
    }
    pub mod level_2_2 {   
        pub fn where_am_i() {println!("2_2");}
    }
    pub fn where_am_i() {println!("1");}
}

fn where_am_i() {println!("main namespace");}


fn main() {
    level_1::level_2_1::level_3::call_someone_else();
}

Question: What will be printed?

Paths to modules

Global paths: start from crate

mod level_1 {
    pub mod level_2_1 {
        pub mod level_3 {
            pub fn where_am_i() {println!("3");}
            pub fn call_someone_else() {
                crate::where_am_i();
                crate::level_1::level_2_2::
                    where_am_i();
                where_am_i();
            }
        }
        pub fn where_am_i() {println!("2_1");}
    }
    pub mod level_2_2 {   
        pub fn where_am_i() {println!("2_2");}
    }
    pub fn where_am_i() {println!("1");}
}

fn where_am_i() {println!("main namespace");}


fn main() {
    level_1::level_2_1::level_3::call_someone_else();
}

Question: What will be printed?

Paths to modules

Local paths:

  • going one or many levels up via super
mod level_1 {
    pub mod level_2_1 {
        pub mod level_3 {
            pub fn where_am_i() {println!("3");}
            
            pub fn call_someone_else() {
                super::where_am_i();
                super::super::where_am_i();
                super::super::
                    level_2_2::where_am_i();
            }
        }
        pub fn where_am_i() {println!("2_1");}
    }
    pub mod level_2_2 {   
        pub fn where_am_i() {println!("2_2");}
    }
    
    pub fn where_am_i() {println!("1");}
}

fn where_am_i() {println!("main namespace");}


fn main() {
    level_1::level_2_1::level_3::call_someone_else();
}

Question: What will be printed?

use to import things into the current scope

mod level_1 {
    pub mod level_2_1 {
        pub mod level_3 {
            pub fn where_am_i() {println!("3");}
            pub fn call_someone_else() {
                super::where_am_i();
            }
            pub fn i_am_here() {println!("I am here");}
        }
        pub fn where_am_i() {println!("2_1");}
    }
    pub mod level_2_2 {   
        pub fn where_am_i() {println!("2_2");}
    }
    pub fn where_am_i() {println!("1");}
}

fn where_am_i() {println!("main namespace");}


fn main() {
// Bring a submodule to current scope:
use level_1::level_2_2;
level_2_2::where_am_i();

// Bring a specific function/type to current scope:
// (Don't do that, it can be confusing).
use level_1::level_2_1::where_am_i;
where_am_i();

// Bring multiple items to current scope:
use level_1::level_2_1::level_3::{call_someone_else, i_am_here};
call_someone_else();
i_am_here();

// ERROR: Name clash! Won't work!
//use level_1::where_am_i;
//where_am_i();
}

Structs within modules

  • You can put structs and methods in modules
  • Fields are private by default
  • Use pub to make fields public
pub mod test {
    #[derive(Debug)]
    pub struct Point {
       x: i32,
       pub y: i32,
    }

    impl Point {
        pub fn create(x:i32,y:i32) -> Point {
            Point{x,y}
        }
        
    }

}


use test::Point;

fn main() {
    let mut p = Point::create(2,3);
    println!("{:?}",p);

    p.x = 3;  // Error: try commenting this out
    p.y = 4;  // Why does this work?
    println!("{:?}",p);
}

Structs within modules

Make fields and functions public to be accessible

mod test {
    #[derive(Debug)]
    pub struct Point {
       pub x: i32,
       y: i32,  // still private
    }

    impl Point {
        pub fn create(x:i32,y:i32) -> Point {
            Point{x,y}
        }

        // public function can access private data
        pub fn update_y(&mut self, y:i32) {
            self.y = y;
        }
    }

}

use test::Point;

fn main() {
let mut p = Point::create(2,3);
println!("{:?}",p);
p.x = 3;
println!("{:?}",p);

p.update_y(2022);  // only way to update y
println!("{:?}",p);

// The create function seemed trivial in the past but the following won't work:
//let mut q = Point{x: 4, y: 5};
}

True/False Statements on Rust Modules

In Rust, all definitions within a module are private by default, and you must use the `pub` keyword to make them accessible outside the module.

TRUE


When accessing a nested module function, only the innermost module and the function need to be declared as `pub` - parent modules can remain private.

FALSE - parent modules must also be public


The `super` keyword is used to navigate up one or more levels in the module hierarchy, while `crate` refers to the root of the current crate for absolute paths.

TRUE - `super` navigates up, `crate` provides global paths


Fields in a struct are public by default, so you need to use the `priv` keyword to make them private within a module.

FALSE - fields are private by default, use `pub` to make them public


Using the `use` statement to bring a submodule into scope is recommended, but bringing individual functions directly into the current scope can be confusing and is discouraged in the lecture.

TRUE - Don't do that, it can be confusing.

Enter your answers into piazza poll.

Recap

  • You can put structs and methods in modules
  • Fields are private by default
  • Use pub to make fields public
  • Use use to import things into the current scope
  • Use mod to create modules
  • Use crate and super to navigate the module hierarchy

Rust Crates and External Dependencies

About This Module

This module introduces Rust's package management system through crates, which are reusable libraries and programs. Students will learn how to find, add, and use external crates in their projects, with hands-on experience using popular crates like rand, csv, and serde. The module covers the distinction between binary and library crates, how to manage dependencies in Cargo.toml, and best practices for working with external code.

Prework

Before this lecture, please read:

Pre-lecture Reflections

  1. What is the difference between a package, crate, and module in Rust?
  2. How does Cargo manage dependencies and versions?
  3. Why might you choose to use an external crate versus implementing functionality yourself?

Learning Objectives

By the end of this lecture, you should be able to:

  • Distinguish between binary and library crates
  • Add external dependencies to your Rust project using Cargo.toml
  • Use popular crates like rand, csv, and serde in your code
  • Understand semantic versioning and dependency management
  • Evaluate external crates for trustworthiness and stability

What are crates?

Crates provided by a project:

  • Binary Crate: Programs you compile to an executable and run.
    • Each must have a main() function that is the program entry point
    • So far we have seen single binaries
  • Library Crate: Define functionality than can be shared with multiple projects.
    • Do not have a main() function
    • A single library crate: can be used by other projects

Shared crates

Where to find crates:

  • Official list: crates.io
  • Unofficial list: lib.rs (including ones not yet promoted to crates.io)

Documentation:

Crate rand: random numbers

See: crates.io/crates/rand

Tell Rust you want to use it:

  • cargo add rand for the latest version
  • cargo add rand --version="0.8.5" for a specific version
  • cargo remove rand to remove it

This adds to Cargo.toml:

[dependencies]
rand = "0.8.5"

Note: Show demo in VS Code.

Question: Why put the version number in Cargo.toml?

To generate a random integer from 1 through 100:

extern crate rand; // only needed in mdbook
use rand::Rng;

fn main() {
  let mut rng = rand::rng();
  let secret_number = rng.random_range(1..=100);
  println!("The secret number is: {secret_number}");
}

Useful Crates

  • csv: reading and writing CSV files
  • serde: serializing and deserializing data
  • serde_json: serializing and deserializing JSON data

See: crates.io/crates/csv See: crates.io/crates/serde See: crates.io/crates/serde_json

Rust Project Organization and Multi-Binary Projects

About This Module

This module covers advanced Rust project organization, focusing on how to structure projects with multiple binaries and libraries. Students will learn about Rust's package system, understand the relationship between packages, crates, and modules, and gain hands-on experience organizing complex projects. The module also discusses best practices for managing external dependencies and the trade-offs involved in using third-party crates.

Prework

Before this lecture, please read:

Pre-lecture Reflections

  1. What are the conventional file locations for binary and library crates in a Rust project?
  2. How does Rust's module system help organize large projects?
  3. What are the security and maintenance implications of depending on external crates?

Learning Objectives

By the end of this lecture, you should be able to:

  • Organize Rust projects with multiple binaries and libraries
  • Understand the Rust module system hierarchy (packages → crates → modules)
  • Configure Cargo.toml for complex project structures
  • Evaluate external dependencies for trustworthiness and stability
  • Apply best practices for project organization and dependency management

Using Multiple Libraries or Binaries in your Project

  • So far, we went from a single source file, to multiple source files organized as Modules.

  • But we built our projects into single binaries with cargo build or cargo run.

  • We can also build multiple binaries.

When we create a new program with cargo new my_program, it creates a folder

.
├── Cargo.toml
└── src
    └── main.rs

And Cargo.toml has:

[package]
name = "my_program"
version = "0.1.0"
edition = "2024"

[dependencies]

Our program is considered a Rust package with the source in src/main.rs that compiles (cargo build) into a single binary at target/debug/my_program.

The Rust Module System

  • Packages: Cargo's way of organizing, building, testing, and sharing crates
    • It's a bundle of one or more crates.
  • Crates: A tree of modules that produces a library or executable
  • Modules and use: Let you control the organization, scope, and privacy of paths
  • Paths: A way of naming an item, such as a struct, function, or module, e.g. my_library::library1::my_function

A package can contain as many binary crates as you want, but only one library crate.

By default src/main.rs is the crate root of a binary crate with the same name as the package (e.g. my_program).

Also by default, src/lib.rs would contain a library crate with the same name as the package and src/lib.rs is its crate root.

How to add multiple binaries to your project

[[bin]]  
name = "some_name"  
path = "some_directory/some_file.rs"  

The file some_file.rs must contain a fn main()

How to add a library to your project

[lib]  
name = "some_name"  
path = "src/lib/lib.rs"  

The file lib.rs does not need to contain a fn main()

You can have as many binaries are you want in a project but only one library!

Example: simple_package

Create a new project with cargo new simple_package.

Copy the code below so your has the same structure and contents.

  • Try cargo run.
  • Since there are two binaries, you can try cargo run --bin first_bin or cargo run --bin second_bin.
.
├── Cargo.lock
├── Cargo.toml
└── src
    ├── bin
    │   └── other.rs
    ├── lib
    │   ├── bar.rs
    │   ├── foo.rs
    │   └── lib.rs
    └── main.rs

Cargo.toml:

[package]
name = "simple_library2"
version = "0.1.0"
edition = "2021"

# the double brackets indicate this is part of an array of bins
[[bin]]
name = "first_bin"
path = "src/main.rs"

[[bin]]
name = "second_bin"
path = "src/bin/other.rs"

# single bracket means we can have only one lib
[lib]
name = "librs"
path = "src/lib/lib.rs"

[dependencies]

src/bin/other.rs:

use librs;

fn main() {
   println!("This is other.rs using a library");
   librs::bar::say_something();
}

src/lib/bar.rs:

use crate::foo;

pub fn say_something() {
  println!("Bar");
  foo::say_something();
}

src/lib/foo.rs:

pub fn say_something() {
   println!("Foo");
}

src/lib/lib.rs:

/*
 * This will make the compiler look for either:
 * 1. src/lib/bar.rs or 
 * 2. src/lib/bar/mod.rs
 */
pub mod bar;

/*
 * This will make the compiler look for either:
 * 1. src/lib/foo.rs or
 * 2. src/lib/foo/mod.rs
 */
pub mod foo;

src/main.rs:

use librs;

fn main() {
   println!("This is main.rs using a library");
   librs::bar::say_something();
}

Relying on external projects

Things to consider about external libraries:

  • trustworthy?
  • stable?
  • long–term survival?
  • do you really need it?

Many things best left to professionals:

Never implement your own cryptography!

Implementing your own things can be a great educational experience!

Extreme example

Yanking a published module version: article about left-pad

article about left-pad

Rust and cargo: can't delete libraries that were published.

Testing in Rust: Ensuring Code Quality

About This Module

This short module introduces testing in Rust, covering how to write effective unit tests, integration tests, and use Rust's built-in testing framework. You'll learn testing best practices and understand why comprehensive testing is crucial for reliable software development.

Prework

Prework Reading

Please read the following sections from The Rust Programming Language Book:

  • Chapter 11: Writing Automated Tests
  • Chapter 11.1: How to Write Tests
  • Chapter 11.2: Controlling How Tests Are Run
  • Chapter 11.3: Test Organization

Pre-lecture Reflections

  1. Why is testing important in software development, especially in systems programming?
  2. How does Rust's testing framework compare to testing frameworks you've used in other languages?
  3. What is the difference between unit tests, integration tests, and documentation tests?
  4. What makes a good test case?

Learning Objectives

By the end of this module, you will be able to:

  • Write unit tests using Rust's testing framework
  • Use assertions effectively in tests
  • Organize and run test suites
  • Understand testing best practices and test-driven development

Tests

  • Why are tests useful?
  • What is typical test to functional code ratio?

730K lines of code in Meta proxy server, roughly 1:1 ratio of tests to actual code. https://github.com/facebook/proxygen

Creating a Library Crate

You can use cargo to create a library project:

$ cargo new adder --lib
     Created library `adder` project
$ cd adder

This will create a new project in the adder directory with the following structure:

.
├── Cargo.lock
├── Cargo.toml
└── src
    └── lib.rs

Library Crate Code

Similar to the "Hello, world!" binary crate, the library crate is prepopulated with some minimal code.

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}
  • The #[cfg(test)] attribute tells Rust to compile and run the tests only when you run cargo test.

  • The use super::*; line tells Rust to bring all the items defined in the outer scope into the scope of the tests module.

  • The #[test] attribute tells Rust that the function is a test function.

  • The assert_eq!(result, 4); line tells Rust to check that the result of the add function is equal to 4.

    • assert! is a macro that takes a boolean expression and panics if the expression is false.
    • there are many other assert! macros, including assert_ne!, assert_approx_eq!, etc.

Running the Tests

You can run the tests with the cargo test command.

% cargo test
   Compiling adder v0.1.0 (...path_to_adder/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.50s
     Running unittests src/lib.rs (target/debug/deps/adder-1dfa21403f25b3c4)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
  • 0 ignored means no tests were ignored with the #[ignore] attribute.
  • 0 measured means no tests were measured with Rust's built-in benchmarking framework.
  • 0 filtered out means no subset of tests were specified
  • Doc-tests automatically test any example code that is provided in /// comments.

Example Unit Test Code

Here is an example of a set of tests for a function that doubles the elements of a vector.

fn doubleme(inp: &Vec<f64>) -> Vec<f64> {
    let mut nv = inp.clone();
    for (i, x) in inp.iter().enumerate() {
        nv[i] = *x * 2.0;
    }
    nv
}

#[test]
fn test_doubleme_positive() {
    let v = vec![1.0, 2.0, 3.0];
    let w = doubleme(&v);
    for (x, y) in v.iter().zip(w.iter()) {
        assert_eq!(*y, 2.0 * *x, "Element is not double");
    }
}
#[test]
fn test_doubleme_negative() {
    let v = vec![-1.0, -2.0, -3.0];
    let w = doubleme(&v);
    for (x, y) in v.iter().zip(w.iter()) {
        assert_eq!(*y, 2.0 * *x, "Negative element is not double");
    }
}
#[test]
fn test_doubleme_zero() {
    let v = vec![0.0];
    let w = doubleme(&v);
    for (x, y) in v.iter().zip(w.iter()) {
        assert_eq!(*y, 2.0 * *x, "Zero element is not double");
    }
}
#[test]
fn test_doubleme_empty() {
    let v: Vec<f64> = vec![];
    let w = doubleme(&v);
    assert_eq!(w.len(), 0, "Empty Vector is not empty");
}

fn testme() {
    let v: Vec<f64> = vec![2.0, 3.0, 4.0];
    let w = doubleme(&v);
    println!("V = {:?} W = {:?}", v, w);
}

fn main() {
    testme();
}

Further Reading

Read 11.1 How to Write Tests for more information.

In-Class Activity

In this activity, you will write tests for a function that finds the second largest element in a slice of integers.

Be creative with your tests! With the right tests, you will be able to find the bug in the function.

Fix the bug in the function so all tests pass.

Part 1: Create a New Library Project

Create a new Rust library project:

cargo new --lib testing_practice
cd testing_practice

Part 2: Implement and Test

Replace the contents of src/lib.rs with the following function:

/// Returns the second largest element in a slice of integers.
/// Returns None if there are fewer than 2 distinct elements.
///
/// # Examples
/// ```
/// use testing_practice::second_largest;
/// assert_eq!(second_largest(&[1, 2, 3]), Some(2));
/// assert_eq!(second_largest(&[5, 5, 5]), None);
/// ```
pub fn second_largest(numbers: &[i32]) -> Option<i32> {
    if numbers.len() < 2 {
        return None;
    }
    
    let mut largest = numbers[0];
    let mut second = numbers[1];
    
    if second > largest {
        std::mem::swap(&mut largest, &mut second);
    }
    
    for &num in &numbers[2..] {
        if num > largest {
            second = largest;
            largest = num;
        } else if num > second {
            second = num;
        }
    }
    
    if largest == second {
        None
    } else {
        Some(second)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_all_same() {
        let result = second_largest(&[1, 1, 1]);
        assert_eq!(result, None);
    }
}

Part 3: Write Tests

Your task is to write at least 3-4 comprehensive tests for this function. Think about:

  • Normal cases
  • Edge cases (empty, single element, etc.)
  • Special cases (all same values, duplicates of largest, etc.)

Add your tests in a #[cfg(test)] module below the function.

Part 4: Debug

Run cargo test. If any of your tests fail, there is a bug in the function. Your goal is to:

  1. Identify what test case reveals the bug
  2. Understand why the function fails
  3. Fix the function so all tests pass

Hint: Think carefully about what happens when the largest element appears multiple times in the array.

Part 5: Submit

Submit your code to Gradescope.

Generics: Avoiding Code Duplication for Different Types

About This Module

This module introduces Rust's powerful generics system, which allows writing flexible, reusable code that works with multiple types while maintaining type safety and performance. You'll learn how to create generic functions, structs, and methods, as well as understand key built-in generic types like Option<T> and Result<T, E>.

Prework

Prework Reading

Please read the following sections from The Rust Programming Language Book:

  • Chapter 10.1: Generic Data Types
  • Chapter 10.2: Traits (for understanding trait bounds)
  • Chapter 6.1: Defining an Enum (for Option review)
  • Chapter 9.2: Recoverable Errors with Result (for Result<T, E> review)

Pre-lecture Reflections

  1. How do generics in Rust compare to similar features in languages you know (templates in C++, generics in Java)?
  2. What are the performance implications of Rust's monomorphization approach?
  3. Why might Option<T> be safer than null values in other languages?
  4. When would you choose Result<T, E> over Option<T>?

Learning Objectives

By the end of this module, you will be able to:

  • Write generic functions and structs using type parameters
  • Apply trait bounds to constrain generic types
  • Use Option<T> and Result<T, E> for safe error handling
  • Understand monomorphization and its performance benefits

How python handles argument types

Python is dynamically typed and quite flexible in this regard. We can pass many different types to a function.

def max(x,y):
    return x if x > y else y
>>> max(3,2)
3
>>> max(3.1,2.2)

3.1
>>> max('s', 't')
't'
Very flexible! Any downsides?
  • Requires inferring types each time function is called
  • Incurs runtime penalty
  • No compile-time guarantees about type safety
>>> max('s',5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in max
TypeError: '>' not supported between instances of 'str' and 'int'

Rust without generics

Rust is strongly typed, so we would have to create a version of the function for each type.

fn max_i32(x:i32,y:i32) -> i32 {
    if x > y {x} else {y}
}

fn max_f64(x:f64,y:f64) -> f64 {
    if x > y {x} else {y}
}

fn max_char(x:char,y:char) -> char {
    if x > y {x} else {y}
}

fn main() {
    println!("{}", max_i32(3,8));
    println!("{}", max_f64(3.3,8.1));
    println!("{}", max_char('a','b'));
}

Rust Generics

Generics allow us to write one version of a function and then have the compiler generate versions for different types.

The process of going from one to the other is monomorphization.

  GENERIC SOURCE                 COMPILER OUTPUT (roughly)
┌─────────────────┐            ┌─────────────────────┐
│ fn pass<T>(x:T) │  ────────► │ fn pass_i32(x:i32)  │
│ { ... }         │            │ fn pass_f64(x:f64)  │
│                 │            │ fn pass_char(x:char)│
└─────────────────┘            └─────────────────────┘
     One source                 Multiple functions

Rust Generics: Syntax

Use the <T> syntax to indicate that the function is generic.

The T is a placeholder for the type and could be any character.

fn passit<T>(x:T) -> T {
    x
}

fn main() {
let x = passit(5);
println!("x is {x}");

let x = passit(1.1);
println!("x is {x}");

let x = passit('s');
println!("x is {x}");
}

Watch Out!

Let's try this:

// ERROR -- this doesn't work
fn show<T>(x:T,y:T){
    println!("x is {x} and y is {y}");
}

fn main() {
    show(3,5);
    show(1.1, 2.1);
    show('s', 't');
}

The Rust compiler is thorough enough to recognize that not all generic type may have the behavior we want.

The Fix: Trait Bounds

We can place restrictions on the generic types we would support.

fn show<T: std::fmt::Display>(x:T,y:T){
    println!("x is {x} and y is {y}");
}

fn main() {
    show(3,5);
    show(1.1, 2.1);
    show('s', 't');
    show( "hello", "world");
    show( true, false);
    //show( vec![1,2,3], vec![4,5,6]); // doesn't work
}

We'll talk about traits in the next module.

Another Watch Out!

// ERROR -- similarly we could try this, but it doesn't work
fn max<T>(x:T,y:T) -> T {
        if x > y {x} else {y}
}

fn main() {
    println!("{}", max(3,8));
    println!("{}", max(3.3,8.1));
    println!("{}", max('a','b'));
}

Not all types support the > operator.

The Fix: Trait Bounds

We can further restrict the type of T to only allow types that implement the PartialOrd trait.

// add info that elements of T are comparable
fn max<T:PartialOrd>(x:T,y:T) -> T {
        if x > y {x} else {y}
}

fn main() {
    println!("{}",max(3,8));
    println!("{}",max(3.3,8.1));
    println!("{}",max('a','b'));
}

Generics / Generic data types

In other programming languages:

  • C++: templates
  • Java: generics
  • Go: generics
  • ML, Haskell: parametric polymorphism

Generic Structs

We can define a struct that is generic.

#[derive(Debug)]
struct Point<T> {
    x: T,
    y: T,
}

fn main() {
let point_int = Point {x: 2, y: 3};
println!("{:?}", point_int);

let point_float = Point {x: 4.2, y: 3.0};
println!("{:?}", point_float);
}

Struct contructor method

We can define methods in the context of Structs that support generic data types

impl<T> Point<T> means that this is an implementation block and all the methods are implemented for any type T that Point might be instantiated with.

#[derive(Debug)]
struct Point<T> {
    x: T,
    y: T,
}

// define a constructor method for the Point struct
impl<T> Point<T> {
    fn create(x:T,y:T) -> Point<T> {
        Point{x,y}
    }
}

fn main() {
    // create instances of the Point struct using the constructor method
    let point = Point::create(1, 2);
    let point2 = Point::<char>::create('c','d');
    let point3 : Point<char> = Point::create('e','f');
    println!("{:?} {:?} {:?}", point, point2, point3);
}

Struct swap method

Let's implement another method that operates on an instance of the struct, hence the use of &mut self.

Remember, &mut self means that the method is allowed to modify the instance of the struct.

#[derive(Debug)]
struct Point<T> {
    x: T,
    y: T,
}

// define a constructor method for the Point struct
impl<T> Point<T> {
    fn create(x:T,y:T) -> Point<T> {
        Point{x,y}
    }
}

// implement a method that swaps the x and y values
impl<T:Copy> Point<T> {
    fn swap(&mut self) {
        let z = self.x;
        self.x = self.y;
        self.y = z;
    }
}

fn main() {
    let mut point = Point::create(2,3);
    println!("{:?}",point);
    point.swap();
    println!("{:?}",point);
}

impl<T:Copy> specifies that T must implement the Copy trait.

You can see what happens if we remove the Copy trait.

Question: What datatype might not implement the Copy trait?

Specialized versions

Even though we have generic functions defined, we can still specify methods/functions for specific types.

#[derive(Debug)]
struct Point<T> {
    x: T,
    y: T,
}

// define a constructor method for the Point struct
impl<T> Point<T> {
    fn create(x:T,y:T) -> Point<T> {
        Point{x,y}
    }
}

impl Point<i32> {
    fn do_you_use_f64(&self) -> bool {
        false
    }
}

impl Point<f64> {
    fn do_you_use_f64(&self) -> bool {
        true
    }
}

fn main() {
    let p_i32 = Point::create(2,3);
    println!("p_i32 uses f64? {}",p_i32.do_you_use_f64());

    let p_f64 = Point::create(2.1,3.1);
    println!("p_f64 uses f64? {}",p_f64.do_you_use_f64());
}

Useful predefined generic data types

There are two useful predefined generic data types: Option<T> and Result<T, E>.

Enum Option<T>

There is a built-in enum Option<T> in the standard library with two variants:

  • Some(T) -- The variant Some contains a value of type T
  • None

Useful for when there may be no output

  • Compared to None or null in other programming languages:
    • Rust forces handling of this case

From Option enum advantage over null:

The Option type encodes the very common scenario in which a value could be something or it could be nothing.

For example, if you request the first item in a non-empty list, you would get a value. If you request the first item in an empty list, you would get nothing.

Expressing this concept in terms of the type system means the compiler can check whether you’ve handled all the cases you should be handling;

This functionality can prevent bugs that are extremely common in other programming languages.

Null

Example: Prime Number Finding

Here's example prime number finding code that returns Option<u32> if a prime number is found, or None if not.

fn prime(x:u32) -> bool {
    if x <= 1 { return false;}

    // factors would come in pairs. if one factor is > sqrt(x), then
    // the other factor must be < sqrt(x).
    // So we only have to search up to sqrt(x)
    for i in 2..=((x as f64).sqrt() as u32) {
        if x % i == 0 { // can be divided by i without a remainder -> not prime
            return false;
        }
    } 
    true
}

fn prime_in_range(a:u32,b:u32) -> Option<u32> {  // returns an Option<u32>
    for i in a..=b {
        if prime(i) {return Some(i);}
    }
    None
}

fn main() {
    println!("prime in 90-906? {:?}",prime_in_range(90,906));

    println!("prime in 90-92? {:?}",prime_in_range(90,92));

    let tmp : Option<u32> = prime_in_range(830,856);
    println!("prime in 830-856? {:?}",tmp);
}
  • If a prime number is found, it returns Some(u32) variant with the prime number.
  • If the prime number is not found, it returns None.

Extracting the content of Some(...)

There are various ways to extract the content of Some(...)

  • if let
  • match
  • unwrap()
fn prime(x:u32) -> bool {
    if x <= 1 { return false;}

    // factors would come in pairs. if one factor is > sqrt(x), then
    // the other factor must be < sqrt(x).
    // So we only have to search up to sqrt(x)
    for i in 2..=((x as f64).sqrt() as u32) {
        if x % i == 0 { // can be divided by i without a remainder -> not prime
            return false;
        }
    } 
    true
}

fn prime_in_range(a:u32,b:u32) -> Option<u32> {  // returns an Option<u32>
    for i in a..=b {
        if prime(i) {return Some(i);}
    }
    None
}

fn main() {
    let tmp : Option<u32> = prime_in_range(830,856);

    // extracting the content of Some(...)
    if let Some(x) = tmp {
        println!("{}",x);
    }

    match tmp {
        Some(x) => println!("{}",x),
        None => println!("None"),
    };

    println!("Another way {}", tmp.unwrap())
}

Be careful with unwrap()

Be careful with unwrap(), it will crash the program if the value is None.

//ERROR
fn main() {
    // extracting the content of Some(...)
    let tmp: Option<u32> = None;  // try changing this to Some(3)

    if let Some(x) = tmp {
        println!("{}",x);   // will skip this block if tmp is None
    }
    match tmp {
        Some(x) => println!("{}",x),
        None => println!("{:?}", tmp),
    };

    // Boom!!!!! Will crash the program if tmp is None
    println!("Another way {}", tmp.unwrap())
}

There is always a prime number in . See Prime Number Theorem

Enum Option<T>: useful methods

Check the variant

  • .is_some() -> bool
  • .is_none() -> bool

Get the value in Some or terminate with an error

  • .unwrap() -> T
  • .expect(message) -> T

Get the value in Some or a default value

  • .unwrap_or(default_value:T) -> T
#![allow(unused)]
fn main() {
let x = Some(3);
println!("x is some? {}",x.is_some());
}

If exception, print a message.

#![allow(unused)]
fn main() {
// Try line 3 instead of 4

//let x:Option<u32> = Some(3);
let x = None;
let y:u32 = x.expect("This should have been an integer!!!");
println!("y is {}",y);
}

A better way to handle this is to use unwrap_or().

#![allow(unused)]
fn main() {
let x = None;
println!("{}",x.unwrap_or(0));

let y = Some(3);
println!("{}",y.unwrap_or(0));

}

More details:

  • https://doc.rust-lang.org/std/option/
  • https://doc.rust-lang.org/std/option/enum.Option.html

Enum Result<T, E>

Another built-in enum Result<T, E> in the standard library with two variants:

  • Ok(T)
  • Err(E)

Useful when you want to pass a solution or information about an error.

fn divide(a:u32,b:u32) -> Result<u32,String> {
    match b {
        0 => Err(String::from("Division by zero")),
        _ => Ok(a / b)
    }
}

fn main() {
    println!("{:?}",divide(3,0));
    println!("{:?}",divide(2022,3));
}

Enum Result<T, E>: useful methods

Check the variant

  • .is_ok() -> bool
  • .is_err() -> bool

Get the value in Ok or terminate with an error

  • .unwrap() -> T
  • .expect(message) -> T

Get the value in Ok or a default value

  • .unwrap_or(default_value:T) -> T
#![allow(unused)]
fn main() {
let r1 : Result<i32,()> = Ok(3);
println!("{}",r1.is_err());
println!("{}",r1.is_ok());
println!("{}",r1.unwrap());
}

But again, that will crash the program if the value is Err, so use unwrap_or().

#![allow(unused)]
fn main() {
let r2 : Result<u32,()> = Err(());
let r3 : Result<u32,()> = Ok(123);
println!("r2: {}\nr3: {}",
    r2.unwrap_or(0),
    r3.unwrap_or(0));

}

More details:

  • https://doc.rust-lang.org/std/result/
  • https://doc.rust-lang.org/std/result/enum.Result.html

In-Class Poll

Will be opened and made visible in class.

In-Class Activity: Practicing Generics

Time: 10 minutes

Instructions

Work individually or in pairs. Complete as many exercises as you can in 10 minutes. You can test your code in the Rust playground or in your local environment.

Exercise 1: Fix the Generic Function (3 minutes)

The following code doesn't compile. Fix it by adding the appropriate trait bound(s).

// TODO: Fix this function so it compiles
fn compare_and_print<T>(a: T, b: T) {
    if a > b {
        println!("{} is greater than {}", a, b);
    } else {
        println!("{} is less than or equal to {}", a, b);
    }
}

fn main() {
    compare_and_print(10, 5);
    compare_and_print(2.71, 3.14);
    compare_and_print('z', 'a');
}
Hint

You need TWO trait bounds:

  • One to enable comparison (>)
  • One to enable printing with {}

Exercise 2: Complete the Generic Struct (4 minutes)

Complete the Container<T> struct by implementing the missing methods.

#[derive(Debug)]
struct Container<T> {
    value: T,
}

impl<T> Container<T> {
    // TODO: Implement a constructor that creates a new Container
    fn new(value: T) -> Container<T> {
        // Your code here
    }
    
    // TODO: Implement a method that returns a reference to the value
    fn get(&self) -> &T {
        // Your code here
    }
    
    // TODO: Implement a method that replaces the value and returns the old one
    fn replace(&mut self, new_value: T) -> T {
        // Your code here
    }
}

fn main() {
    let mut container = Container::new(42);
    println!("Value: {:?}", container.get());
    
    let old_value = container.replace(100);
    println!("Old value: {}, New value: {:?}", old_value, container.get());
}
Hint for replace()

Use std::mem::replace(&mut self.value, new_value) or swap manually using a temporary variable.

Exercise 3: Use Option (3 minutes)

Implement a function that finds the first even number in a vector. Return Some(number) if found, or None if no even numbers exist.

// TODO: Implement this function
fn find_first_even(numbers: &Vec<i32>) -> Option<i32> {
    // Your code here
}

fn main() {
    let numbers1 = vec![1, 3, 5, 7];
    let numbers2 = vec![1, 3, 6, 7];
    
    match find_first_even(&numbers1) {
        Some(n) => println!("Found even number: {}", n),
        None => println!("No even numbers found"),
    }
    
    // TODO: Use unwrap_or() to print the result with a default value of -1
    println!("First even in numbers2: {}", /* your code here */);
}

Bonus Challenge (if you finish early)

Combine everything you learned! Create a generic Pair<T, U> struct that can hold two values of different types, and implement a method swap() that returns a new Pair<U, T> with the values swapped.

// TODO: Define the struct and implement the method
struct Pair<T, U> {
    // Your code here
}

impl<T, U> Pair<T, U> {
    fn new(first: T, second: U) -> Self {
        // Your code here
    }
    
    fn swap(self) -> Pair<U, T> {
        // Your code here
    }
}

fn main() {
    let pair = Pair::new(42, "hello");
    let swapped = pair.swap();
    // This should compile and show that types are swapped!
}

Solutions

Click to reveal solutions (try on your own first!)

Exercise 1 Solution

fn compare_and_print<T: PartialOrd + std::fmt::Display>(a: T, b: T) {
    if a > b {
        println!("{} is greater than {}", a, b);
    } else {
        println!("{} is less than or equal to {}", a, b);
    }
}

fn main() {
    compare_and_print(10, 5);
    compare_and_print(2.71, 3.14);
    compare_and_print('z', 'a');
}

Exercise 2 Solution

#[derive(Debug)]
struct Container<T> {
    value: T,
}

impl<T> Container<T> {
    fn new(value: T) -> Container<T> {
        Container { value }
    }
    
    fn get(&self) -> &T {
        &self.value
    }
    
    fn replace(&mut self, new_value: T) -> T {
        std::mem::replace(&mut self.value, new_value)
    }
}

fn main() {
    let mut container = Container::new(42);
    println!("Value: {:?}", container.get());
    
    let old_value = container.replace(100);
    println!("Old value: {}, New value: {:?}", old_value, container.get());
}

Exercise 3 Solution

fn find_first_even(numbers: &Vec<i32>) -> Option<i32> {
    for &num in numbers {
        if num % 2 == 0 {
            return Some(num);
        }
    }
    None
}

fn main() {
    let numbers1 = vec![1, 3, 5, 7];
    let numbers2 = vec![1, 3, 6, 7];
    
    match find_first_even(&numbers1) {
        Some(n) => println!("Found even number: {}", n),
        None => println!("No even numbers found"),
    }
    
    println!("First even in numbers2: {}", find_first_even(&numbers2).unwrap_or(-1));
}

Bonus Solution

#[derive(Debug)]
struct Pair<T, U> {
    first: T,
    second: U,
}

impl<T, U> Pair<T, U> {
    fn new(first: T, second: U) -> Self {
        Pair { first, second }
    }
    
    fn swap(self) -> Pair<U, T> {
        Pair {
            first: self.second,
            second: self.first,
        }
    }
}

fn main() {
    let pair = Pair::new(42, "hello");
    let swapped = pair.swap();
    // This should compile and show that types are swapped!
}

Traits: Defining Shared Behavior

About This Module

This module introduces Rust's trait system, which allows you to define shared behavior that can be implemented by different types. Traits are similar to interfaces in other languages but more powerful, enabling polymorphism, generic programming, and code reuse while maintaining Rust's safety guarantees.

Prework

Prework Reading

Please read the following sections from The Rust Programming Language Book:

  • Chapter 10.2: Traits: Defining Shared Behavior
  • Chapter 17.2: Using Trait Objects That Allow for Values of Different Types
  • Chapter 19.3: Advanced Traits

Pre-lecture Reflections

  1. How do traits in Rust compare to interfaces in Java or abstract base classes in Python?
  2. What are the benefits of default method implementations in traits?
  3. When would you use impl Trait vs generic type parameters with trait bounds?
  4. How do trait objects enable dynamic polymorphism in Rust?

Learning Objectives

By the end of this module, you will be able to:

  • Define and implement traits for custom types
  • Use trait bounds to constrain generic functions
  • Understand different syntaxes for trait parameters (impl Trait, generic bounds, where clauses)
  • Return types that implement traits

Traits

From Traits: Defining Shared Behavior.

  • A trait defines the functionality a particular type has and can share with other types.
  • We can use traits to define shared behavior in an abstract way.
  • We can use trait bounds to specify that a generic type can be any type that has certain behavior.

Some other programming languages call this an interface.

Sample trait definition

The general idea is:

  • define method signatures as behaviors that need to be implemented by any type that implements the trait

  • We can also define default implementations of methods.

#![allow(unused)]
fn main() {
trait Person {
    // method header specifications
    // must be implemented by any type that implements the trait
    fn get_name(&self) -> String;
    fn get_age(&self) -> u32;
    
    // default implementation of a method 
    fn description(&self) -> String {
        format!("{} ({})",self.get_name(),self.get_age())
    }
}
}

Sample trait implementation 1

Let's look at a simple example of a trait implementation.

trait Person {
    // method header specifications
    // must be implemented by any type that implements the trait
    fn get_name(&self) -> String;
    fn get_age(&self) -> u32;
    
    // default implementation of a method 
    fn description(&self) -> String {
        format!("{} ({})",self.get_name(),self.get_age())
    }
}

#[derive(Debug)]
struct SoccerPlayer {
    name: String,
    age: u32,
    team: String,
}

// Implement the `Person` trait for `SoccerPlayer` so that 
// it can be used as a `Person` object.
impl Person for SoccerPlayer {
    fn get_age(&self) -> u32 {
        self.age
    }
    
    // We must implement all trait items
    fn get_name(&self) -> String {
        self.name.clone()
    }
}

// Implement a constructor for `SoccerPlayer`
impl SoccerPlayer {
    fn create(name:String, age:u32, team:String) -> SoccerPlayer {
        SoccerPlayer{name,age,team}
    }
}

// Since `SoccerPlayer` implements the `Person` trait, 
// we can use the `description` method on instances of `SoccerPlayer`.

fn main() {
    let zlatan = SoccerPlayer::create(String::from("Zlatan Ibrahimovic"), 40, String::from("AC Milan"));
    println!("{}", zlatan.description());
}

Sample trait implementation 2

Now let's look at another example of a trait implementation.

trait Person {
    // method header specifications
    // must be implemented by any type that implements the trait
    fn get_name(&self) -> String;
    fn get_age(&self) -> u32;
    
    // default implementation of a method 
    fn description(&self) -> String {
        format!("{} ({})",self.get_name(),self.get_age())
    }
}

#[derive(Debug)]
struct RegularPerson {
    year_born: u32,
    first_name: String,
    middle_name: String,
    last_name: String,
}

impl Person for RegularPerson {
    fn get_age(&self) -> u32 {
        2024 - self.year_born
    }
    
    fn get_name(&self) -> String {
        if self.middle_name == "" {
            format!("{} {}",self.first_name,self.last_name)
        } else {
            format!("{} {} {}",self.first_name,self.middle_name,self.last_name)
        }
    }
}

impl RegularPerson {
    fn create(first_name:String,middle_name:String,last_name:String,year_born:u32) -> RegularPerson {
        RegularPerson{first_name,middle_name,last_name,year_born}
    }
}

fn main() {
    let mlk = RegularPerson::create(
        String::from("Martin"),
        String::from("Luther"),
        String::from("King"),
        1929
    );
    println!("{}", mlk.description());
}

Using traits in functions -- Trait Bounds

So now, we specify that we need a function that accepts an object that implements the Person trait.

#![allow(unused)]
fn main() {
// sample function accepting object implementing trait
fn long_description(person: &impl Person) {
    println!("{}, who is {} years old", person.get_name(), person.get_age());
}
}

This way we know we can call the get_name and get_age methods on the object that is passed to the function.

It allows us to specify a whole class of objects and know what methods are available on them.

Examples

We can see this in action with the two examples we saw earlier.

trait Person {
    // method header specifications
    // must be implemented by any type that implements the trait
    fn get_name(&self) -> String;
    fn get_age(&self) -> u32;
    
    // default implementation of a method 
    fn description(&self) -> String {
        format!("{} ({})",self.get_name(),self.get_age())
    }
}

#[derive(Debug)]
struct SoccerPlayer {
    name: String,
    age: u32,
    team: String,
}

// Implement the `Person` trait for `SoccerPlayer` so that 
// it can be used as a `Person` object.
impl Person for SoccerPlayer {
    fn get_age(&self) -> u32 {
        self.age
    }
    
    // We must implement all trait items
    fn get_name(&self) -> String {
        self.name.clone()
    }
}

// Implement a constructor for `SoccerPlayer`
impl SoccerPlayer {
    fn create(name:String, age:u32, team:String) -> SoccerPlayer {
        SoccerPlayer{name,age,team}
    }
}

#[derive(Debug)]
struct RegularPerson {
    year_born: u32,
    first_name: String,
    middle_name: String,
    last_name: String,
}

impl Person for RegularPerson {
    fn get_age(&self) -> u32 {
        2024 - self.year_born
    }
    
    fn get_name(&self) -> String {
        if self.middle_name == "" {
            format!("{} {}",self.first_name,self.last_name)
        } else {
            format!("{} {} {}",self.first_name,self.middle_name,self.last_name)
        }
    }
}

impl RegularPerson {
    fn create(first_name:String,middle_name:String,last_name:String,year_born:u32) -> RegularPerson {
        RegularPerson{first_name,middle_name,last_name,year_born}
    }
}

// sample function accepting object implementing trait
fn long_description(person: &impl Person) {
    println!("{}, who is {} years old", person.get_name(), person.get_age());
}

fn main() {
    let mlk = RegularPerson::create(
        String::from("Martin"),
        String::from("Luther"),
        String::from("King"),
        1929
    );
    let zlatan = SoccerPlayer::create(String::from("Zlatan Ibrahimovic"), 40, String::from("AC Milan"));

    long_description(&mlk); // we can pass a `RegularPerson` object to the function
    long_description(&zlatan); // we can pass a `SoccerPlayer` object to the function
}

Using traits in functions: long vs. short form

There's a longer, generic version of the function that we can use.

trait Person {
    // method header specifications
    // must be implemented by any type that implements the trait
    fn get_name(&self) -> String;
    fn get_age(&self) -> u32;
    
    // default implementation of a method 
    fn description(&self) -> String {
        format!("{} ({})",self.get_name(),self.get_age())
    }
}

#[derive(Debug)]
struct SoccerPlayer {
    name: String,
    age: u32,
    team: String,
}

// Implement the `Person` trait for `SoccerPlayer` so that 
// it can be used as a `Person` object.
impl Person for SoccerPlayer {
    fn get_age(&self) -> u32 {
        self.age
    }
    
    // We must implement all trait items
    fn get_name(&self) -> String {
        self.name.clone()
    }
}

// Implement a constructor for `SoccerPlayer`
impl SoccerPlayer {
    fn create(name:String, age:u32, team:String) -> SoccerPlayer {
        SoccerPlayer{name,age,team}
    }
}

#[derive(Debug)]
struct RegularPerson {
    year_born: u32,
    first_name: String,
    middle_name: String,
    last_name: String,
}

impl Person for RegularPerson {
    fn get_age(&self) -> u32 {
        2024 - self.year_born
    }
    
    fn get_name(&self) -> String {
        if self.middle_name == "" {
            format!("{} {}",self.first_name,self.last_name)
        } else {
            format!("{} {} {}",self.first_name,self.middle_name,self.last_name)
        }
    }
}

impl RegularPerson {
    fn create(first_name:String,middle_name:String,last_name:String,year_born:u32) -> RegularPerson {
        RegularPerson{first_name,middle_name,last_name,year_born}
    }
}

// short version
fn long_description(person: &impl Person) {
    println!("{}, who is {} old", person.get_name(), person.get_age());
}

// longer version
fn long_description_2<T: Person>(person: &T) {
    println!("{}, who is {} old", person.get_name(), person.get_age());
}

fn main() {
    let mlk = RegularPerson::create(
        String::from("Martin"),
        String::from("Luther"),
        String::from("King"),
        1929
    );
    let zlatan = SoccerPlayer::create(String::from("Zlatan Ibrahimovic"), 40, String::from("AC Milan"));

    long_description(&zlatan);
    long_description_2(&zlatan);

    long_description(&mlk);
    long_description_2(&mlk);
}

So what's up with the different ways to specify traits (It's complicated!!!!)

Optional: You can skip this if you want.

  • &impl and &T -> static dispatch (also relevant in the context of return values)
  • &T restricts the type especially if you plan to pass multiple arguments of the same type (relevant to inputs)
  • Read https://joshleeb.com/posts/rust-traits-and-trait-objects if you want to dig deep but without a background in programming languages and compilers this will not be possible to understand.

Using traits in functions: multiple traits

trait Person {
    // method header specifications
    // must be implemented by any type that implements the trait
    fn get_name(&self) -> String;
    fn get_age(&self) -> u32;
    
    // default implementation of a method 
    fn description(&self) -> String {
        format!("{} ({})",self.get_name(),self.get_age())
    }
}

#[derive(Debug)]
struct SoccerPlayer {
    name: String,
    age: u32,
    team: String,
}

// Implement the `Person` trait for `SoccerPlayer` so that 
// it can be used as a `Person` object.
impl Person for SoccerPlayer {
    fn get_age(&self) -> u32 {
        self.age
    }
    
    // We must implement all trait items
    fn get_name(&self) -> String {
        self.name.clone()
    }
}

// Implement a constructor for `SoccerPlayer`
impl SoccerPlayer {
    fn create(name:String, age:u32, team:String) -> SoccerPlayer {
        SoccerPlayer{name,age,team}
    }
}

#[derive(Debug)]
struct RegularPerson {
    year_born: u32,
    first_name: String,
    middle_name: String,
    last_name: String,
}

impl Person for RegularPerson {
    fn get_age(&self) -> u32 {
        2024 - self.year_born
    }
    
    fn get_name(&self) -> String {
        if self.middle_name == "" {
            format!("{} {}",self.first_name,self.last_name)
        } else {
            format!("{} {} {}",self.first_name,self.middle_name,self.last_name)
        }
    }
}

impl RegularPerson {
    fn create(first_name:String,middle_name:String,last_name:String,year_born:u32) -> RegularPerson {
        RegularPerson{first_name,middle_name,last_name,year_born}
    }
}

// sample function accepting object implementing trait
fn long_description(person: &impl Person) {
    println!("{}, who is {} years old", person.get_name(), person.get_age());
}


use std::fmt::Debug;

fn multiple_1(person: &(impl Person + Debug)) {
    println!("{:?}",person);
    println!("Age: {}",person.get_age());
}

fn main() {
    let mlk = RegularPerson::create(
        String::from("Martin"),
        String::from("Luther"),
        String::from("King"),
        1929
    );
    let zlatan = SoccerPlayer::create(String::from("Zlatan Ibrahimovic"), 40, String::from("AC Milan"));

    multiple_1(&zlatan);
    multiple_1(&mlk);
}

Using traits in functions: multiple traits

trait Person {
    // method header specifications
    // must be implemented by any type that implements the trait
    fn get_name(&self) -> String;
    fn get_age(&self) -> u32;
    
    // default implementation of a method 
    fn description(&self) -> String {
        format!("{} ({})",self.get_name(),self.get_age())
    }
}

#[derive(Debug)]
struct SoccerPlayer {
    name: String,
    age: u32,
    team: String,
}

// Implement the `Person` trait for `SoccerPlayer` so that 
// it can be used as a `Person` object.
impl Person for SoccerPlayer {
    fn get_age(&self) -> u32 {
        self.age
    }
    
    // We must implement all trait items
    fn get_name(&self) -> String {
        self.name.clone()
    }
}

// Implement a constructor for `SoccerPlayer`
impl SoccerPlayer {
    fn create(name:String, age:u32, team:String) -> SoccerPlayer {
        SoccerPlayer{name,age,team}
    }
}

#[derive(Debug)]
struct RegularPerson {
    year_born: u32,
    first_name: String,
    middle_name: String,
    last_name: String,
}

impl Person for RegularPerson {
    fn get_age(&self) -> u32 {
        2024 - self.year_born
    }
    
    fn get_name(&self) -> String {
        if self.middle_name == "" {
            format!("{} {}",self.first_name,self.last_name)
        } else {
            format!("{} {} {}",self.first_name,self.middle_name,self.last_name)
        }
    }
}

impl RegularPerson {
    fn create(first_name:String,middle_name:String,last_name:String,year_born:u32) -> RegularPerson {
        RegularPerson{first_name,middle_name,last_name,year_born}
    }
}

// sample function accepting object implementing trait
fn long_description(person: &impl Person) {
    println!("{}, who is {} years old", person.get_name(), person.get_age());
}


use std::fmt::Debug;

// three options, useful for different settings

// This is good if you want to pass many parameters to the function
// and the parameters are of different types
fn multiple_1(person: &(impl Person + Debug)) {
    println!("{:?}",person);
    println!("Age: {}",person.get_age());
}

// This is better if you want all your parameters to be of the same type
fn multiple_2<T: Person + Debug>(person: &T) {
    println!("{:?}",person);
    println!("Age: {}",person.get_age());
}

// This is like option 2 but easier to read if your parameter
// combines many traits
fn multiple_3<T>(person: &T)
    where T: Person + Debug
{
    println!("{:?}",person);
    println!("Age: {}",person.get_age());
}

fn main() {
    let mlk = RegularPerson::create(
        String::from("Martin"),
        String::from("Luther"),
        String::from("King"),
        1929
    );

    multiple_1(&mlk);
    multiple_2(&mlk);
    multiple_3(&mlk);
}

Returning types implementing a trait

trait Person {
    // method header specifications
    // must be implemented by any type that implements the trait
    fn get_name(&self) -> String;
    fn get_age(&self) -> u32;
    
    // default implementation of a method 
    fn description(&self) -> String {
        format!("{} ({})",self.get_name(),self.get_age())
    }
}

#[derive(Debug)]
struct SoccerPlayer {
    name: String,
    age: u32,
    team: String,
}

// Implement the `Person` trait for `SoccerPlayer` so that 
// it can be used as a `Person` object.
impl Person for SoccerPlayer {
    fn get_age(&self) -> u32 {
        self.age
    }
    
    // We must implement all trait items
    fn get_name(&self) -> String {
        self.name.clone()
    }
}

// Implement a constructor for `SoccerPlayer`
impl SoccerPlayer {
    fn create(name:String, age:u32, team:String) -> SoccerPlayer {
        SoccerPlayer{name,age,team}
    }
}

#[derive(Debug)]
struct RegularPerson {
    year_born: u32,
    first_name: String,
    middle_name: String,
    last_name: String,
}

impl Person for RegularPerson {
    fn get_age(&self) -> u32 {
        2024 - self.year_born
    }
    
    fn get_name(&self) -> String {
        if self.middle_name == "" {
            format!("{} {}",self.first_name,self.last_name)
        } else {
            format!("{} {} {}",self.first_name,self.middle_name,self.last_name)
        }
    }
}

impl RegularPerson {
    fn create(first_name:String,middle_name:String,last_name:String,year_born:u32) -> RegularPerson {
        RegularPerson{first_name,middle_name,last_name,year_born}
    }
}

// sample function accepting object implementing trait
fn long_description(person: &impl Person) {
    println!("{}, who is {} years old", person.get_name(), person.get_age());
}



fn get_zlatan() -> impl Person {
    SoccerPlayer::create(String::from("Zlatan Ibrahimovic"), 40, String::from("AC Milan")) 
}

fn main() {
    let zlatan = SoccerPlayer::create(String::from("Zlatan Ibrahimovic"), 40, String::from("AC Milan"));
    let zlatan_2 = get_zlatan();
    long_description(&zlatan_2);
}

Recap

  • Traits are a way to define shared behavior that can be implemented by different types.
  • We can use traits to define shared behavior in an abstract way.
  • We can use trait bounds to specify that a generic type can be any type that has certain behavior.

In-Class Activity: Practicing Traits and Trait Bounds

Time: 10 minutes

Instructions

Work individually or in pairs. Complete as many exercises as you can in 10 minutes. You can test your code in the Rust playground or in your local environment.

Exercise 1: Define and Implement a Trait (3 minutes)

Define a trait called Describable with a method describe() that returns a String. Then implement it for the Book struct.

// TODO: Define the Describable trait
trait Describable {
    // Your code here
}

struct Book {
    title: String,
    author: String,
    pages: u32,
}

// TODO: Implement Describable for Book
// The describe() method should return a string like:
// "'The Rust Book' by Steve Klabnik (500 pages)"

fn main() {
    let book = Book {
        title: String::from("The Rust Book"),
        author: String::from("Steve Klabnik"),
        pages: 500,
    };
    
    println!("{}", book.describe());
}
Hint

Remember the trait definition syntax:

#![allow(unused)]
fn main() {
trait TraitName {
    fn method_name(&self) -> ReturnType;
}
}

And implementation:

#![allow(unused)]
fn main() {
impl TraitName for StructName {
    fn method_name(&self) -> ReturnType {
        // implementation
    }
}
}

Exercise 2: Multiple Trait Bounds with Where Clause (3 minutes)

Refactor the following function to use a where clause instead of inline trait bounds. Then add a call to the function in main.

use std::fmt::{Debug, Display};

// TODO: Refactor this to use a where clause
fn print_info<T: Debug + Display + PartialOrd>(item: &T, compare_to: &T) {
    println!("Item: {}", item);
    println!("Debug: {:?}", item);
    if item > compare_to {
        println!("Item is greater than comparison value");
    }
}

fn main() {
    // TODO: Call print_info with appropriate arguments
}
Hint

The where clause syntax is:

#![allow(unused)]
fn main() {
fn function_name<T>(params) -> ReturnType
    where T: Trait1 + Trait2
{
    // body
}
}

Bonus Challenge (if you finish early)

Create a trait called Area with a method area() that returns f64. Implement it for both Circle and Rectangle structs. Then write a generic function print_area that accepts anything implementing the Area trait.

// TODO: Define the Area trait

// TODO: Define Circle struct (radius: f64)

// TODO: Define Rectangle struct (width: f64, height: f64)

// TODO: Implement Area for Circle (π * r²)

// TODO: Implement Area for Rectangle (width * height)

// TODO: Write a generic function that prints the area
// fn print_area(...) { ... }

fn main() {
    let circle = Circle { radius: 5.0 };
    let rectangle = Rectangle { width: 4.0, height: 6.0 };
    
    print_area(&circle);
    print_area(&rectangle);
}

Solutions

Click to reveal solutions (try on your own first!)

Exercise 1 Solution

trait Describable {
    fn describe(&self) -> String;
}

struct Book {
    title: String,
    author: String,
    pages: u32,
}

impl Describable for Book {
    fn describe(&self) -> String {
        format!("'{}' by {} ({} pages)", self.title, self.author, self.pages)
    }
}

fn main() {
    let book = Book {
        title: String::from("The Rust Book"),
        author: String::from("Steve Klabnik"),
        pages: 500,
    };
    
    println!("{}", book.describe());
}

Exercise 2 Solution

use std::fmt::{Debug, Display};

fn print_info<T>(item: &T, compare_to: &T)
    where T: Debug + Display + PartialOrd
{
    println!("Item: {}", item);
    println!("Debug: {:?}", item);
    if item > compare_to {
        println!("Item is greater than comparison value");
    }
}

fn main() {
    print_info(&42, &10);
    print_info(&3.14, &2.71);
    print_info(&'z', &'a');
}

Bonus Solution

trait Area {
    fn area(&self) -> f64;
}

struct Circle {
    radius: f64,
}

struct Rectangle {
    width: f64,
    height: f64,
}

impl Area for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
}

impl Area for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
}

fn print_area(shape: &impl Area) {
    println!("Area: {:.2}", shape.area());
}

// Alternative using generic syntax:
// fn print_area<T: Area>(shape: &T) {
//     println!("Area: {:.2}", shape.area());
// }

fn main() {
    let circle = Circle { radius: 5.0 };
    let rectangle = Rectangle { width: 4.0, height: 6.0 };
    
    print_area(&circle);
    print_area(&rectangle);
}

Lifetimes in Rust

About This Module

This module introduces Rust's lifetime system, which ensures memory safety by tracking how long references remain valid. We'll explore lifetime annotations, the borrow checker, lifetime elision rules, and how lifetimes work with functions, structs, and methods.

Prework

Prework Reading

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. How do lifetimes prevent dangling pointer bugs that plague other systems languages?
  2. When does Rust require explicit lifetime annotations vs. lifetime elision?
  3. How do lifetime parameters relate to generic type parameters?
  4. What are the trade-offs between memory safety and programming convenience in lifetime systems?
  5. How do lifetimes enable safe concurrent programming patterns?

Learning Objectives

By the end of this module, you should be able to:

  • Understand how the borrow checker prevents dangling references
  • Write explicit lifetime annotations when required by the compiler
  • Apply lifetime elision rules to understand when annotations are optional
  • Use lifetimes in function signatures, structs, and methods
  • Combine lifetimes with generics and trait bounds
  • Debug lifetime-related compilation errors effectively

Lifetimes Overview

  • Ensures references are valid as long as we need them to be
  • The goal is to enable Rust compiler to prevent dangling references.
  • A dangling reference is a reference that points to data that has been freed or is no longer valid.

Note: you can separate declaration and initialization

#![allow(unused)]
fn main() {
let r;  // declaration
r = 32;  // initialization
println!("r: {r}");
}
  • Consider the following code:
#![allow(unused)]
fn main() {
let r;

{
    let x = 5;
    r = &x;
}

println!("r: {r}");
}

The Rust Compiler Borrow Checker

  • Let's annotate the lifetimes of r and x.

  • Rust uses a special naming pattern for lifetimes: 'a (single quote followed by identifier)

#![allow(unused)]
fn main() {
let r;                // ---------+-- 'a
                      //          |
{                     //          |
    let x = 5;        // -+-- 'b  |
    r = &x;           //  |       |
}                     // -+       |
                      //          |
println!("r: {r}");   //          |                      // ---------+
}
  • We can see that x goes out of scope before we use a reference, r, to x.

  • We can can fix the scope so lifetimes overlap

#![allow(unused)]
fn main() {
let x = 5;            // ----------+-- 'b
                      //           |
let r = &x;           // --+-- 'a  |
                      //   |       |
println!("r: {r}");   //   |       |
                      // --+       |
                      // ----------+
}

Generic Lifetimes in Functions

  • Let's see an example of why we need to be able to specify lifetimes.

  • Say we want to compare to strings and pick the longest one

// Compiler Error

// compare two string slices and return reference to the longest
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {x} else {y}
}

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}
Why is this a problem?
Answer: In general, we don't know which reference will be returned and so we can't know the lifetime of the return reference.

The Solution: Lifetime Annotation Syntax

  • names of lifetime parameters must start with an apostrophe (') and are usually all lowercase and very short, like generic types
#![allow(unused)]
fn main() {
&i32        // a reference with inferred lifetime
&'a i32     // a reference with an explicit lifetime
&'a mut i32 // a mutable reference with an explicit lifetime
}
  • now we can annotate our function with lifetime
#![allow(unused)]
fn main() {
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {x} else {y}
}
}

Update Example with Lifetime Annotation

  • we use the same syntax like we used for generic types, fn longest<'a>(...

  • The lifetime 'a is the shorter of the two input lifetimes: (x: &'a str, y: &'a str)

  • The returned string slice will have lifetime at least as long as 'a, e.g. -> &'a str

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {x} else {y}
}

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}
  • Above is not an issue, because all lifetimes are the same.

Example of Valid Code

// this code is still fine
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {x} else {y}
}

fn main() {
    let string1 = String::from("long string is long");

    {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {result}");
    }
}
  • Above is not an issue, because the returned reference is no longer than the shorter of the two args

Example of Invalid Code

  • But what about below?
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {x} else {y}
}

fn main() {
    let string1 = String::from("abcd");              // ----------+-- 'a
    let result;                                      //           |
    {                                                //           |
        let string2 = "xyz";                         // --+-- 'b  |
        result = longest(string1.as_str(), string2); //   |       |
    }                                                // --+       |
    println!("The longest string is {result}");      //           |
}                                                    // ----------+
  • We're trying to use result after the shortest arg lifetime ended

Lifetime of return type must match lifetime of at least one parameter

  • This won't work
#![allow(unused)]
fn main() {
fn first_str<'a>(_x: &str, _y: &str) -> &'a str {
    let result = String::from("really long string");
    result.as_str()
}
}
Why is this a problem?
Answer: The return reference is to `result` which gets dropped at end of function.

Lifetime Annotations in Struct Definitions

  • So far, we've only used structs that fully owned their member types.

  • We can define structs to hold references, but then we need lifetime annotations

#[derive(Debug)]
struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
    println!("{:?}", i);
}
  • An instance of ImportantExcerpt can't outlive the reference it holds in the part field.

Lifetime Elision

e·li·sion
/əˈliZH(ə)n/
noun

the omission of a sound or syllable when speaking (as in I'm, let's, e ' en ).

* an omission of a passage in a book, speech, or film.
  "the movie's elisions and distortions have been carefully thought out"

* the process of joining together or merging things, especially abstract ideas.
  "unease at the elision of so many vital questions"
  • In Rust, the cases where we can omit lifetime annotations are called lifetime elision.

Lifetime Elision Example

So why does this function compile without errors?

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}
fn main() {
    let s = String::from("Call me Ishmael.");
    let word = first_word(&s);
    println!("The first word is: {word}");
}

Shouldn't we have to write?

#![allow(unused)]
fn main() {
fn first_word<'a>(s: &'a str) -> &'a str {
}

Inferring Lifetimes

The compiler developers decided that some patterns were so common and simple to infer that the compiler could just infer and automatically generate the lifetime specifications.

  • input lifetimes: lifetimes on function or method parameters

  • output lifetimes: lifetimes on return values

Three Rules for Compiler Lifetime Inference

First Rule

Assign a lifetime parameter to each parameter that is a reference.

#![allow(unused)]
fn main() {
// function with one parameter
fn foo<'a>(x: &'a i32);

//a function with two parameters gets two separate lifetime parameters: 
fn foo<'a, 'b>(x: &'a i32, y: &'b i32);

// and so on.
}

Three Rules for Compiler Lifetime Inference

Second Rule

If there is exactly one input lifetime parameter, that lifetime is assigned to all output lifetime parameters

#![allow(unused)]
fn main() {
fn foo<'a>(x: &'a i32) -> &'a i32
}

Three Rules for Compiler Lifetime Inference

Third Rule -- Methods

If there are multiple input lifetime parameters, but one of them is &self or &mut self because this is a method, the lifetime of self is assigned to all output lifetime parameters.

Let's Test Our Understanding

You're the compiler and you see this function.

fn first_word(s: &str) -> &str {...}
Do any rules apply? which one would you apply first?
Answer:

First rule: Apply input lifetime annotations.

fn first_word<'a>(s: &'a str) -> &str {...}

Second rule: Apply output lifetime annotation.

fn first_word<'a>(s: &'a str) -> &'a str {...}

Done! Everything is accounted for.

Test Our Understanding Again

What about if you see this function signature?

fn longest(x: &str, y: &str) -> &str {...}
Can we apply any rules?

We can apply first rule again. Each parameter gets it's own lifetime.

fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {...}

Can we apply anymore rules?
No! Produce a compiler error asking for annotations.

Lifetime Annotations in Method Definitions

Let's take a look at the third rule again:

If there are multiple input lifetime parameters, but one of them is &self or &mut self because this is a method, the lifetime of self is assigned to all output lifetime parameters.

Previously, we defined a struct with a field that takes a string slice reference.

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct ImportantExcerpt<'a> {
    part: &'a str,
}

// For implementation, `impl` of methods, we use the generics style annotation, which is required.

// But we don't have to annotate the following method. The **First Rule** applies.
impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

// For the following method...
impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {announcement}");
        self.part
    }
}
}

There are two input lifetimes so:

  • Rust applies the first lifetime elision rule and gives both &self and announcement their own lifetimes.
  • Then, because one of the parameters is &self, the return type gets the lifetime of &self, and all lifetimes have been accounted for.

The Static Lifetime

  • a special lifetime designation
  • lives for the entire duration of the program
#![allow(unused)]
fn main() {
// This is actually redundant since string literals are always 'static
let s: &'static str = "I have a static lifetime.";
}
  • use only if necessary

  • manage lifetimes more fine grained if at all possible

For more, see for example:

  • https://doc.rust-lang.org/rust-by-example/scope/lifetime/static_lifetime.html

Combining Lifetimes with Generics and Trait Bounds

Let's look at an example that combines:

  • lifetimes
  • generics with trait bounds
use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,  // T must implement the Display trait
{
    println!("Announcement! {ann}");
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("short");
    let string2 = "longer";

    let result = longest_with_an_announcement(string1.as_str(), string2, "Hear ye! Hear ye!");
    println!("The longest string is {result}");
}

Breaking Down the Function Declaration

Let's break down the function declaration:

#![allow(unused)]
fn main() {
fn longest_with_an_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,   // T must implement the Display trait
}
  • It has two generic parameters:
    • 'a: A lifetime parameter
    • T: A type parameter
  • It takes three arguments:
    • x: A string slice with lifetime 'a
    • y: A string slice with lifetime 'a
    • ann: A value of generic type T
  • Returns a string slice with lifetime 'a
  • The where clause specifies that type T must implement the Display trait

Recap

  • Lifetimes are a way to ensure that references are valid as long as we need them to be.
  • The borrow checker is a tool that helps us ensure that our references are valid.
  • We can use lifetime annotations to help the borrow checker understand our code better.
  • We can use lifetime elision to help the compiler infer lifetimes for us.
  • We can use lifetimes in function signatures, structs, and methods.
  • We can combine lifetimes with generics and trait bounds.

In-Class Exercise

Part 1 -- Illustrate the Lifetimes

Annotate the lifetimes of the variables in the following code using the notation from the beginning of the module.

Paste the result in GradeScope.

#![allow(unused)]
fn main() {
{
    let s = String::from("never mind how long precisely --"); // 
    {                                                         //
        let t = String::from("Some years ago -- ");           //
        {                                                     //
            let v = String::from("Call me Ishmael.");         //
            println!("{v}");                                  //
        }                                                     //
        println!("{t}");                                      //
    }                                                         //
    println!("{s}");                                          //
}                                                             //
}
Solution
#![allow(unused)]
fn main() {
{
    let s = String::from("never mind how long precisely --"); //----------+'a
    {                                                         //          |
        let t = String::from("Some years ago -- ");           //------+'b |
        {                                                     //      |   |
            let v = String::from("Call me Ishmael.");         //--+'c |   |
            println!("{v}");                                  //  |   |   |
        }                                                     //--+   |   |
        println!("{t}");                                      //      |   |
    }                                                         //--------+ |
    println!("{s}");                                          //          |
}                                                             //----------+
}

Part 2 -- Fix the Function with Multiple References

The following function is supposed to take a vector of string slices, a default value, and an index, and return either the string at the given index or the default if the index is out of bounds. However, it won't compile without lifetime annotations.

Add the appropriate lifetime annotations to make this code compile and paste the result in GradeScope.

fn get_or_default(strings: &Vec<&str>, default: &str, index: usize) -> &str {
    if index < strings.len() {
        strings[index]
    } else {
        default
    }
}

fn main() {
    let vec = vec!["hello", "world", "rust"];
    let default = "not found";
    let result = get_or_default(&vec, default, 5);
    println!("{}", result);
}
Solution
fn get_or_default<'a>(strings: &Vec<&'a str>, default: &'a str, index: usize) -> &'a str {
    if index < strings.len() {
        strings[index]
    } else {
        default
    }
}

fn main() {
    let vec = vec!["hello", "world", "rust"];
    let default = "not found";
    let result = get_or_default(&vec, default, 5);
    println!("{}", result);
}

The return value could come from either strings or default, so both need the same lifetime annotation 'a. The vector reference itself doesn't need to live as long since we're returning references to its contents, not the vector itself.

Part 3 -- Generic Type with Lifetime Annotations

The following code defines a Wrapper struct that holds both a generic value and a reference. The struct and its method won't compile without proper lifetime annotations.

Add the appropriate lifetime annotations to make this code compile and paste the result in GradeScope.

struct Wrapper<T> {
    value: T,
    description: &str,
}

impl<T> Wrapper<T> {
    fn new(value: T, description: &str) -> Self {
        Wrapper { value, description }
    }
    
    fn get_description(&self) -> &str {
        self.description
    }
    
    fn get_value(&self) -> &T {
        &self.value
    }
}

fn main() {
    let desc = String::from("A number");
    let wrapper = Wrapper::new(42, &desc);
    println!("Value: {}, Description: {}", wrapper.get_value(), wrapper.get_description());
}
Solution
struct Wrapper<'a, T> {
    value: T,
    description: &'a str,
}

impl<'a, T> Wrapper<'a, T> {
    fn new(value: T, description: &'a str) -> Self {
        Wrapper { value, description }
    }
    
    fn get_description(&self) -> &str {
        self.description
    }
    
    fn get_value(&self) -> &T {
        &self.value
    }
}

fn main() {
    let desc = String::from("A number");
    let wrapper = Wrapper::new(42, &desc);
    println!("Value: {}, Description: {}", wrapper.get_value(), wrapper.get_description());
}

The struct needs a lifetime parameter 'a because it holds a reference (description). The impl block must also declare this lifetime parameter: impl<'a, T>. The methods get_description and get_value don't need explicit lifetime annotations because the compiler can apply elision rules (the return lifetime is inferred from &self).

Closures (Anonymous Functions) in Rust

About This Module

This module introduces Rust closures - anonymous functions that can capture variables from their environment. Closures are powerful tools for functional programming patterns, lazy evaluation, and creating flexible APIs. Unlike regular functions, closures can capture variables from their surrounding scope, making them ideal for customizing behavior and implementing higher-order functions.

Prework

Prework Reading

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. How do closures differ from regular functions in terms of variable capture?
  2. What are the advantages of lazy evaluation using closures over eager evaluation?
  3. How does Rust's type inference work with closure parameters and return types?
  4. When would you choose a closure over a function pointer for API design?
  5. How do closures enable functional programming patterns in systems programming?

Learning Objectives

By the end of this module, you should be able to:

  • Define and use closures with various syntactic forms
  • Understand how closures capture variables from their environment
  • Implement lazy evaluation patterns using closures
  • Use closures with Option and Result methods like unwrap_or_else
  • Apply closures for HashMap entry manipulation and other standard library methods
  • Choose between closures and function pointers based on use case

Closures (Anonymous Functions)

  • Closures are anonymous functions you can:
    • save in a variable, or
    • pass as arguments to other functions

In Python they are called lambda functions:

>>> x = lambda a, b: a * b
>>> print(x(5,6))
30

In Rust syntax (with implicit or explicit type specification):

|a, b| a * b
|a: i32, b: i32| -> i32 {a * b}

Basic Closure Syntax

  • types are inferred
#![allow(unused)]
fn main() {
// Example 1: Basic closure syntax
let add = |x, y| x + y;
println!("Basic closure: 5 + 3 = {}", add(5, 3));
}

Can't change types

  • Once inferred, the type cannot change.
#![allow(unused)]
fn main() {
let example_closure = |x| x;

let s = example_closure(String::from("hello"));
let n = example_closure(5);
}

Basic Closure Syntax with Explicit Types

  • Type annotations in closures are optional unlike in functions.
  • Required in functions because those are interfaces exposed to users.

For comparison:

fn  add_one_v1   (x: u32) -> u32 { x + 1 }  // function
let add_one_v2 = |x: u32| -> u32 { x + 1 }; // closures...
let add_one_v3 = |x|             { x + 1 }; // ... remove types
let add_one_v4 = |x|               x + 1  ; // ... remove brackets

Another example:

#![allow(unused)]
fn main() {
let add = |x: i32, y: i32| -> i32 {x + y};
println!("Basic closure: 5 + 3 = {}", add(5, 3));
}

Closure Capturing a Variable from the Environment

Note how multiplier is used from the environment.

#![allow(unused)]
fn main() {
let multiplier = 2;
let multiply = |x| x * multiplier;
println!("Closure with captured variable: 4 * {} = {}", multiplier, multiply(4));
}

Closure with Multiple Statements

#![allow(unused)]
fn main() {
let process = |x: i32| {
    let doubled = x * 2;
    doubled + 1
};
println!("Multi-statement closure: process(3) = {}", process(3));
}

Digression

  • You can assign regular functions to variables as well
#![allow(unused)]
fn main() {
fn median2(arr: &mut [i32]) -> i32 {
    arr.sort();
    println!("{}", arr[2]);
    arr[2]
}

let f = median2;
f(&mut [1,4,5,6,4]);
}
  • but you can't capture variables from the environment.

Lazy Evaluation

Closures enable lazy evaluation: delaying computation until the result is actually needed.

  • unwrap_or() and unwrap_or_else() are methods on Option and Result
  • unwrap_or_else() takes a closure and only executes on else case.
// Expensive computation function
// What is this computing???
fn expensive_computation(n: i32) -> i32 {
    println!("Computing expensive result...");
    if n <= 1 { 1 } 
    else { expensive_computation(n-1) + expensive_computation(n-2) }
}

fn main() {
    let x = Some(5);
    
    // EAGER evaluation - always computed, even if not needed!
    println!("EAGER evaluation");
    let result1 = x.unwrap_or(expensive_computation(5));
    println!("Result 1: {}", result1);
    
    // LAZY evaluation - only computed if needed
    println!("\nLAZY evaluation");
    let result2 = x.unwrap_or_else(|| expensive_computation(5));  // <-- note the closure!
    println!("Result 2: {}", result2);
    
    // When x is None, the closure is called
    println!("\nNone evaluation");
    let y: Option<i32> = None;
    let result3 = y.unwrap_or_else(|| expensive_computation(5));
    println!("Result 3: {}", result3);
}

Key insight: unwrap_or_else takes a closure, delaying execution until needed.

Recap

  • Closures are anonymous functions that can be saved in variables or passed as arguments
  • Syntax: |params| expression or |params| { statements } - type annotations are optional
  • Type inference: Closure types are inferred from first use and cannot change afterward
  • Environment capture: Unlike regular functions, closures can capture variables from their surrounding scope
  • Flexibility: Closures are more flexible than functions, but functions can also be assigned to variables
  • Closures enable lazy evaluation, functional programming patterns, and flexible API design

In-Class Activity

Exercise: Mastering Closures (10 minutes)

Setup: Work individually or in pairs. Open the Rust Playground or your local editor.

Paste your solutions in GradeScope.

Part 1: Basic Closure Practice (3 minutes)

Create closures for the following tasks. Try to use the most concise syntax possible:

  1. A closure that takes two integers and returns their maximum
  2. A closure that takes a string slice and returns its length
  3. A closure that captures a tax_rate variable from the environment and calculates the total price (price + tax)
fn main() {
    // TODO 1: Write a closure that returns the maximum of two integers
    let max = // YOUR CODE HERE
    println!("Max of 10 and 15: {}", max(10, 15));
    
    // TODO 2: Write a closure that returns the length of a string slice
    let str_len = // YOUR CODE HERE
    println!("Length of 'hello': {}", str_len("hello"));
    
    // TODO 3: Write a closure that captures tax_rate and calculates total
    let tax_rate = 0.08;
    let calculate_total = // YOUR CODE HERE
    println!("Price $100 with {}% tax: ${:.2}", tax_rate * 100.0, calculate_total(100.0));
}

Part 2: Lazy vs Eager Evaluation (4 minutes)

Fix the following code by converting eager evaluation to lazy evaluation where appropriate:

fn expensive_database_query(id: i32) -> String {
    println!("Querying database for id {}...", id);
    // Simulate expensive operation
    format!("User_{}", id)
}

fn main() {
    // Scenario 1: We have a cached user
    let cached_user = Some("Alice".to_string());
    
    // BUG: This always queries the database, even when we have a cached value!
    let user1 = cached_user.unwrap_or(expensive_database_query(42));
    println!("User 1: {}", user1);
    
    // TODO: Fix the above to only query when needed
    
    // Scenario 2: No cached user
    let cached_user2: Option<String> = None;
    let user2 = // YOUR CODE HERE - use lazy evaluation
    println!("User 2: {}", user2);
}

Part 3: Counter using a mutable closure

Create a closure that captures and modifies a variable and assigns it to a variable called increment.

fn main() {
    // Create a counter using a mutable closure
    // This closure captures and modifies a variable

    // Your code here.

    
    println!("Count: {}", increment());
    println!("Count: {}", increment());
    println!("Count: {}", increment());
}

Bonus: Challenge - Functions That Accept Closures (3 minutes)

Write a function that takes a closure as a parameter and uses it:

// TODO: Complete this function that applies an operation to a number
// only if the number is positive. Otherwise returns None.
fn apply_if_positive<F>(value: i32, operation: F) -> Option<i32> 
where
    F: Fn(i32) -> i32  // F is a closure that takes i32 and returns i32
{
    // YOUR CODE HERE
}

fn main() {
    // Test with different closures
    let double = |x| x * 2;
    let square = |x| x * x;
    
    println!("Double 5: {:?}", apply_if_positive(5, double));
    println!("Square 5: {:?}", apply_if_positive(5, square));
    println!("Double -3: {:?}", apply_if_positive(-3, double));
}

Discussion Questions (during/after activity):

  1. When did you need explicit type annotations vs. relying on inference?
  2. In Part 2, what's the practical difference in performance between eager and lazy evaluation?
  3. Can you think of other scenarios where lazy evaluation with closures would be beneficial?
  4. What happens if you try to use a closure after the captured variable has been moved?

Solutions

Part 1 Solutions:

fn main() {
    // Solution 1: Maximum of two integers
    let max = |a, b| if a > b { a } else { b };
    println!("Max of 10 and 15: {}", max(10, 15));
    
    // Solution 2: Length of a string slice
    let str_len = |s: &str| s.len();
    println!("Length of 'hello': {}", str_len("hello"));
    
    // Solution 3: Calculate total with captured tax_rate
    let tax_rate = 0.08;
    let calculate_total = |price| price + (price * tax_rate);
    println!("Price $100 with {}% tax: ${:.2}", tax_rate * 100.0, calculate_total(100.0));
}

Key Points:

  • The max closure uses an if expression to return the larger value
  • The str_len closure needs a type annotation &str because Rust needs to know it's a string slice (not a String)
  • The calculate_total closure captures tax_rate from the environment automatically

Part 2 Solutions:

fn expensive_database_query(id: i32) -> String {
    println!("Querying database for id {}...", id);
    format!("User_{}", id)
}

fn main() {
    // Scenario 1: We have a cached user
    let cached_user = Some("Alice".to_string());
    
    // FIXED: Use unwrap_or_else with a closure for lazy evaluation
    let user1 = cached_user.unwrap_or_else(|| expensive_database_query(42));
    println!("User 1: {}", user1);
    
    // Scenario 2: No cached user
    let cached_user2: Option<String> = None;
    let user2 = cached_user2.unwrap_or_else(|| expensive_database_query(99));
    println!("User 2: {}", user2);
}

Key Points:

  • In Scenario 1, with unwrap_or_else, the database query is NOT executed because we have Some("Alice")
  • In Scenario 2, the closure IS executed because we have None
  • Notice the closure syntax: || expensive_database_query(42) - no parameters needed
  • The lazy evaluation saves expensive computation when the value is already available

Part 3 Solutions:

fn main() {
    // Create a counter using a mutable closure
    // This closure captures and modifies a variable

    let mut count = 0;
    let mut increment = || {
        count += 1;
        count
    };
    
    println!("Count: {}", increment());
    println!("Count: {}", increment());
    println!("Count: {}", increment());
}
  • The closure mutates the captured variable each time it's called

Bonus: Challenge Solutions:

// Solution: Complete function that applies operation only to positive numbers
fn apply_if_positive<F>(value: i32, operation: F) -> Option<i32> 
where
    F: Fn(i32) -> i32
{
    if value > 0 {
        Some(operation(value))
    } else {
        None
    }
}

fn main() {
    // Test with different closures
    let double = |x| x * 2;
    let square = |x| x * x;
    
    println!("Double 5: {:?}", apply_if_positive(5, double));      // Some(10)
    println!("Square 5: {:?}", apply_if_positive(5, square));      // Some(25)
    println!("Double -3: {:?}", apply_if_positive(-3, double));    // None
}

Key Points:

  • The function uses a generic type parameter F with a Fn(i32) -> i32 trait bound
  • This allows any closure (or function) that takes an i32 and returns an i32
  • The mutable closure requires mut on both count and increment
  • This demonstrates closure flexibility: they can be immutable (like double) or mutable (like increment)

Iterators in Rust

About This Module

This module introduces Rust's iterator pattern, which provides a powerful and efficient way to process sequences of data. Iterators in Rust are lazy, meaning they don't do any work until you call methods that consume them. You'll learn to create custom iterators, use built-in iterator methods, and understand how iterators enable functional programming patterns while maintaining Rust's performance characteristics.

Prework

Prework Reading

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. How do iterators in Rust differ from traditional for loops in terms of performance and safety?
  2. What does it mean for iterators to be "lazy" and why is this beneficial?
  3. How do iterator adapters (like map, filter) differ from iterator consumers (like collect, fold)?
  4. Why can't floating-point ranges be directly iterable in Rust?
  5. How does implementing the Iterator trait enable custom data structures to work with Rust's iteration ecosystem?

Learning Objectives

By the end of this module, you should be able to:

  • Create and use iterators from ranges and collections
  • Implement custom iterators by implementing the Iterator trait
  • Apply iterator adapters (map, filter, take, cycle) to transform data
  • Use iterator consumers (collect, fold, reduce, any) to produce final results
  • Understand lazy evaluation in the context of Rust iterators
  • Choose between iterator-based and loop-based approaches for different scenarios

Iterators

The iterator pattern allows you to perform some task on a sequence of items in turn.

An iterator is responsible for the logic of iterating over each item and determining when the sequence has finished.

  • provide values one by one
  • method next provides next one
  • Some(value) or None if no more available

Some ranges are iterators:

  • 1..100
  • 0..

First value has to be known (so .. and ..123 are not)

Range as an Iterator Example

fn main() {
let mut iter = 1..3; // must be mutable

println!("{:?}", iter.next());
println!("{:?}", iter.next());
println!("{:?}", iter.next());
println!("{:?}", iter.next());
println!("{:?}", iter.next());
}

Range between floats is not iterable

  • What about a range between floats?
#![allow(unused)]
fn main() {
let mut iter = 1.0..3.0; // must be mutable
println!("{:?}", iter.next());
println!("{:?}", iter.next());
println!("{:?}", iter.next());
println!("{:?}", iter.next());
println!("{:?}", iter.next());
}
  • In Rust, ranges over floating-point numbers (f64) are not directly iterable.

  • This is because floating-point numbers have inherent precision issues that make it difficult to guarantee exact iteration steps.

Range between characters is iterable

  • But this works.
#![allow(unused)]
fn main() {
let mut iter = 'a'..'c'; // must be mutable
println!("{:?}", iter.next());
println!("{:?}", iter.next());
println!("{:?}", iter.next());
println!("{:?}", iter.next());
println!("{:?}", iter.next());
}

Iterator from Scratch: Implementing the Iterator Trait

struct Fib {
    current: u128,
    next: u128,
}

impl Fib {
    fn new() -> Fib {
        Fib{current: 0, next: 1}
    }
}

impl Iterator for Fib {
    type Item = u128;
    
    // Calculate the next number in the Fibonacci sequence
    fn next(&mut self) -> Option<Self::Item> {
        let now = self.current;
        self.current = self.next;
        self.next = now + self.current;
        Some(now)
    }
}

fn main() {
    let mut fib = Fib::new();
    for _ in 0..10 {
        print!("{:?} ",fib.next().unwrap());
    }
    println!();
}

Iterator Methods and Adapters

Pay special attention to what the output is.

  • next() -> Get the next element of an iterator (None if there isn't one)
  • collect() -> Put iterator elements in collection
  • take(N) -> take first N elements of an iterator and turn them into an iterator
  • cycle() -> Turn a finite iterator into an infinite one that repeats itself
  • for_each(||, ) -> Apply a closure to each element in the iterator
  • filter(||, ) -> Create new iterator from old one for elements where closure is true
  • map(||, ) -> Create new iterator by applying closure to input iterator
  • any(||, ) -> Return true if closure is true for any element of the iterator
  • fold(a, |a, |, ) -> Initialize expression to a, execute closure on iterator and accumulate into a
  • reduce(|x, y|, ) -> Similar to fold but the initial value is the first element in the iterator
  • zip(iterator) -> Zip two iterators together to turn them into pairs

If the method returns an iterator, you have to do something with the iterator.

See Rust provided methods for the complete list.

Iterator Methods Examples

#![allow(unused)]
fn main() {
// this does nothing!
let v1 = vec![1, 2, 3];
let mut v1_iter = v1.iter();
println!("{:?}", v1_iter);
println!("{:?}", v1_iter.next());
}

collect can be used to put elements of an iterator into a vector:

#![allow(unused)]
fn main() {
let small_numbers : Vec<_> = (1..=10).collect();
println!("{:?}", small_numbers);
}

take turns an infinite iterator into an iterator that provides at most a specific number of elements

#![allow(unused)]
fn main() {
let small_numbers : Vec<_> = (1..).take(15).collect();
println!("{:?}", small_numbers);
}

cycle creates an iterator that repeats itself forever:

#![allow(unused)]
fn main() {
let cycle : Vec<_> = (1..4).cycle().take(21).collect();
println!("{:?}", cycle);
}

Recap

  • Iterators provide values one by one via the next() method, returning Some(value) or None
  • Ranges like 1..100 and 0.. are iterators (but floating-point ranges are not)
  • Custom iterators can be created by implementing the Iterator trait with next() method
  • Lazy evaluation: Iterators don't do work until consumed
  • Adapters (like map, filter, take, cycle) transform iterators into new iterators
  • Consumers (like collect, fold, reduce, any) produce final results from iterators
  • Iterators enable functional programming patterns while maintaining Rust's performance

Iterators + Closures: Functional Programming in Rust

About This Module

This module explores the powerful combination of iterators and closures in Rust, which enables elegant functional programming patterns. You'll learn how to chain iterator methods with closures to create expressive, efficient data processing pipelines. This combination allows you to write concise code for complex operations like filtering, mapping, reducing, and combining data sequences while maintaining Rust's performance guarantees.

Prework

Prework Reading

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. How do closures enable powerful iterator chaining patterns that would be difficult with function pointers?
  2. What are the performance implications of chaining multiple iterator adapters together?
  3. How does the combination of map and reduce/fold relate to the MapReduce paradigm in distributed computing?
  4. When would you choose fold vs reduce for aggregation operations?
  5. How does Rust's type system help prevent common errors in functional programming patterns?

Learning Objectives

By the end of this module, you should be able to:

  • Combine iterators with closures for concise data processing
  • Use functional programming patterns like map, filter, and fold effectively
  • Implement complex algorithms using iterator method chaining
  • Choose appropriate aggregation methods (fold, reduce, sum) for different scenarios
  • Apply zip to combine multiple data sequences
  • Build efficient data processing pipelines using lazy evaluation

Iterator + Closure Magic

  • Operate on entire sequence, sometimes lazily by creating a new iterator
  • Allows for concise expression of many concepts

for_each applies a function to each element

#![allow(unused)]
fn main() {
let x = (0..5).for_each(|x| println!("{}",x));
}

filter creates a new iterator that has elements for which the given function is true

#![allow(unused)]
fn main() {
let not_divisible_by_3 : Vec<_> = (0..10).filter(|x| x % 3 != 0).collect();
println!("{:?}", not_divisible_by_3);
}

More Iterator Operations with Closures

  • Operate on entire sequence, sometimes lazily by creating a new iterator
  • Allows for concise expression of many concepts

map creates a new iterator in which values are processed by a function

struct Fib {
    current: u128,
    next: u128,
}

impl Fib {
    fn new() -> Fib {
        Fib{current: 0, next: 1}
    }
}

impl Iterator for Fib {
    type Item = u128;
    
    // Calculate the next number in the Fibonacci sequence
    fn next(&mut self) -> Option<Self::Item> {
        let now = self.current;
        self.current = self.next;
        self.next = now + self.current;
        Some(now)
    }
}

fn main() {
let fibonacci_squared : Vec<_> = Fib::new().take(10).map(|x| x*x).collect();
println!("{:?}", fibonacci_squared);
}

Calculate Primes with .any()

any is true if the passed function is true on some element

Is a number prime?

fn is_prime(k:u32) -> bool {
    !(2..k).any(|x| k % x == 0)
}

fn main() {
println!("{}", is_prime(33));
println!("{}", is_prime(31));
}

Create infinite iterator over primes:

#![allow(unused)]
fn main() {
// create a new iterator
let primes = (2..).filter(|k| !(2..*k).any(|x| k % x == 0));  
let v : Vec<_> = primes.take(20).collect();
println!("{:?}", v);
}

Functional Programming Classic: fold

  • fold(init, |acc, x| f(acc, x) ) -> Initialize expression to init, execute closure on iterator and accumulate into acc.

iterator.fold(init, |acc, x|, f(x)) equivalent to

let mut accumulator = init;
while let Some(x) = iterator.next() {
    accumulator = f(accumulator,x);
}
println!("{:?}", accumulator);

Example: compute

#![allow(unused)]
fn main() {
let sum_of_squares: i32 = (1..=10).fold(0,|a,x| a + x * x);
println!("{}", sum_of_squares);
}
#![allow(unused)]
fn main() {
// Another approach: using `sum` (which can be implemented using `map`)
let sum_of_squares: i32 = (1..=10).map(|x| x * x).sum();
println!("{}", sum_of_squares);
}

Functional Programming Classic: reduce

  • reduce(|x, y|, ) -> Similar to fold but the initial value is the first element in the iterator

iterator.reduce(f) equivalent to

if let Some(x) = iterator.next() {
    let mut accumulator = x;
    while let Some(y) = iterator.next() { accumulator = f(accumulator,y}
    Some(accumulator)
} else {
    None
}

Differences from fold:

  • no default value for an empty sequence
  • output must be the same type as elements of input sequence
  • output for length–one sequence equals the only element in the sequence

Example: computing the maximum number in {x^2 mod 7853: x∈[1,...,123]}, i.e. finds the largest squared value (modulo 7853) across all integers from 1 to 123.

#![allow(unused)]
fn main() {
let x = (1..=123).map(|x| (x*x) % 7853).reduce(|x,y| x.max(y)).unwrap();
println!("{}", x);
}

where y is the next element in the iterator.

#![allow(unused)]
fn main() {
// in this case one can use the builtin `max` method (which can be implemented, using `fold`)
let x = (1..=123).map(|x| (x*x) % 7853).max().unwrap();
println!("{}", x);
}

Combining Two Iterators: zip

  • Returns an iterator of pairs
  • The length is the minimum of the lengths
#![allow(unused)]
fn main() {
let v: Vec<_>= (1..10).zip(11..20).collect();
println!("{:?}", v);
}

Inner product of two vectors:

#![allow(unused)]
fn main() {
let x: Vec<f64> = vec![1.1,  2.2, -1.3,  2.2];
let y: Vec<f64>  = vec![2.7, -1.2, -1.1, -3.4];
let inner_product: f64 = x.iter().zip(y.iter()).map(|(a,b)| a * b).sum();
println!("{}", inner_product);
}

Recap

  • for_each - apply function to each element
  • filter - create iterator with elements matching a condition
  • map - transform elements into new values
  • any - test if any element satisfies a condition
  • fold - accumulate with explicit initial value
  • reduce - accumulate using first element (returns Option)
  • zip - combine two iterators into pairs

In-Class Exercise

Time: 5 minutes

Complete the following tasks using iterators and their methods:

  1. Create a vector containing the first 10 odd numbers (1, 3, 5, ..., 19)

    • Use a range starting from 1
    • Use iterator adapters and collect()
  2. Using the Fibonacci iterator from earlier, collect the first 15 Fibonacci numbers into a vector and print them.

  3. Create an iterator that:

    • Starts with the range 1..=20
    • Filters to keep only numbers divisible by 3
    • Multiplies each remaining number by 2
    • Collects into a vector

Bonus Challenge: Without running the code, predict what this will output:

#![allow(unused)]
fn main() {
let result: Vec<_> = (0..5).map(|x| x * 2).collect();
println!("{:?}", result);
}

Solution Discussion

After attempting the exercise, compare your solutions with a neighbor. Key concepts to verify:

  • Did you chain iterator adapters before calling a consumer?
  • Did you understand that map and filter return iterators, not final values?
  • Did you remember that iterators are lazy and need a consumer to produce results?

Solutions

Task 1: First 10 odd numbers

#![allow(unused)]
fn main() {
let odd_numbers: Vec<_> = (1..).step_by(2).take(10).collect();
println!("{:?}", odd_numbers);
// Output: [1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
}

Alternative solution using filter:

#![allow(unused)]
fn main() {
let odd_numbers: Vec<_> = (1..20).filter(|x| x % 2 == 1).collect();
println!("{:?}", odd_numbers);
}

Task 2: First 15 Fibonacci numbers

struct Fib {
    current: u128,
    next: u128,
}

impl Fib {
    fn new() -> Fib {
        Fib{current: 0, next: 1}
    }
}

impl Iterator for Fib {
    type Item = u128;
    
    // Calculate the next number in the Fibonacci sequence
    fn next(&mut self) -> Option<Self::Item> {
        let now = self.current;
        self.current = self.next;
        self.next = now + self.current;
        Some(now)
    }
}

fn main() {
let fib_numbers: Vec<_> = Fib::new().take(15).collect();
println!("{:?}", fib_numbers);
// Output: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377]
}

Task 3: Filter and map

#![allow(unused)]
fn main() {
let result: Vec<_> = (1..=20)
    .filter(|x| x % 3 == 0)
    .map(|x| x * 2)
    .collect();
println!("{:?}", result);
// Output: [6, 12, 18, 24, 30, 36]
}

Bonus Challenge

#![allow(unused)]
fn main() {
let result: Vec<_> = (0..5).map(|x| x * 2).collect();
println!("{:?}", result);
// Output: [0, 2, 4, 6, 8]
}

Error handling in Rust

About This Module

This module covers error handling in Rust, focusing on the use of the Result enum for recoverable errors and the panic! macro for unrecoverable errors. You'll learn how to propagate errors using the ? operator and how to design functions that can gracefully handle failure scenarios while maintaining Rust's safety and performance guarantees.

Prework

Prework Reading

Please read the following sections from The Rust Programming Language Book:

  • Chapter 9: Error Handling
  • Chapter 9.1: Unrecoverable Errors with panic!
  • Chapter 9.2: Recoverable Errors with Result

Pre-lecture Reflections

Before class, consider these questions:

  1. What are the differences between recoverable and unrecoverable errors in Rust?
  2. How does the Result enum facilitate error handling in Rust?
  3. What are the advantages of using the ? operator for error propagation?
  4. When should you use panic! versus returning a Result?
  5. How does Rust's approach to error handling compare to exception handling in other languages?

Lecture

Learning Objectives

By the end of this module, you will be able to:

  • Understand the difference between recoverable and unrecoverable errors
  • Use the panic! macro for handling unrecoverable errors
  • Use the Result enum for handling recoverable errors
  • Propagate errors using the ? operator
  • Design functions that can handle errors gracefully

Error Handling in Rust

Two basic options:

  • terminate when an error occurs: macro panic!(...)

  • pass information about an error: enum Result<T,E>

Macro panic!(...)

  • Use for unrecoverable errors
  • Terminates the application
fn divide(a:u32, b:u32) -> u32 {
    if b == 0 {
        panic!("I'm sorry, Dave. I'm afraid I can't do that.");
    }
    a/b
}

fn main() {
    println!("{}", divide(20,7));
    //println!("{}", divide(20,0));  // Try uncommenting this line
}

Enum Result<T,E>

Provided by the standard library, but shown here for reference.

#![allow(unused)]
fn main() {
enum Result<T,E> {
    Ok(T),
    Err(E),
}
}

Functions can use it to

  • return a result
  • or information about an encountered error
fn divide(a:u32, b:u32) -> Result<u32, String> {
    if b != 0 {
        Ok(a / b)
    } else {
        let str = format!("Division by zero {} {}", a, b);
        Err(str)
    }
}

fn main() {
    println!("{:?}", divide(20,7));
    println!("{:?}", divide(20,0));
}
  • Useful when the error best handled somewhere else
  • Example: input/output subroutines in the standard library

Common pattern: propagating errors

  • We are interested in the positive outcome: t in Ok(t)
  • But if an error occurs, we want to propagate it
  • This can be handled using match statements
fn divide(a:u32, b:u32) -> Result<u32, String> {
    if b != 0 {
        Ok(a / b)
    } else {
        let str = format!("Division by zero {} {}", a, b);
        Err(str)
    }
}

// compute a/b + c/d
fn calculate(a:u32, b:u32, c:u32, d:u32) -> Result<u32, String> {
    let first = match divide(a,b) {
        Ok(t) => t,
        Err(e) => return Err(e),
    };
    let second = match divide(c,d) {
        Ok(t) => t,
        Err(e) => return Err(e),
    };    
    Ok(first + second)
}


fn main() {
    println!("{:?}", calculate(16,4,18,3));
    println!("{:?}", calculate(16,0,18,3));
}

The question mark shortcut

  • Place ? after an expression that returns Result<T,E>

  • This will:

    • give the content of Ok(t)
    • or immediately return the error Err(e) from the encompassing function
fn divide(a:u32, b:u32) -> Result<u32, String> {
    if b != 0 {
        Ok(a / b)
    } else {
        let str = format!("Division by zero {} {}", a, b);
        Err(str)
    }
}

// compute a/b + c/d
fn calculate(a:u32, b:u32, c:u32, d:u32) -> Result<u32, String> {
    Ok(divide(a,b)? + divide(c,d)?)
}

fn main() {
    println!("{:?}", calculate(16,4,18,3));
    println!("{:?}", calculate(16,0,18,3));
}

Optional: try/catch pattern

  • In some languages we have the pattern try/catch or throw/catch or try/except (C++, Java, Javascript, Python).
  • Rust does not have something equivalent

The Rust pattern for error handling is the following:

    let do_steps = || -> Result<(), MyError> {
        do_step_1()?;
        do_step_2()?;
        do_step_3()?;
        Ok(())
    };

    if let Err(_err) = do_steps() {
        println!("Failed to perform necessary steps");
    }
  • Create a closure with the code you want to guard. Use the ? shorthand inside the closure for anything that can return an Error. Use a match or if let statement to catch the error.

Recap

  • Use panic! for unrecoverable errors
  • Use Result<T,E> for recoverable errors
  • Use ? to propagate errors

Midterm 2 Review

Table of Contents:

Suggested way to use this review material

  1. The material is organized by major topics.
  2. For each topic, there are:
    • links to lecture modules
    • high level overview
    • examples,
    • true/false questions,
    • predict the output questions, and
    • coding challenges.
  3. Try to answer the questions without peaking at the solutions.
  4. The material is not guaranteed to be complete, so you should review the material in the lectures as well as this review material.

Book References:

The lectures modules all start with pre-reading assignments that point to the relevant chapters in The Rust Language Book.

Exam Format:

The exam will be in four parts:

  • Part 1 (10 pts): 5 questions, 2 points each -- select all that are true
  • Part 2 (16 pts): 4 questions, 4 points each -- find the bug in the code and fix it
  • Part 3 (12 pts): 4 questions, 3 points each -- Predict the output and explain why
  • Part 4 (12 pts): 2 questions, 6 points each -- hand-coding problems

Total Points: 50

Suggested time budget for each part:

  • Part 1: (~10 min)
  • Part 2: (~16 min)
  • Part 3: (~12 min)
  • Part 4: (~22 min)

for a total of 60 minutes and then another 15 minutes to check your work.


Preliminaries

The material for midterm 2 assumes that you have gained proficiency with Rust's basic syntax such as main and function definitions, basic data types including tuples and enums as well as defining and passing values as arguments to functions, etc.

For example you should be familiar enough with Rust syntax type in the following program code from memory, without notes.

Basic main function

// Write a main function that prints "Hello, DS210!"

Expected output:

Hello, DS210!

Basic Function Calling

// Create a function called `print_hello` that takes no arguments and 
// doesn't return anything, but prints "Hello, DS210!".

// Write a main function that calls `print_hello`.

Expected output:

Hello, DS210!

Calling Function with Argument

// Create a function called 'print_hello' that takes an integer argument
// and prints, for example for argument `340`, "Hello, DS340!".

// Write a main function that call `print_hello with some integer number.

Output for argument 110:

Hello, DS110!

Challenge yourself with increasingly more complex exercises.

If you struggled with remembering the syntax for those exercises, then consider practicing these basics before moving on to the slightly more advanced syntax below. Practice by writing code into an empty Rust Playground.

You can review the basics of Rust syntax in the A1 Midterm 1 Review.

Review basic and complex data types, e.g. tuples, arrays, Vecs, Strings, enums, etc., methods on these data types like len(), push(), pop(), get(), insert(), remove(), etc.

1. Structs and Methods

Modules

Quick Review

Structs group related data together with named fields, providing type safety and semantic meaning. Unlike tuples, fields have names making code self-documenting.

Key Concepts:

  • Regular structs: struct Person { name: String, age: u32 }
  • Tuple structs: struct Point3D(f64, f64, f64) - named tuples for type safety
  • Field access with . notation
  • Methods with self, &self, or &mut self

Examples

#![allow(unused)]
fn main() {
// Regular struct
struct Rectangle {
    width: u32,
    height: u32,
}

// Implementation block with methods
impl Rectangle {
    // Constructor (associated function)
    fn new(width: u32, height: u32) -> Rectangle {
        Rectangle { width, height }
    }
    
    // Method borrowing immutably
    fn area(&self) -> u32 {
        self.width * self.height
    }
    
    // Method borrowing mutably
    fn scale(&mut self, factor: u32) {
        self.width *= factor;
        self.height *= factor;
    }
}

// Tuple struct for type safety
struct Miles(f64);
struct Kilometers(f64);
// Cannot accidentally mix these types!
}

True/False Questions

  1. T/F: A tuple struct Point3D(i32, i32, i32) can be assigned to a variable of type (i32, i32, i32).

  2. T/F: Methods that take &self can modify the struct's fields.

  3. T/F: You can have multiple impl blocks for the same struct.

  4. T/F: Struct fields are public by default in Rust.

  5. T/F: Associated functions (like constructors) don't take any form of self as a parameter.

Answers
  1. False - Tuple structs create distinct types, even with identical underlying structure
  2. False - &self is immutable; you need &mut self to modify fields
  3. True - Multiple impl blocks are allowed and sometimes useful
  4. False - Struct fields are private by default; use pub to make them public
  5. True - Associated functions are called on the type itself (e.g., Rectangle::new())

Predict the Output (3-4 questions)

Question 1:

struct Counter {
    count: i32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
    
    fn increment(&mut self) {
        self.count += 1;
    }
}

fn main() {
    let mut c = Counter::new();
    c.increment();
    c.increment();
    println!("{}", c.count);
}

Question 2:

struct Point(i32, i32);

fn main() {
    let p = Point(3, 4);
    println!("{} {}", p.0, p.1);
    let Point(x, y) = p;
    println!("{} {}", x, y);
}

Question 3:

struct Temperature {
    celsius: f64,
}

impl Temperature {
    fn new(celsius: f64) -> Self {
        Self { celsius }
    }
    
    fn to_fahrenheit(&self) -> f64 {
        self.celsius * 1.8 + 32.0
    }
}

fn main() {
    let temp = Temperature::new(100.0);
    println!("{:.1}", temp.to_fahrenheit());
}

Question 4:

struct Box3D {
    width: u32,
    height: u32,
    depth: u32,
}

impl Box3D {
    fn volume(&self) -> u32 {
        self.width * self.height * self.depth
    }
}

fn main() {
    let b = Box3D { width: 2, height: 3, depth: 4 };
    let v1 = b.volume();
    let v2 = b.volume();
    println!("{} {}", v1, v2);
}
Answers
  1. Output: 2
  2. Output: 3 4 (newline) 3 4
  3. Output: 212.0
  4. Output: 24 24

Coding Challenges

Challenge 1: Circle struct

Create a Circle struct with a radius field. Implement methods:

  • new(radius: f64) -> Circle - constructor
  • area(&self) -> f64 - returns area (use π ≈ 3.14159)
  • scale(&mut self, factor: f64) - multiplies radius by factor
// your code here
Solution
struct Circle {
    radius: f64,
}

impl Circle {
    fn new(radius: f64) -> Circle {
        Circle { radius }
    }
    
    fn area(&self) -> f64 {
        3.14159 * self.radius * self.radius
    }
    
    fn scale(&mut self, factor: f64) {
        self.radius *= factor;
    }
}

fn main() {
    let mut c = Circle::new(5.0);
    println!("Area: {}", c.area());
    c.scale(2.0);
    println!("New area: {}", c.area());
}

Challenge 2: Student struct with grade calculation

Create a Student struct with fields for name (String) and three exam scores (exam1, exam2, exam3 as u32). Implement:

  • new(name: String, e1: u32, e2: u32, e3: u32) -> Student
  • average(&self) -> f64 - returns average of three exams
  • letter_grade(&self) -> char - returns 'A' (90+), 'B' (80-89), 'C' (70-79), 'D' (60-69), 'F' (<60)
// your code here

Solution
#![allow(unused)]
fn main() {
struct Student {
    name: String,
    exam1: u32,
    exam2: u32,
    exam3: u32,
}

impl Student {
    fn new(name: String, e1: u32, e2: u32, e3: u32) -> Student {
        Student { name, exam1: e1, exam2: e2, exam3: e3 }
    }
    
    fn average(&self) -> f64 {
        (self.exam1 + self.exam2 + self.exam3) as f64 / 3.0
    }
    
    fn letter_grade(&self) -> char {
        let avg = self.average();
        if avg >= 90.0 { 'A' }
        else if avg >= 80.0 { 'B' }
        else if avg >= 70.0 { 'C' }
        else if avg >= 60.0 { 'D' }
        else { 'F' }
    }
}
}

2. Ownership and Borrowing, Strings and Vecs

Modules

Quick Review

Ownership Rules:

  1. Each value has exactly one owner
  2. When owner goes out of scope, value is dropped
  3. Ownership can be moved or borrowed

Borrowing:

  • Immutable references &T: multiple allowed, read-only
  • Mutable references &mut T: only ONE at a time, exclusive access
  • References must always be valid (no dangling)

Key Types:

  • String: heap-allocated, growable, owned
  • Vec<T>: heap-allocated dynamic array, owns elements
  • Both have ptr, length, capacity on stack

Examples

#![allow(unused)]
fn main() {
// Ownership transfer (move)
let s1 = String::from("hello");
let s2 = s1;  // s1 is now invalid
// println!("{}", s1);  // ERROR!

// Borrowing immutably
let s3 = String::from("world");
let len = calculate_length(&s3);  // borrow
println!("{} has length {}", s3, len);  // s3 still valid

// Borrowing mutably
let mut v = vec![1, 2, 3];
add_one(&mut v);  // exclusive mutable borrow

fn calculate_length(s: &String) -> usize {
    s.len()
}

fn add_one(v: &mut Vec<i32>) {
    for item in v.iter_mut() {
        *item += 1;
    }
}
}

True/False Questions

  1. T/F: After let s2 = s1; where s1 is a String, both s1 and s2 are valid.

  2. T/F: You can have multiple immutable references to the same data simultaneously.

  3. T/F: Vec::push() takes &mut self because it modifies the vector.

  4. T/F: When you pass a Vec<i32> to a function without &, the function takes ownership.

  5. T/F: A mutable reference &mut T can coexist with immutable references &T to the same data.

  6. T/F: String::clone() creates a deep copy of the string data on the heap.

Answers
  1. False - Ownership moves; s1 becomes invalid
  2. True - Multiple immutable borrows are allowed
  3. True - Modifying requires mutable reference
  4. True - Without &, ownership is transferred
  5. False - Cannot have &mut and & simultaneously
  6. True - clone() performs a deep copy

Predict the Output

Question 1:

fn main() {
    let mut v = vec![1, 2, 3];
    v.push(4);
    println!("{}", v.len());
}

Question 2:

fn process(s: String) -> usize {
    s.len()
}

fn main() {
    let text = String::from("hello");
    let len = process(text);
    println!("{}", len);
    //println!("{}", text);  // Would this compile?
}

Question 3:

fn main() {
    let mut s = String::from("hello");
    let r1 = &s;
    let r2 = &s;
    println!("{} {}", r1, r2);
    
    let r3 = &mut s;
    r3.push_str(" world");
    println!("{}", r3);
}

Question 4:

fn main() {
    let v1 = vec![1, 2, 3];
    let v2 = v1.clone();
    println!("{} {}", v1.len(), v2.len());
}
Answers
  1. Output: 4
  2. Output: 5 (the second println with text would cause compile error - moved)
  3. Output: hello hello (newline) hello world
  4. Output: 3 3

Coding Challenges

Challenge 1: Fix the borrowing errors

// Fix this code so it compiles
fn main() {
    let mut numbers = vec![1, 2, 3];
    let sum = calculate_sum(numbers);
    double_all(numbers);
    println!("Sum: {}, Doubled: {:?}", sum, numbers);
}

fn calculate_sum(v: Vec<i32>) -> i32 {
    v.iter().sum()
}

fn double_all(v: Vec<i32>) {
    for x in v.iter() {
        x *= 2;
    }
}
Solution
fn main() {
    let mut numbers = vec![1, 2, 3];
    let sum = calculate_sum(&numbers);  // borrow instead of move
    double_all(&mut numbers);  // mutable borrow
    println!("Sum: {}, Doubled: {:?}", sum, numbers);
}

fn calculate_sum(v: &Vec<i32>) -> i32 {  // take reference
    v.iter().sum()
}

fn double_all(v: &mut Vec<i32>) {  // take mutable reference
    for x in v.iter_mut() {  // mutable iterator
        *x *= 2;  // dereference to modify
    }
}

Challenge 2: String manipulation

Write a function reverse_words(s: &str) -> String that takes a string slice and returns a new String with words in reverse order. For example, "hello world rust" becomes "rust world hello".

hint #1

The string method .split_whitespace() might be very useful.

hint #2

Collect the splitted string into a Vec<&str>.

// Your code here


Solution
fn reverse_words(s: &str) -> String {
    let words: Vec<&str> = s.split_whitespace().collect();
    let mut result = String::new();
    
    for (i, word) in words.iter().rev().enumerate() {
        if i > 0 {
            result.push(' ');
        }
        result.push_str(word);
    }
    result
}

fn main() {
    let original = "hello world rust";
    let reversed = reverse_words(original);
    println!("{}", reversed);  // "rust world hello"
}

3. Modules, Crates and Projects

Modules

Quick Review

Modules organize code within a crate:

  • mod keyword defines modules
  • pub makes items public
  • use brings items into scope
  • File structure: mod.rs or module_name.rs

Crates and Projects:

  • Binary crate: has main(), produces executable
  • Library crate: has lib.rs, provides functionality
  • Cargo.toml: manifest with dependencies
  • cargo build, cargo test, cargo run

Examples

// lib.rs
pub mod shapes {
    pub struct Circle {
        pub radius: f64,
    }
    
    impl Circle {
        pub fn new(radius: f64) -> Circle {
            Circle { radius }
        }
        
        pub fn area(&self) -> f64 {
            std::f64::consts::PI * self.radius * self.radius
        }
    }
}

// main.rs
use crate::shapes::Circle;

fn main() {
    let c = Circle::new(5.0);
    println!("Area: {}", c.area());
}

True/False Questions

  1. T/F: By default, all items (functions, structs, etc.) in a module are public.

  2. T/F: A Rust package can have both lib.rs and main.rs.

  3. T/F: The use statement imports items at compile time and has no runtime cost.

  4. T/F: Tests are typically placed in a tests module marked with #[cfg(test)].

  5. T/F: External dependencies are listed in Cargo.toml under the [dependencies] section.

Answers
  1. False - Items are private by default; need pub for public access
  2. True - This creates both a library and binary target
  3. True - Module system is resolved at compile time
  4. True - This is the standard pattern for unit tests
  5. True - Dependencies are declared in Cargo.toml

Predict the Output

Question 1:

mod math {
    pub fn add(a: i32, b: i32) -> i32 {
        a + b
    }
    
    fn private_func() {
        println!("Private");
    }
}

fn main() {
    println!("{}", math::add(3, 4));
    // math::private_func();  // What happens?
}

Question 2:

mod outer {
    pub mod inner {
        pub fn greet() {
            println!("Hello from inner");
        }
    }
}

use outer::inner;

fn main() {
    inner::greet();
}
Answers
  1. Output: 7 (private_func() call would cause compile error - not public)
  2. Output: Hello from inner

Coding Challenge

Challenge: Create a temperature conversion module

Create a module called temperature with:

  • Function celsius_to_fahrenheit(c: f64) -> f64
    • fahrenheit = celsius * 1.8 + 32.0
  • Function fahrenheit_to_celsius(f: f64) -> f64
    • celsius = (fahrenheit - 32.0) / 1.8
  • Function celsius_to_kelvin(c: f64) -> f64
    • kelvin = celsius + 273.15

All functions should be public.

In a main function, use the module to convert 100°C to Fahrenheit, 32°F to Celsius, and 0°C to Kelvin and print the results.

// your code here

Solution
pub mod temperature {
    pub fn celsius_to_fahrenheit(c: f64) -> f64 {
        c * 1.8 + 32.0
    }
    
    pub fn fahrenheit_to_celsius(f: f64) -> f64 {
        (f - 32.0) / 1.8
    }
    
    pub fn celsius_to_kelvin(c: f64) -> f64 {
        c + 273.15
    }
}

fn main() {
    use temperature::*;
    
    println!("100°C = {}°F", celsius_to_fahrenheit(100.0));
    println!("32°F = {}°C", fahrenheit_to_celsius(32.0));
    println!("0°C = {}K", celsius_to_kelvin(0.0));
}

4. Tests and Error Handling

Modules

Quick Review

Testing in Rust:

  • Unit tests: in same file with #[cfg(test)] module
  • #[test] attribute marks test functions
  • assert!, assert_eq!, assert_ne! macros
  • cargo test runs all tests
  • #[should_panic] for testing panics
  • Result<T, E> return type for tests that can fail

Error Handling in Rust:

See Error Handling for more details.

  • panic! for unrecoverable errors
  • Result<T,E> for recoverable errors
  • ? to propagate errors

Examples

#![allow(unused)]
fn main() {
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
    }
    
    #[test]
    fn test_add_negative() {
        assert_eq!(add(-1, 1), 0);
    }
    
    #[test]
    #[should_panic]
    fn test_overflow() {
        let _x = i32::MAX + 1;  // Should panic in debug mode
    }
}
}

True/False Questions

  1. T/F: Test functions must return () or Result<T, E>.

  2. T/F: The assert_eq! macro checks if two values are equal using the == operator.

  3. T/F: Tests marked with #[should_panic] pass if they panic.

  4. T/F: Private functions cannot be tested in unit tests.

  5. T/F: cargo test compiles the code in release mode by default.

Answers
  1. True - Tests can return these types
  2. True - assert_eq!(a, b) checks a == b
  3. True - #[should_panic] expects the test to panic
  4. False - Unit tests in the same module can access private functions
  5. False - cargo test uses debug mode; use --release for release mode

Predict the Output

Question 1: What would the result be for cargo test on this code?

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    #[test]
    fn test_pass() {
        assert_eq!(2 + 2, 4);
    }
    
    #[test]
    fn test_fail() {
        assert_eq!(2 + 2, 5);
    }
}
}

Question 2: What would the result be for cargo test on this code?

fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err(String::from("Division by zero"))
    } else {
        Ok(a / b)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_divide_ok() -> Result<(), String> {
        let result = divide(10, 2);
        assert_eq!(result, Ok(5));
        Ok(())
    }

    #[test]
    fn test_divide_err() {
        let result = divide(10, 0);
        assert_eq!(result, Err(String::from("Division by zero")));
    }
}
Answers
  1. Test passes (10/2 = 5, assertion succeeds, returns Ok(()))
  2. Test passes (10/0 = error, assertion succeeds, returns Err(String::from("Division by zero")))

Coding Challenge

Challenge: Write tests for a max function

Write a function max_of_three(tup: (i32, i32, i32)) -> i32 that returns the maximum of three integers given in a tuple. Then write at least 3 test cases.

// your code here
Solution
#![allow(unused)]
fn main() {
pub fn max_of_three(tup: (i32, i32, i32)) -> i32 {
    if tup.0 >= tup.1 && tup.0 >= tup.2 {
        tup.0
    } else if tup.1 >= tup.2 {
        tup.1
    } else {
        tup.2
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_first_is_max() {
        assert_eq!(max_of_three((5, 2, 3)), 5);
    }
    
    #[test]
    fn test_second_is_max() {
        assert_eq!(max_of_three((1, 9, 4)), 9);
    }
    
    #[test]
    fn test_third_is_max() {
        assert_eq!(max_of_three((2, 3, 10)), 10);
    }
    
    #[test]
    fn test_all_equal() {
        assert_eq!(max_of_three((7, 7, 7)), 7);
    }
}
}

5. Generics and Traits

back to top

Modules

Quick Review

Generics enable code reuse across different types:

  • Type parameters: <T>, <T, U>, etc.
  • Monomorphization: compiler generates specialized versions
  • Zero runtime cost
  • Trait bounds constrain generic types: <T: Display>

Traits define shared behavior:

  • Like interfaces in other languages
  • impl Trait for Type syntax
  • Standard traits: Debug, Clone, PartialEq, PartialOrd, Display, etc.
  • Trait bounds: fn foo<T: Trait>(x: T)
  • Trait bounds can be combined with multiple traits: fn foo<T: Trait1 + Trait2>(x: T)

Examples

Generic function:

#![allow(unused)]
fn main() {
// Generic function
fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}
}

Generic struct:

#![allow(unused)]
fn main() {
// Generic struct
struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn new(x: T, y: T) -> Self {
        Point { x, y }
    }
}
}

Trait definition:

#![allow(unused)]
fn main() {
// Trait definition
trait Summary {
    fn summarize(&self) -> String;
}

// Trait implementation
struct Article {
    title: String,
    author: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{} by {}", self.title, self.author)
    }
}
}

True/False Questions

  1. T/F: Generics in Rust have runtime overhead because type checking happens at runtime.

  2. T/F: A struct Point<T> where both x and y are type T means x and y must be the same type.

  3. T/F: Option<T> and Result<T, E> are examples of generic enums in the standard library.

  4. T/F: Trait bounds like <T: Display + Clone> require T to implement both traits.

  5. T/F: The derive attribute can automatically implement certain traits like Debug and Clone.

Answers
  1. False - Monomorphization happens at compile time; zero runtime cost
  2. True - Both fields share the same type parameter
  3. True - Both are generic enums
  4. True - + combines multiple trait bounds
  5. True - #[derive(Debug, Clone)] auto-implements these traits

Predict the Output

Question 1:

fn print_type<T: std::fmt::Display>(x: T) {
    println!("{}", x);
}

fn main() {
    print_type(42);
    print_type("hello");
    print_type(3.14);
}

Question 2:

fn swap<T>(a: T, b: T) -> (T, T) {
    (b, a)
}

fn main() {
    let (x, y) = swap(1, 2);
    println!("{} {}", x, y);
}

Question 3:

struct Container<T> {
    value: T,
}

impl<T: std::fmt::Display> Container<T> {
    fn show(&self) {
        println!("Value: {}", self.value);
    }
}

fn main() {
    let c = Container { value: 42 };
    c.show();
}

Question 4:

trait Double {
    fn double(&self) -> Self;
}

impl Double for i32 {
    fn double(&self) -> Self {
        self * 2
    }
}

fn main() {
    let x = 5;
    println!("{}", x.double());
}
Answers
  1. Output: 42 (newline) hello (newline) 3.14
  2. Output: 2 1
  3. Output: Value: 42
  4. Output: 10

Coding Challenges

Challenge 1: Generic pair

Create a generic struct Pair<T> that holds two values of the same type. Implement:

  • new(first: T, second: T) -> Self
  • swap(&mut self) - swaps the two values
  • larger(&self) -> &T - returns reference to the larger value (requires T: PartialOrd)
// your code here

Solution
struct Pair<T> {
    first: T,
    second: T,
}

impl<T> Pair<T> {
    fn new(first: T, second: T) -> Self {
        Pair { first, second }
    }
    
    fn swap(&mut self) {
        std::mem::swap(&mut self.first, &mut self.second);
    }
}

impl<T: PartialOrd> Pair<T> {
    fn larger(&self) -> &T {
        if self.first > self.second {
            &self.first
        } else {
            &self.second
        }
    }
}

fn main() {
    let mut p = Pair::new(5, 10);
    println!("{}", p.larger());  // 10
    p.swap();
    println!("{}", p.larger());  // 10
}

Challenge 2: Trait for area calculation

Define a trait Area with a method area(&self) -> f64. Implement it for Circle (radius) and Rectangle (width, height).

// your code here

Solution
trait Area {
    fn area(&self) -> f64;
}

struct Circle {
    radius: f64,
}

struct Rectangle {
    width: f64,
    height: f64,
}

impl Area for Circle {
    fn area(&self) -> f64 {
        3.14159 * self.radius * self.radius
    }
}

impl Area for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
}

fn main() {
    let c = Circle { radius: 5.0 };
    let r = Rectangle { width: 4.0, height: 6.0 };
    println!("Circle area: {}", c.area());
    println!("Rectangle area: {}", r.area());
}

6. Lifetimes

back to top

Modules

Quick Review

Lifetimes ensure references are valid:

  • Prevent dangling references at compile time
  • Notation: 'a, 'b, etc.
  • Most lifetimes are inferred
  • Explicit annotations needed when ambiguous
  • Lifetime elision rules reduce annotations needed

Key Concepts:

  • Every reference has a lifetime
  • Function signatures sometimes need lifetime annotations
  • Structs with references need lifetime parameters
  • 'static lifetime lasts entire program

Examples

#![allow(unused)]
fn main() {
// Explicit lifetime annotations
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

// Struct with lifetime
struct Book<'a> {
    title: &'a str,
    author: &'a str,
}

// Multiple lifetimes
fn first_word<'a, 'b>(s: &'a str, _other: &'b str) -> &'a str {
    s.split_whitespace().next().unwrap_or("")
}

// Static lifetime
let s: &'static str = "This string lives forever";
}

True/False Questions

  1. T/F: All references in Rust have lifetimes, but most are inferred by the compiler.

  2. T/F: The lifetime 'static means the reference can live for the entire program duration.

  3. T/F: Lifetime parameters in function signatures change the actual lifetimes of variables.

  4. T/F: A struct that contains references must have lifetime parameters.

  5. T/F: The notation <'a> in a function signature creates a lifetime; it doesn't declare a relationship.

Answers
  1. True - Lifetime inference works in most cases
  2. True - 'static references live for the entire program
  3. False - Lifetime annotations describe relationships, don't change actual lifetimes
  4. True - Structs with references need lifetime parameters
  5. False - <'a> declares a lifetime parameter; annotations describe relationships

Predict the Output

Question 1:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let s1 = String::from("short");
    let s2 = String::from("longer");
    let result = longest(&s1, &s2);
    println!("{}", result);
}

Question 2:

fn first<'a>(x: &'a str, _y: &str) -> &'a str {
    x
}

fn main() {
    let s1 = "hello";
    let s2 = "world";
    println!("{}", first(s1, s2));
}
Answers
  1. Output: longer
  2. Output: hello

Coding Challenge

Challenge: Implement a function with lifetimes

Write a function get_first_sentence<'a>(text: &'a str) -> &'a str that returns the first sentence (up to the first period, or the whole string if no period exists).

// your code here
Solution
fn get_first_sentence<'a>(text: &'a str) -> &'a str {
    match text.find('.') {
        Some(pos) => &text[..=pos],
        None => text,
    }
}

fn main() {
    let text = "Hello world. This is Rust.";
    let first = get_first_sentence(text);
    println!("{}", first);  // "Hello world."
    
    let text2 = "No period here";
    let first2 = get_first_sentence(text2);
    println!("{}", first2);  // "No period here"
}

7. Closures and Iterators

back to top

Modules

Quick Review

Closures are anonymous functions that can capture environment:

  • Syntax: |param| expression or |param| { body }
  • Capture variables from surrounding scope
  • Enable lazy evaluation
  • Used with iterators and functional programming
  • A predicate is a closure (or function) that returns a boolean value.

Iterators:

  • Trait-based: Iterator trait with next() method
  • Lazy evaluation - only compute when consumed
  • Common methods: map, filter, fold, collect
  • for loops use IntoIterator
  • Three forms: iter(), iter_mut(), into_iter()

Iterator Creation Methods

  • iter() -> Create an iterator from a collection that yields immutable references (&T)to elements
  • iter_mut() -> Create an iterator that yields mutable references (&mut T) to elements
  • into_iter() -> Consumes the collection and yields owned values (T) transferring ownership to the iterator

Iterator Methods and Adapters

From Iterator Methods and Adapters module:

Pay special attention to what the output is.

  • into_iter() -> Create an iterator that consumes the collection
  • next() -> Get the next element of an iterator (None if there isn't one)
  • enumerate() -> Create an iterator that yields the index and the element (added)
  • collect() -> Put iterator elements in collection
  • take(N) -> take first N elements of an iterator and turn them into an iterator
  • cycle() -> Turn a finite iterator into an infinite one that repeats itself
  • for_each(||, ) -> Apply a closure to each element in the iterator
  • filter(||, ) -> Create new iterator from old one for elements where closure is true
  • map(||, ) -> Create new iterator by applying closure to input iterator
  • filter_map(||, ) -> Creates an iterator that both filters and maps (added)
  • any(||, ) -> Return true if closure is true for any element of the iterator
  • fold(a, |a, |, ) -> Initialize expression to a, execute closure on iterator and accumulate into a
  • reduce(|x, y|, ) -> Similar to fold but the initial value is the first element in the iterator
  • zip(iterator) -> Zip two iterators together to turn them into pairs

Other useful methods:

  • sum() -> Sum the elements of an iterator
  • product() -> Product the elements of an iterator
  • min() -> Minimum element of an iterator
  • max() -> Maximum element of an iterator
  • count() -> Count the number of elements in an iterator
  • nth(N) -> Get the Nth element of an iterator
  • skip(N) -> Skip the first N elements of an iterator
  • skip_while(||, ) -> Skip elements while the closure is true

If the method returns an iterator, you have to do something with the iterator.

See Rust provided methods for the complete list.

Examples

#![allow(unused)]
fn main() {
// Closure basics
let add = |x, y| x + y;
let result = add(3, 4);  // 7

// Capturing environment
let multiplier = 3;
let multiply = |x| x * multiplier;
println!("{}", multiply(5));  // 15

// Iterators
let numbers = vec![1, 2, 3, 4, 5];

// map and filter (lazy)
let doubled: Vec<i32> = numbers.iter()
    .map(|x| x * 2)
    .filter(|x| x > &5)
    .copied()
    .collect();

// fold
let sum: i32 = numbers.iter().fold(0, |acc, x| acc + x);

// Lazy evaluation
let result = Some(5).unwrap_or_else(|| expensive_function());
}

True/False Questions

  1. T/F: Closures can capture variables from their environment, but regular functions cannot.

  2. T/F: Iterator methods like map and filter are eagerly evaluated.

  3. T/F: The collect() method consumes an iterator and produces a collection.

  4. T/F: for x in vec moves ownership, while for x in &vec borrows.

  5. T/F: Closures can have explicit type annotations like |x: i32| -> i32 { x + 1 }.

  6. T/F: The fold method requires an initial accumulator value.

Answers
  1. True - Closures capture environment; functions don't
  2. False - They're lazy; evaluated only when consumed
  3. True - collect() is a consumer that builds a collection
  4. True - Without &, ownership moves; with &, it borrows
  5. True - Type annotations are optional but allowed
  6. True - fold takes initial value and closure

Predict the Output

Question 1:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];
    let sum: i32 = numbers.iter().map(|x| x * 2).sum();
    println!("{}", sum);
}

Question 2:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];
    let result: Vec<i32> = numbers.iter()
        .filter(|x| *x % 2 == 0)
        .map(|x| x * x)
        .collect();
    println!("{:?}", result);
}

Question 3:

fn main() {
    let factor = 3;
    let multiply = |x| x * factor;
    println!("{}", multiply(7));
}

Question 4:

fn main() {
    let numbers = vec![1, 2, 3];
    let result = numbers.iter()
        .fold(0, |acc, x| acc + x);
    println!("{}", result);
}
Answers
  1. Output: 30 (sum of 2, 4, 6, 8, 10)
  2. Output: [4, 16] (squares of even numbers: 2² and 4²)
  3. Output: 21
  4. Output: 6 (1 + 2 + 3)

Coding Challenges

Challenge 1: Custom filter

Write a function count_if<F>(vec: &Vec<i32>, predicate: F) -> usize where F is a closure that takes &i32 and returns bool. The function returns the count of elements satisfying the predicate.

// your code here

Solution
fn count_if<F>(vec: &Vec<i32>, predicate: F) -> usize 
where
    F: Fn(&i32) -> bool
{
    let mut count = 0;
    for item in vec {
        if predicate(item) {
            count += 1;
        }
    }
    count
}

// Alternative using iterators:
fn count_if_iter<F>(vec: &Vec<i32>, predicate: F) -> usize 
where
    F: Fn(&i32) -> bool
{
    vec.iter().filter(|x| predicate(x)).count()
}

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6];
    let evens = count_if(&numbers, |x| x % 2 == 0);
    println!("{}", evens);  // 3
}

Challenge 2: Iterator chain

Given a Vec<i32>, create an iterator chain that:

  1. Filters for numbers > 5
  2. Squares each number
  3. Sums the results
// your code here
Solution
fn process_numbers(numbers: &Vec<i32>) -> i32 {
    numbers.iter()
        .filter(|&&x| x > 5)
        .map(|x| x * x)
        .sum()
}

fn main() {
    let numbers = vec![1, 3, 6, 8, 10, 2];
    let result = process_numbers(&numbers);
    println!("{}", result);  // 6² + 8² + 10² = 36 + 64 + 100 = 200
}

Challenge 3: Custom map

Implement a function apply_to_all<F>(vec: &mut Vec<i32>, f: F) that applies a closure to each element, modifying the vector in place.

// your code here
Solution
fn apply_to_all<F>(vec: &mut Vec<i32>, f: F)
where
    F: Fn(i32) -> i32
{
    for item in vec.iter_mut() {
        *item = f(*item);
    }
}

fn main() {
    let mut numbers = vec![1, 2, 3, 4, 5];
    apply_to_all(&mut numbers, |x| x * 2);
    println!("{:?}", numbers);  // [2, 4, 6, 8, 10]
}

Final Tips for the Exam

back to top

  1. Ownership & Borrowing: Remember the rules - one owner, multiple & OR one &mut
  2. Lifetimes: Think about what references your function returns and where they come from
  3. Generics: Use trait bounds when you need specific capabilities (PartialOrd, Display, etc.)
  4. Iterators: They're lazy - need collect() or sum() to actually compute
  5. Tests: Write tests that cover normal cases, edge cases, and error cases
  6. Read error messages: Rust's compiler errors are very helpful - read them carefully!

Good luck on your midterm!

Complexity Analysis: Understanding Algorithm Performance

About This Module

This module covers algorithmic complexity analysis with a focus on how memory is managed in Rust vectors. You'll learn to analyze time and space complexity of operations and understand the performance characteristics of different data structures and algorithms.

Prework

Prework Reading

Please read the following:

Pre-lecture Reflections

  1. What is the difference between time complexity and space complexity?
  2. Why is amortized analysis important for dynamic data structures?
  3. How does Rust's memory management affect algorithm complexity?

Learning Objectives

By the end of this module, you will be able to:

  • Analyze time and space complexity using Big O notation
  • Understand amortized analysis for vector operations
  • Compare complexity of some algorithms and data structures

Complexity Analysis (e.g. memory management in vectors)

Let's dive deeper into algorithmic complexity analysis by considering how memory is manged in Rust Vecs.

Previously: vectors Vec<T>

  • Dynamic-length array/list
  • Allowed operations:
    • access item at specific location
    • push: add something to the end
    • pop: remove an element from the end

Other languages:

  • Python: list
  • C++: vector<T>
  • Java: ArrayList<T> / Vector<T>

Implementation details

Challenges

  • Size changes: allocate on the heap?
  • What to do if a new element added?
    • Allocate a larger array and copy everything?
    • Linked list?

Solution

  • Allocate more space than needed!
  • When out of space:
    • Increase storage size by, say, 100%
    • Copy everything

Under the hood

Variable of type Vec<T> contains:

  • pointer to allocated memory
  • size: the current number of items
  • capacity: how many items could currently fit

Important: size capacity

Example (adding elements to a vector)

Method capacity() reports the current storage size

#![allow(unused)]
fn main() {
// print out the current size and capacity

// define a generic function `info` that takes one argument, `vector`,
// of generic `Vec` type and prints it's length and capacity
fn info<T>(vector:&Vec<T>) {  
    println!("length = {}, capacity = {}",vector.len(),vector.capacity());
}

// Let's keep adding elements to Vec and see what happens to capacity

let mut v = Vec::with_capacity(7); // instantiate empty Vec with capacity 7
let mut capacity = v.capacity();
info(&v);

for i in 1..=1000 {
    v.push(i);  // push the index onto the Vec

    // if capacity changed, print the length and new capacity
    if v.capacity() != capacity {
        capacity = v.capacity();
        info(&v);
    }
};
info(&v);
}

Example (decreasing the size of a vector)

#![allow(unused)]
fn main() {
fn info<T>(vector:&Vec<T>) {  
    println!("length = {}, capacity = {}",vector.len(),vector.capacity());
}
// what happens when we decrease the Vec by popping off values?

let mut v = vec![10; 1000];

info(&v);

// `while let` is a control flow construct that will continue
// as long as pattern `Some(_) = v.pop()` matches.
// If there is a value to pop, v.pop() returns Option enum, which
//    is either Some(Vec<T>)
//    otherwise it will return None and the loop will end.
while let Some(_) = v.pop() {}

info(&v);
}

Questions

  • What is happening as we push elements?
  • When does it happen?
  • How much is it changing by?
  • What happens when we pop? Is capacity changing?

Example -- Shrink to Fit

  • We can shrink the size of a vector manually
#![allow(unused)]
fn main() {
fn info<T>(vector:&Vec<T>) {  
    println!("length = {}, capacity = {}",vector.len(),vector.capacity());
}

let mut v = vec![10; 1000];
while let Some(_) = v.pop() {}

info(&v);

for i in 1..=13 {
    v.push(i);
}

info(&v);

// shrink the size manually
v.shrink_to_fit();

info(&v);
}

Note: size and capacity not guaranteed to be the same

Example -- Creating a vector with specific capacity

Avoid reallocation if you know how many items to expect.

#![allow(unused)]
fn main() {
fn info<T>(vector:&Vec<T>) {  
    println!("length = {}, capacity = {}",vector.len(),vector.capacity());
}

// creating vector with specific capacity
let mut v2 : Vec<i32> = Vec::with_capacity(1234);
info(&v2);
}

.get() versus .pop()


  • .get() does not remove from the vector, but you must specify the index
  • .pop() removes the last element from the vector
  • both return an Option<T>
    • .get() returns Some(T) if the index is valid, None otherwise
    • .pop() returns Some(T) if the vector is not empty, None otherwise

#![allow(unused)]
fn main() {
let mut v = Vec::new();
for i in 1..=13 {
    v.push(i);
}
println!("{:?}", v);

// Does not remove from the vector, but you must specify the index
println!("{:?} {:?}", v.get(v.len()-1), v);

// But this one does, and removes the last element
println!("{:?} {:?}", v.pop(), v);
}

Other useful functions

  • append Add vector at the end of another vec.append(&mut vec2)
  • clear Remove all elements from the vector vec.clear()
  • dedup Remove consecutive identical elements vec.dedup(), most useful when combined with sort
  • drain Remove a slice from the vector vec.drain(2..4) -- removes and shifts -- expensive
  • remove Remove an element from the vector vec.remove(2) -- removes and shifts -- expensive
  • sort Sort the elements of a mutable vector vec.sort()
  • Complete list at https://doc.rust-lang.org/std/vec/struct.Vec.html

Sketch of analysis: Amortization

  • Inserting an element not constant time (i.e. ) under all conditions

However

  • Assumption: allocating memory size takes either or time

  • Slow operations: current_size time

  • Fast operations: time

What is the average time?

  • Consider an initial 100-capacity Vec.
  • Continually add element
  • First 100 added elements:
  • For 101st element:

So on average for the first 101 elements:

  • On average: amortized time
  • Fast operations pay for slow operations

Dominant terms and constants in notation

We ignore constants and all but dominant terms as :

Order of n plots

Which is worse? or ?

Shrinking?

  • Can be implemented this way too
  • Example: shrink by 50% if less than 25% used
  • Most implementations don't shrink automatically

Notations

-> Algorithm takes no more than n time (worst case scenario)

-> Algorithm takes at least n time (best case scenario)

-> Average/Typical running time for the algorithm (average case scenario)

Digression (Sorting Vectors in Rust)

Sorting on on integer vectors works fine.

#![allow(unused)]
fn main() {
// This works great
let mut a = vec![1, 4, 3, 6, 8, 12, 5];
a.sort();
println!("{:?}", a);
}

But sorting on floating point vectors does not work directly.

#![allow(unused)]
fn main() {
// But the compiler does not like this one, since sort depends on total order
let mut a = vec![1.0, 4.0, 3.0, 6.0, 8.0, 12.0, 5.0];

a.sort();
println!("{:?}", a);
}

Why?

Because floats in Rust support special values like NaN and inf which don't obey normal sorting rules.

More technically, floats in Rust don't implement the Ord trait, only the PartialOrd trait.

The Ord trait is a total order, which means that for any two numbers and , either , , or .

The PartialOrd trait is a partial order, which means that for any two numbers and , either , , , or the comparison is not well defined.

Example -- inf

#![allow(unused)]
fn main() {
let mut x: f64 = 6.8;
println!("{}", x/0.0);
}

We can push inf onto a Vec.

#![allow(unused)]
fn main() {
let mut a = vec![1.0, 4.0, 3.0, 6.0, 8.0, 12.0, 5.0];
let mut x: f64 = 6.8;
a.push(x/0.0);
a.push(std::f64::INFINITY);
println!("{:?}", a);
}

Example -- NaN

#![allow(unused)]
fn main() {
let mut x: f64 = -1.0;
println!("{}", x.sqrt());
}

Similarly, we can push NaN onto a Vec.


#![allow(unused)]
fn main() {
let mut a = vec![1.0, 4.0, 3.0, 6.0, 8.0, 12.0, 5.0];
let mut x: f64 = -1.0;
a.push(x.sqrt());
a.push(std::f64::NAN);
println!("{:?}", a);
}

Example -- Sorting with sort_by()

We can work around this by:

  • not relying on the Rust implementation of sort(), but rather
  • defining our own comparison function using partial_cmp, which is a required method for the PartialOrd trait, and
  • using the .sort_by() function.

#![allow(unused)]
fn main() {
// This is ok since we don't use sort, sort_by depends on the function you pass in to compute order
let mut a: Vec<f32> = vec![1.0, 4.0, 3.0, 6.0, 8.0, 12.0, 5.0];
// a.sort();
a.sort_by(|x, y| x.partial_cmp(y).unwrap());
println!("{:?}", a);
}

where partial_cmp is a method that returns for types that implement the PartialOrd trait:

  • Some(std::cmp::Ordering::Equal) when ,
  • Some(std::cmp::Ordering::Less) when
  • Some(std::cmp::Ordering::Greater) when
  • None when the comparison is not well defined, e.g x ? NaN

Example -- Can even handle inf

#![allow(unused)]
fn main() {
let mut a: Vec<f32> = vec![1.0, 4.0, 3.0, std::f32::INFINITY, 6.0, 8.0, 12.0, 5.0];

println!("{:?}", a);

a.sort_by(|x, y| x.partial_cmp(y).unwrap());
println!("{:?}", a);
}
#![allow(unused)]
fn main() {
let mut a: Vec<f32> = vec![1.0, 4.0, 3.0, std::f32::INFINITY, 6.0, std::f32::NEG_INFINITY, 8.0, 12.0, 5.0];

println!("{:?}", a);

a.sort_by(|x, y| x.partial_cmp(y).unwrap());
println!("{:?}", a);
}
#![allow(unused)]
fn main() {
let mut a: Vec<f32> = vec![1.0, 4.0, 3.0, std::f32::INFINITY, 6.0, 8.0, std::f32::INFINITY, 12.0, 5.0];

println!("{:?}", a);

a.sort_by(|x, y| x.partial_cmp(y).unwrap());
println!("{:?}", a);
}

Infinity goes to the end:

Infinity has a well-defined ordering in IEEE 754 floating-point arithmetic:

  • Positive infinity is explicitly defined as greater than all finite numbers
  • inf.partial_cmp(finite_number) returns Some(Ordering::Greater)
  • This is a valid comparison, so the unwrap_or fallback is never used
  • Result: infinity naturally sorts to the end

Just be careful!

It will panic if you try to unwrap a special value like NaN.


#![allow(unused)]
fn main() {
// When partial order is not well defined in the inputs you get a panic
let mut a = vec![1.0, 4.0, 3.0, 6.0, 8.0, 12.0, 5.0];

let mut x: f32 = -1.0;
x = x.sqrt();
a.push(x);

println!("{:?}", a);
a.sort_by(|x, y| x.partial_cmp(y).unwrap());
println!("{:?}", a);
}

Workaround

Return a default value when the comparison is not well defined.


#![allow(unused)]
fn main() {
// When partial order is not well defined in the inputs you get a panic
let mut a = vec![1.0, 4.0, 3.0, 6.0, 8.0, 12.0, 5.0];

// push a NaN (sqrt(-1.0))
let mut x: f32 = -1.0;
x = x.sqrt();
a.push(x);

// push an inf (10.0/0.0)
a.push(10.0/0.0);

println!("{:?}", a);

a.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Less));
println!("{:?}", a);
}

NaN goes to the beginning:

The .unwrap_or(std::cmp::Ordering::Less) says: "if the comparison is undefined (returns None), pretend that x is less than y".

So when NaN is compared with any other value:

  • NaN.partial_cmp(other)None
  • Falls back to Ordering::Less
  • This means NaN is always treated as "smaller than" everything else
  • Result: NaN gets sorted to the beginning

In-Class Piazza Poll

Select all that are true:

  1. The push() operation on a Rust Vec<T> always has O(1) time complexity in the worst case.
  2. When a Vec<T> runs out of capacity and needs to grow, it typically doubles its capacity, resulting in O(n) time for that specific push operation where n is the current size.
  3. The pop() operation on a Rust Vec<T> has O(1) time complexity and automatically shrinks the vector's capacity when the size drops below 25% of capacity.
  4. The amortized time complexity of push() operations on a Vec<T> is O(1), meaning that averaged over many operations, each push takes constant time.
  5. In Big O notation, O(n² + 100n + 50) simplifies to O(n²) because we ignore constants and non-dominant terms as n approaches infinity.
Solutions

Question 1: FALSE

  • The push() operation is NOT always O(1) in the worst case
  • When the vector needs to grow (current size equals capacity), it must:
    • Allocate new memory (typically double the current capacity)
    • Copy all existing elements to the new location
    • This copying takes O(n) time where n is the current size
  • Only when there's available capacity is push O(1)

Question 2: TRUE

  • When a Vec runs out of capacity, it does typically double its capacity
  • This resize operation requires allocating new memory and copying all n existing elements
  • Therefore, that specific push operation takes O(n) time
  • This is why we see capacity jumps in the examples (7 → 14 → 28 → 56, etc.)

Question 3: FALSE

  • The pop() operation does have O(1) time complexity (this part is true)
  • However, pop() does NOT automatically shrink the vector's capacity
  • As shown in the examples, popping all elements leaves the capacity unchanged
  • You must explicitly call shrink_to_fit() to reduce capacity
  • Most implementations don't shrink automatically to avoid repeated allocate/deallocate cycles

Question 4: TRUE

  • Amortized analysis averages the cost over a sequence of operations
  • While occasional push operations take O(n) time (during reallocation), most take O(1)
  • The expensive operations are infrequent enough that on average, each push is O(1)
  • Example: For 101 pushes starting with capacity 100: (100 × 1 + 1 × 100) / 101 ≈ 2, which is still O(1)

Question 5: TRUE

  • In Big O notation, we focus on the dominant term as n approaches infinity
  • n² grows much faster than n or constant terms
  • Therefore: O(n² + 100n + 50) → O(n²)
  • We drop the constants (50), the coefficient (100), and the non-dominant term (n)

Hash Maps and Hash Sets: Key-Value Storage

About This Module

This module introduces HashMap and HashSet collections in Rust, which provide efficient key-value storage and set operations. You'll learn how to use these collections for fast lookups, counting, and deduplication tasks common in data processing.

Prework

Prework Reading

Please read the following sections from The Rust Programming Language Book:

Pre-lecture Reflections -- Part 1

  1. Why must a HashMap take ownership of values like String, and what memory safety problems does this solve?
  2. How does the entry API help you safely update a value?
  3. The get method returns an Option. Why is this a crucial design choice, and what common bugs does it prevent?
  4. When would you choose to use a HashMap over a Vec, and what is the main performance trade-off for looking up data?

Pre-lecture Reflections -- Part 2

  1. How do hash maps achieve O(1) average-case lookup time?
  2. What are the tradeoffs between HashMap and BTreeMap in Rust?
  3. When would you use a HashSet vs a Vec for storing unique values?
  4. What makes a good hash function?

Learning Objectives

By the end of this module, you will be able to:

  • Create and manipulate HashMap and HashSet collections
  • Understand hash table operations and their complexity
  • Choose appropriate collection types for different use cases
  • Handle hash collisions and understand their implications

Hash maps

Collection HashMap<K,V>

Goal: a mapping from elements of K to elements of V

  • elements of K called keys -- must be unique
  • elements of V called values -- need not be unique

Similar structure in other languages:

  • Python: dictionaries
  • C++: unordered_map<K,V>
  • Java: Hashtable<K,T>

Creating a HashMap

  • Create a hash map and insert key-value pairs
  • Extract a reference with .get()
#![allow(unused)]
fn main() {
use std::collections::HashMap;

// number of wins in a local Counterstrike league
let mut wins = HashMap::<String,u16>::new();

// Insert creates a new key/value if exists and overwrites old value if key exists
wins.insert(String::from("Boston University"),24);
wins.insert(String::from("Harvard"),22);
wins.insert(String::from("Boston College"),20);
wins.insert(String::from("Northeastern"),32);

// Extracting a reference: returns `Option<&V>`

println!("Boston University wins: {:?}", wins.get("Boston University"));
println!("MIT wins: {:?}", wins.get("MIT"));
}

Inserting a key-value pair if not present

To check if a key is present, and if not, insert a default value, you can use .entry().or_insert().

#![allow(unused)]
fn main() {
use std::collections::HashMap;

// number of wins in a local Counterstrike league
let mut wins = HashMap::<String,u16>::new();

// Insert creates a new key/value if exists and overwrites old value if key exists
wins.insert(String::from("Boston University"),24);
wins.insert(String::from("Harvard"),22);
wins.insert(String::from("Boston College"),20);
wins.insert(String::from("Northeastern"),32);

//Insert if not present, you can use `.entry().or_insert()`.

wins.entry(String::from("MIT")).or_insert(10);
println!("MIT wins: {:?}", wins.get("MIT"));
}

Updating a value based on the old value

To update a value based on the old value, you can use .entry().or_insert() and get a mutable reference to the value.

#![allow(unused)]
fn main() {
use std::collections::HashMap;

// number of wins in a local Counterstrike league
let mut wins = HashMap::<String,u16>::new();

// Insert creates a new key/value if exists and overwrites old value if key exists
wins.insert(String::from("Boston University"),24);
wins.insert(String::from("Harvard"),22);
wins.insert(String::from("Boston College"),20);
wins.insert(String::from("Northeastern"),32);

// Updating a value based on the old value:
println!("Boston University wins: {:?}", wins.get("Boston University"));

{ // code block to limit how long the reference lasts
    let entry = wins.entry(String::from("Boston University")).or_insert(10);
    *entry += 50;
}
//wins.insert(String::from("Boston University"),24);
println!("Boston University wins: {:?}", wins.get("Boston University"));
}

Iterating

You can iterate over each key-value pair with a for loop similar to vectors.

#![allow(unused)]
fn main() {
use std::collections::HashMap;

// number of wins in a local Counterstrike league
let mut wins = HashMap::<String,u16>::new();

// Insert creates a new key/value if exists and overwrites old value if key exists
wins.insert(String::from("Boston University"),24);
wins.insert(String::from("Harvard"),22);
wins.insert(String::from("Boston College"),20);
wins.insert(String::from("Northeastern"),32);

for (k,v) in &wins {
    println!("{}: {}",k,v);
};

println!("\nUse .iter(): ");
for (k,v) in wins.iter() {
    println!("{}: {}",k,v);
};
}

Iterating and Modifying Values

To modify values, you have to use mutable versions:

#![allow(unused)]
fn main() {
use std::collections::HashMap;

// number of wins in a local Counterstrike league
let mut wins = HashMap::<String,u16>::new();

// Insert creates a new key/value if exists and overwrites old value if key exists
wins.insert(String::from("Boston University"),24);
wins.insert(String::from("Harvard"),22);
wins.insert(String::from("Boston College"),20);
wins.insert(String::from("Northeastern"),32);

for (k,v) in &wins {
    println!("{}: {}",k,v);
};

println!("\nUse implicit mutable iterator: ");
for (k,v) in &mut wins {
    *v += 1;
    println!("{}: {}",k,v);
};

println!("\nUse .iter_mut(): ");
for (k,v) in wins.iter_mut() {
    *v += 1;
    println!("{}: {}",k,v);
};
}

Using HashMaps with Match statements

  • Let's use a hash map to store the price of different items in a cafe
#![allow(unused)]
fn main() {
use std::collections::HashMap;

let mut crispy_crêpes_café = HashMap::new();
crispy_crêpes_café.insert(String::from("Nutella Crêpe"),5.85);
crispy_crêpes_café.insert(String::from("Strawberries and Nutella Crêpe"),8.75);
crispy_crêpes_café.insert(String::from("Roma Tomato, Pesto and Spinach Crêpe"),8.90);
crispy_crêpes_café.insert(String::from("Three Mushroom Crêpe"),8.90);

fn on_the_menu(cafe: &HashMap<String,f64>, s:String) {
    print!("{}: ",s);
    match cafe.get(&s) {  // .get() returns an Option enum
        None => println!("not on the menu"),
        Some(price) => println!("${:.2}",price),
    }
}
on_the_menu(&crispy_crêpes_café, String::from("Four Mushroom Crêpe"));
on_the_menu(&crispy_crêpes_café, String::from("Three Mushroom Crêpe"));
}

Summary of Useful HashMap Methods

Basic Operations:

  • new(): Creates an empty HashMap.
  • insert(key, value): Adds a key-value pair to the map. Returns true if the key was not present, false otherwise.
  • remove(key): Removes a key-value pair from the map. Returns true if the key was present, false otherwise.
  • get(key): Returns a reference to the value in the map, if any, that is equal to the given key.
  • contains_key(key): Checks if the map contains a specific key. Returns true if present, false otherwise.
  • len(): Returns the number of key-value pairs in the map.
  • is_empty(): Checks if the map contains no key-value pairs.
  • clear(): Removes all key-value pairs from the map.
  • drain(): Returns an iterator that removes all key-value pairs and yields them. The map becomes empty after this operation.

Iterators and Views:

  • iter(): Returns an immutable iterator over the key-value pairs in the map.
  • iter_mut(): Returns a mutable iterator over the key-value pairs in the map.
  • keys(): Returns an iterator over the keys in the map.
  • values(): Returns an iterator over the values in the map.
  • values_mut(): Returns a mutable iterator over the values in the map.

See the documentation for more details.

How Hash Tables Work

Internal Representation

Array of Option enums of tuples (key, value, hash)

  • A hash map is represented as an array of buckets, e.g. capacity
  • The array is an array of Option<T> enums like Vec<Option<T>>) ,
  • And the Some(<T>) variant has value T with tuple (key, value, hash)
  • So the internal representation is like Vec<Option<(K, V, u64)>>

Hash function

  • Use a hash function which is like a pseudorandom number generator with key as the seed, e.g.
  • Pseudorandom means that the same key will always produce the same hash, but different keys will produce different hashes.
  • Then take modulo of capacity , e.g. index = hash % 8 = 6
  • So ultimately maps keys into one of the buckets

Hash Function Examples

Let's calculate hash and index for different inputs using Rust's built-in hash function.

use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};

fn hash_function(input: &str) -> u64 {
    let mut hasher = DefaultHasher::new();
    input.hash(&mut hasher);
    hasher.finish()
}

fn main() {
    let B = 8;  // capacity of the hash map (e.g. number of buckets)

    let input = "Hello!";
    let hash = hash_function(input);
    let index = hash % B;
    println!("Hash of '{}' is: {} and index is: {}", input, hash, index);

    let input = "Hello";  // slight change in input
    let hash = hash_function(input);
    let index = hash % B;
    println!("Hash of '{}' is: {} and index is: {}", input, hash, index);

    let input = "hello";  // slight change in input
    let hash = hash_function(input);
    let index = hash % B;
    println!("Hash of '{}' is: {} and index is: {}", input, hash, index);

}
  • Any collisions?
  • Try increasing the capacity to 16 and see how the index changes.

More Hash Function Examples

  • Keys don't have to be strings.
  • They can be any type that implements the Hash trait.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};

fn generic_hash_function<T: Hash>(input: &T) -> u64 {
    let mut hasher = DefaultHasher::new();
    input.hash(&mut hasher);
    hasher.finish()
}

fn main() {    
    // Using the generic hash function with different types
    println!("\nUsing generic_hash_function:");
    println!("String hash: {}", generic_hash_function(&"Hello, world!"));
    println!("Integer hash: {}", generic_hash_function(&42));
    // println!("Float hash: {}", generic_hash_function(&3.14)); // what if we try float?
    println!("Bool hash: {}", generic_hash_function(&true));
    println!("Tuple hash: {}", generic_hash_function(&(1, 2, 3)));
    println!("Vector hash: {}", generic_hash_function(&vec![1, 2, 3, 4, 5]));
    println!("Char hash: {}", generic_hash_function(&'A'));
}

What if you try to hash a float?

General ideas

  • Store keys (and associated values and hashes) in buckets
  • Indexing: Use hash function to find bucket holding key and value.

Collision: two keys mapped to the same bucket

  • Very unlikely given the pseudorandom nature of the hash function
  • What to do if two keys in the same bucket

Handling collisions

Probing

  • Each bucket entry: (key, value, hash)
  • Use a deterministic algorithm to find an open bucket

Inserting:

  • entry busy: try , , etc.
  • insert into first empty

Searching:

  • try , , , etc.
  • stop when found or empty entry

Handling collisions, example

Step 1

Step 1: Empty hash map with 4 buckets

Index:  0         1         2         3
      +-------+ +-------+ +-------+ +-------+
      | empty | | empty | | empty | | empty |
      +-------+ +-------+ +-------+ +-------+

Step 2

Step 2: Insert key="apple", hash("apple") = 42

hash("apple") = 42
42 % 4 = 2  ← insert at index 2

Index:  0         1         2         3
      +-------+ +-------+ +-------+ +-------+
      | empty | | empty | |apple,v| | empty |
      |       | |       | | h=42  | |       |
      +-------+ +-------+ +-------+ +-------+
                            ^
                            insert here

Step 3

Step 3: Insert key="banana", hash("banana") = 14

hash("banana") = 14
14 % 4 = 2  ← collision! index 2 is occupied, and not same key

Index:  0         1         2         3
      +-------+ +-------+ +-------+ +-------+
      | empty | | empty | |apple,v| | empty |
      |       | |       | | h=42  | |       |
      +-------+ +-------+ +-------+ +-------+
                            ^
                            occupied, check next

Step 4

Step 4: Linear probing - check next bucket (index 3)

Index 2 is full, try (2+1) % 4 = 3

Index:  0         1         2         3
      +-------+ +-------+ +-------+ +-------+
      | empty | | empty | |apple,v| |banana,v|
      |       | |       | | h=42  | | h=14   |
      +-------+ +-------+ +-------+ +-------+
                                      ^
                                      insert here

Step 5

Step 5: Insert key="cherry", hash("cherry") = 10

hash("cherry") = 10
10 % 4 = 2  ← collision again!

Check index 2: occupied (apple), not (cherry)
Check index 3: occupied (banana), not (cherry)
Check index 0: empty! ← wrap around and insert

Index:  0         1         2         3
      +-------+ +-------+ +-------+ +-------+
      |cherry,v| | empty | |apple,v| |banana,v|
      | h=10   | |       | | h=42  | | h=14   |
      +-------+ +-------+ +-------+ +-------+
        ^
        insert here after wrapping around

Searching for a key

Current state of hash map:

Index:  0         1         2         3
      +-------+ +-------+ +-------+ +-------+
      |cherry,v| | empty | |apple,v| |banana,v|
      | h=10   | |       | | h=42  | | h=14   |
      +-------+ +-------+ +-------+ +-------+

Step 1

Step 1: Search for key="cherry"

hash("cherry") = 10
10 % 4 = 2  ← start searching at index 2

Index:  0         1         2         3
      +-------+ +-------+ +-------+ +-------+
      |cherry,v| | empty | |apple,v| |banana,v|
      | h=10   | |       | | h=42  | | h=14   |
      +-------+ +-------+ +-------+ +-------+
                            ^
                            check here first

Step 2

Step 2: Check index 2

Index 2: key = "apple" ≠ "cherry"
         bucket occupied, continue probing

Index:  0         1         2         3
      +-------+ +-------+ +-------+ +-------+
      |cherry,v| | empty | |apple,v| |banana,v|
      | h=10   | |       | | h=42  | | h=14   |
      +-------+ +-------+ +-------+ +-------+
                            ^
                            not found, try next

Step 3

Step 3: Check index 3 (next probe)

Index 3: key = "banana" ≠ "cherry"
         bucket occupied, continue probing

Index:  0         1         2         3
      +-------+ +-------+ +-------+ +-------+
      |cherry,v| | empty | |apple,v| |banana,v|
      | h=10   | |       | | h=42  | | h=14   |
      +-------+ +-------+ +-------+ +-------+
                                      ^
                                      not found, try next

Step 4

Step 4: Check index 0 (wrap around)

Index 0: key = "cherry" = "cherry" ✓
         FOUND! Return value

Index:  0         1         2         3
      +-------+ +-------+ +-------+ +-------+
      |cherry,v| | empty | |apple,v| |banana,v|
      | h=10   | |       | | h=42  | | h=14   |
      +-------+ +-------+ +-------+ +-------+
        ^
        FOUND: return value v

Key point: Linear probing continues until we either:

  • Find a matching key (success)
  • Find an empty bucket (key doesn't exist)
  • Check all buckets (hash map is full)

What is worse case scenario?

  • All keys map to the same bucket.

  • We have to check all buckets to find the key.

  • This is time complexity.

  • This is the worst case scenario for linear probing.

What is the average case scenario?

  • Each bucket has 1 key.

  • We have to check about 1 bucket to find the key.

  • This is time complexity.

  • This is the average case scenario for linear probing.

Growing the collection: amortization

Keep track of the number of filled entries.

When the number of keys

  • Double
  • Pick new hash function
  • Move the information

Adversarial data

  • Could create lots of collisions

  • Potential basis for denial of service attacks

What makes a good hash function?

  • Uniform distribution of inputs to the buckets available!!!
  • Consistent hashing adds the property that not too many things move around when the number of buckets changes

http://www.partow.net/programming/hashfunctions/index.html
https://en.wikipedia.org/wiki/Consistent_hashing
https://doc.rust-lang.org/std/collections/struct.HashMap.html

To Dig Deeper (Optional)

Clone, inspect and debug/single-step through a simple implementation that supports creation, insert, get and remove.

See how index is found from hashing the key.

See how collision is handled.

Hashing with custom types in Rust

How do we use custom datatypes as keys?

Required for hashing:

  1. check if K equal
  2. compute a hash function for elements of K
#![allow(unused)]
fn main() {
use std::collections::HashMap;

struct Point {
    x:i64,
    y:i64,
}

let point = Point{x:2,y:-1};

let mut elevation = HashMap::new();

elevation.insert(point,2.3);

}

Most importantly:

[E0277] Error: the trait bound `Point: Eq` is not satisfied
[E0277] Error: the trait bound `Point: Hash` is not satisfied

Required traits for custom types

In order for a data structure to work as a key for hashmap, they need three traits:

  • PartialEq (required by Eq)
    • ✅ Symmetry: If a == b, then b == a.
    • ✅ Transitivity: If a == b and b == c, then a == c.
    • ❌ Reflexivity is NOT guaranteed (because e.g. NaN != NaN in floats).
  • Eq
    • ✅ Reflexivity: a == a is always true.
    • ✅ Symmetry: If a == b, then b == a.
    • ✅ Transitivity: If a == b and b == c, then a == c.
  • Hash
    • Supports deterministic output of a hash function
    • Consistency with Equality -- if two values are equal , then their hashes are equal
    • Non-Invertibility -- One way. You cannot reconstruct the original value from the hash
    • etc...

Default implementation

  • Eq and PartialEq are automatically derived for most types.
#![allow(unused)]
fn main() {
use std::collections::HashMap;

#[derive(Debug,Hash,Eq,PartialEq)]
struct DistanceKM(u64);

let mut tired = HashMap::new();

tired.insert(DistanceKM(30),true);
println!("{:?}", tired);
}

Reminder: All the traits that you can automatically derive from

  • Clone: Allow user to make an explicit copy
  • Copy: Allow user to make an implicit copy
  • Debug: Allow user to print contents
  • Default: Allow user to initialize with default values (Default::default())
  • Hash: Allow user to use it as a key to a hash map or set.
  • Eq: Allow user to test for equality
  • Ord: Allow user to sort and fully order types
  • PartialEq: Obeys most rules for equality but not all
  • PartialOrd: Obeys most rules for ordering but not all

Using Floats as Keys

Note: You can use this for HW7.

Use ordered_float crate to get a type that implements Eq and Hash.

A wrapper around floats providing implementations of Eq, Ord, and Hash.

NaN is sorted as greater than all other values and equal to itself, in contradiction with the IEEE standard.

use ordered_float::OrderedFloat;
use std::f32::NAN;
use std::collections::{HashMap, HashSet};

fn main() {
let mut v = [OrderedFloat(NAN), OrderedFloat(2.0), OrderedFloat(1.0)];
v.sort();
assert_eq!(v, [OrderedFloat(1.0), OrderedFloat(2.0), OrderedFloat(NAN)]);

let mut m: HashMap<OrderedFloat<f32>, String> = HashMap::new();
m.insert(OrderedFloat(3.14159), "pi".to_string());
assert!(m.contains_key(&OrderedFloat(3.14159)));

let mut s: HashSet<OrderedFloat<f32>> = HashSet::new();
s.insert(OrderedFloat(3.14159));
assert!(s.contains(&OrderedFloat(3.14159)));

Using Floats as Keys (Alternative)

Not all basic types support the Eq and Hash traits (f32 and f64 do not). The reasons have to do with the NaN and Infinity problems we discussed last time.

  • If you find yourself needing floats as keys consider converting the float to a collection of integers
  • Floating point representation consists of Sign, Exponent and Mantissa, each integer
Float number
From https://www.geeksforgeeks.org/ieee-standard-754-floating-point-numbers/

float_num = (-1)^sign * mantissa * 2^exponent where

  • sign is -1 or 1
  • mantissa is u23 between 0 and 2^23
  • exponent is i8 between -127 and 128
// Built-in Rust library for traits on numbers
cargo add num-traits
#![allow(unused)]
fn main() {
let num:f64 = 3.14159;  // Some float
println!("num: {:32.21}", num);
}

Question: Why is the number printed different than the number assigned?

Answer: Floating point can't exactly represent every decimal number. See above.


Let's decompose the floating point number into its components:

use num_traits::Float;

let num:f64 = 3.14159;  // Some float
println!("num: {:32.21}", num);

let base:f64 = 2.0;

// Deconstruct the floating point
let (mantissa, exponent, sign) = Float::integer_decode(num);
println!("mantissa: {} exponent: {} sign: {}", mantissa, exponent, sign);

// Conver to f64
let sign_f:f64 = sign as f64;
let mantissa_f:f64 = mantissa as f64;
let exponent_f:f64 = base.powf(exponent as f64);

// Recalculate the floating point value
let new_num:f64 = sign_f * mantissa_f * exponent_f;

println!("{:32.31} {:32.31}", num, new_num);
mantissa: 7074231776675438 exponent: -51 sign: 1
3.1415899999999998826183400524314 3.1415899999999998826183400524314

Let's check it:

#![allow(unused)]
fn main() {
let mantissa:u64 = 7074231776675438;
let exponent:i8 = -51;
let sign:i8 = 1;
let base:f64 = 2.0;

//convert to f64
let sign_f:f64 = sign as f64;
let mantissa_f:f64 = mantissa as f64;
let exponent_f:f64 = base.powf(exponent as f64);

// Recalculate the floating point value
let new_num:f64 = sign_f * mantissa_f * exponent_f;

println!("{:32.31}", new_num);
}

HashSet<K>

"A HashMap without values"

  • No value associated with keys
  • Just a set of items
  • Same implementation
  • Fastest way to do membership tests and some set operations

Creating a HashSet

  • Create: HashSet::new()
  • .insert(), .is_empty(), .contains()
#![allow(unused)]
fn main() {
use std::collections::HashSet;

// create
let mut covid = HashSet::new();
println!("Is empty: {}", covid.is_empty());

// insert values
for i in 2019..=2022 {
    covid.insert(i);
};

println!("Is empty: {}", covid.is_empty());
println!("Contains 2019: {}", covid.contains(&2019));
println!("Contains 2015: {}", covid.contains(&2015));
}

Growing the collection: amortization

  • Let's monitor the length and capacity as we insert values.
#![allow(unused)]
fn main() {
use std::collections::HashSet;

// create
let mut covid = HashSet::new();
println!("Length: {}, Capacity: {}", covid.len(), covid.capacity());
println!("Is empty: {}", covid.is_empty());

// insert values
for i in 2019..=2022 {
    covid.insert(i);
    println!("Length: {}, Capacity: {}", covid.len(), covid.capacity());
};

println!("Length: {}, Capacity: {}", covid.len(), covid.capacity());
println!("Is empty: {}", covid.is_empty());
}
  • More expensive than growing a Vec because we need to rehash all the elements.

Iterating over a HashSet

You can iterate over a HashSet with a for loop.

#![allow(unused)]
fn main() {
use std::collections::HashSet;

// create
let mut covid = HashSet::new();

// insert values
for i in 2019..=2022 {
    covid.insert(i);
};

// use the implicit iterator
for year in &covid {
    print!("{} ",year);
}
println!();

// use the explicit iterator
for year in covid.iter() {
    print!("{} ",year);
}
println!();
}

Question: Why aren't the years in the order we inserted them?


Using .get() and .insert()

We can use .get() and .insert(), similarly to how we used them in HashMaps.

#![allow(unused)]
fn main() {
use std::collections::HashSet;

// create
let mut covid = HashSet::new();

// insert values
for i in 2019..=2022 {
    covid.insert(i);
};

// Returns `None` if not in the HashSet
println!("{:?}", covid.get(&2015));

println!("{:?}", covid.get(&2021));

covid.insert(2015); // insert 2015 if not present
covid.insert(2020); // insert 2020 if not present

// iterate over the set
for year in &covid {
    print!("{} ",year);
}
}

Summary of Useful HashSet Methods

Basic Operations:

  • new(): Creates an empty HashSet.
  • insert(value): Adds a value to the set. Returns true if the value was not present, false otherwise.
  • remove(value): Removes a value from the set. Returns true if the value was present, false otherwise.
  • contains(value): Checks if the set contains a specific value. Returns true if present, false otherwise.
  • len(): Returns the number of elements in the set.
  • is_empty(): Checks if the set contains no elements.
  • clear(): Removes all elements from the set.
  • drain(): Returns an iterator that removes all elements and yields them. The set becomes empty after this operation.

Set Operations:

  • union(&self, other: &HashSet<T>): Returns an iterator over the elements that are in self or other (or both).
  • intersection(&self, other: &HashSet<T>): Returns an iterator over the elements that are in both self and other.
  • difference(&self, other: &HashSet<T>): Returns an iterator over the elements that are in self but not in other.
  • symmetric_difference(&self, other: &HashSet<T>): Returns an iterator over the elements that are in self or other, but not in both.
  • is_subset(&self, other: &HashSet<T>): Checks if self is a subset of other.
  • is_superset(&self, other: &HashSet<T>): Checks if self is a superset of other.
  • is_disjoint(&self, other: &HashSet<T>): Checks if self has no elements in common with other.

Iterators and Views:

  • iter(): Returns an immutable iterator over the elements in the set.
  • get(value): Returns a reference to the value in the set, if any, that is equal to the given value.

See the documentation for more details.

In-Class Exercise 1: Word Frequency Counter

Task: Create a HashMap that counts the frequency of each word in the following sentence:

"rust is awesome rust is fast rust is safe"

Your code should:

  1. Split the sentence into words. (Hint: Use .split_whitespace() on your string and iterate over the result.)
  2. Count how many times each word appears using a HashMap
  3. Print each word and its frequency

Hint: Use .entry().or_insert() to initialize or increment counts.

Expected Output:

rust: 3
is: 3
awesome: 1
fast: 1
safe: 1
Solution
use std::collections::HashMap;

fn main() {
    let sentence = "rust is awesome rust is fast rust is safe";
    let mut word_count = HashMap::new();
    
    for word in sentence.split_whitespace() {
        let count = word_count.entry(word).or_insert(0);
        *count += 1;
    }
    
    for (word, count) in &word_count {
        println!("{}: {}", word, count);
    }
}

In-Class Exercise 2: Programming Languages Analysis

Task: Two developers list their known programming languages. Create two HashSets and perform set operations to analyze their skills.

Developer 1 knows: Rust, Python, JavaScript, C++, Go
Developer 2 knows: Python, Java, JavaScript, Ruby, Go

Your code should find and print:

  1. Languages both developers know (intersection)
  2. Languages unique to Developer 1 (difference)
  3. All languages known by at least one developer (union)
  4. Languages known by exactly one developer (symmetric difference)

Hint: Create two HashSets and use set operations methods shown earlier.

Solutions will be added here after class.

Solution
use std::collections::HashSet;

fn main() {
    // Insert one at a time
    let mut dev1 = HashSet::new();
    dev1.insert("Rust");
    dev1.insert("Python");
    dev1.insert("JavaScript");
    dev1.insert("C++");
    dev1.insert("Go");
    
    // Iterate over a vector and insert
    let mut dev2 = HashSet::new();
    let dev2_languages = vec!["Python", "Java", "JavaScript", "Ruby", "Go"];
    for lang in dev2_languages {
        dev2.insert(lang);
    }
    
    println!("Languages both know:");
    for lang in dev1.intersection(&dev2) {
        println!("  {}", lang);
    }
    
    println!("\nLanguages unique to Developer 1:");
    for lang in dev1.difference(&dev2) {
        println!("  {}", lang);
    }
    
    println!("\nAll languages known:");
    for lang in dev1.union(&dev2) {
        println!("  {}", lang);
    }
    
    println!("\nLanguages known by exactly one:");
    for lang in dev1.symmetric_difference(&dev2) {
        println!("  {}", lang);
    }
}

Linked Lists in Rust

About This Module

This module explores linked list data structures in Rust, covering both the theoretical concepts and practical implementation challenges. Students will learn about different types of linked lists (singly and doubly linked), understand their computational complexity, and discover why implementing linked lists in Rust requires careful consideration of ownership rules. The module compares various implementation approaches and discusses when to use linked lists versus other data structures.

Prework

Before this lecture, please read:

Pre-lecture Reflections

  1. Why can't you implement a recursive data structure directly in Rust without using Box<T>?
  2. What are the memory layout differences between arrays and linked lists?
  3. How do ownership rules affect pointer-based data structures in Rust?

Learning Objectives

By the end of this lecture, you should be able to:

  • Understand the structure and operations of linked lists
  • Analyze the computational complexity of linked list operations
  • Implement basic linked lists in Rust using Box<T> and proper ownership patterns
  • Compare the performance characteristics of different linked list variants
  • Choose appropriate data structures based on access patterns and performance requirements

What is a linked list?

  • A recursive data structure
  • Simplest version is a single pointer (head) that points to the first element in the list
  • Each list element contains some data and a pointer to the next element in the list
  • A special pointer value (None) used to indicate the end of the list
  • If first == None then the list is empty

Inserting and Removing from the beginning of the list

Assume you have a new list element "John". How do you add it to the list?

"John".next = first  
first = "John"  

How about getting an element out of the list?

item = first  
first = item.next  
item.next = NULL  
return item

Common optimization for lists

  • Doubly linked list
  • Tail pointer


Cost of list operations

  • Insert to Front: (SLL O(1), DLL O(1))
  • Remove from Front (SLL O(1), DLL O(1))
  • Insert to Back (SLL O(N), DLL O(1))
  • Remove from Back (SLL O(N), DLL O(1))
  • Insert to Middle (SLL O(N), DLL O(N))
  • Remove from Middle (SLL O(N), DLL O(N))

Rust's LinkedList

#![allow(unused)]
fn main() {
use std::collections::LinkedList;

let mut list = LinkedList::from([1, 2, 3]);
println!("{:?}", list);
list.push_front(0);
println!("{:?}", list);
list.push_back(4);
println!("{:?}", list);
list.pop_front();
println!("{:?}", list);
list.pop_back();
println!("{:?}", list);
}

Summary of Useful LinkedList Methods

  • push_front(value): Adds a value to the front of the list.
  • push_back(value): Adds a value to the back of the list.
  • pop_front(): Removes and returns the value from the front of the list.
  • pop_back(): Removes and returns the value from the back of the list.
  • front(): Returns a reference to the value at the front of the list.
  • back(): Returns a reference to the value at the back of the list.
  • len(): Returns the number of elements in the list.
  • is_empty(): Returns true if the list is empty, false otherwise.
  • clear(): Removes all elements from the list.
  • drain(): Returns an iterator that removes all elements and yields them. The list becomes empty after this operation.

See the documentation for more details.

Don't use LinkedList!

Warning from the Rust documentation on LinkedList:

NOTE: It is almost always better to use Vec or VecDeque because array-based containers are generally faster, more memory efficient, and make better use of CPU cache.

We'll see VecDeque in a later lecture.

Moving on...

Recap

  • Linked lists are a recursive data structure
  • They are not contiguous in memory, and poor processor cache utilization
  • Simple to access the beginning or end

Stack Data Structure in Rust

About This Module

This module introduces the stack data structure, a fundamental Last-In-First-Out (LIFO) container. Students will learn about stack operations, computational complexity, and multiple implementation strategies using both linked lists and vectors. The module explores the trade-offs between different implementations and demonstrates practical applications of stacks in programming and data science.

Prework

Before this lecture, please read:

Pre-lecture Reflections

  1. What are some real-world examples where LIFO behavior is useful?
  2. How might stack implementation affect performance in different scenarios?
  3. What are the memory layout differences between stack implementations using vectors vs. linked lists?

Learning Objectives

By the end of this lecture, you should be able to:

  • Understand the LIFO principle and stack operations
  • Implement stacks using different underlying data structures
  • Analyze the computational complexity of stack operations
  • Compare performance characteristics of vector-based vs. linked list-based stacks
  • Choose appropriate stack implementations based on use case requirements

Stacks

  • A Stack is a container of objects that are inserted and removed according the LIFO (Last In First Out) principle
  • Insertions are known as "Push" operations while removals are known as "Pop" operations

Universal Stack Operations

Stack operations would be along the lines of:

  • push(object): Insert object onto top of stack. Input: object, Output: none
  • pop(): Remove top object from stack and return it. Input: none, Output: object
  • size(): Number of objects in stack
  • isEmpty(): Return boolean indicated if stack is empty
  • top() or peek(): Return a reference to top object in the stack without removing it

Question: Which Rust data structure could we use to implement a stack?

Computational complexity of Stack operations

Assume we are using a singly (or doubly) linked list

  • Push: O(1)
  • Pop: O(1)
  • Size: O(1) (keep an auxiliary counter)
  • isEmpty: O(1)
  • top: O(1)

Using Vectors to implement a stack

  • Implementing a stack using a vector is straightforward.
  • We can build on Vec<T> methods.
#![allow(unused)]
fn main() {
#[derive(Debug)]
pub struct Stack<T> {
    v: Vec<T>,
}

impl <T> Stack<T> {
    pub fn new() -> Self {
        Stack {v : Vec::new() }
        
    }
    pub fn push(&mut self, obj:T) {
        self.v.push(obj)
    }
     
    pub fn pop(&mut self) -> Option<T> {
        return self.v.pop();
    }
    
    pub fn size(&mut self) -> usize {
        return self.v.len();
    }
    
    pub fn isEmpty(&mut self) -> bool {
        return self.v.len() == 0;
    }
    
    pub fn top(&mut self) -> Option<&T> {
        return self.v.last()
    }
}
}

Using our stack implementation

Now let's use it!

#[derive(Debug)]
pub struct Stack<T> {
    v: Vec<T>,
}

impl <T> Stack<T> {
    pub fn new() -> Self {
        Stack {v : Vec::new() }
        
    }
    pub fn push(&mut self, obj:T) {
        self.v.push(obj)
    }
     
    pub fn pop(&mut self) -> Option<T> {
        return self.v.pop();
    }
    
    pub fn size(&mut self) -> usize {
        return self.v.len();
    }
    
    pub fn isEmpty(&mut self) -> bool {
        return self.v.len() == 0;
    }
    
    pub fn top(&mut self) -> Option<&T> {
        return self.v.last()
    }
}

fn main() {
    let mut s: Stack<i32> = Stack::new();

    println!("Pushing 13, 11, and 9\n");
    s.push(13);
    s.push(11);
    s.push(9);

    println!("size: {}", s.size());
    println!("isEmpty: {}", s.isEmpty());

    println!("\ntop: {:?}", s.top());
    println!("pop: {:?}", s.pop());
    println!("size: {}", s.size());

    println!("\ntop: {:?}", s.top());
    println!("pop: {:?}", s.pop());
    println!("size: {}", s.size());

    println!("\ntop: {:?}", s.top());
    println!("pop: {:?}", s.pop());
    println!("size: {}", s.size());
    println!("isEmpty: {}", s.isEmpty());

    println!("\ntop: {:?}", s.top());
    println!("pop: {:?}", s.pop());
}

Which implementation is better: LinkedList or Vec?

  • Computation complexity is the same for both (at least on average)
  • The Vector implementation has the occasional long operation which may be undesirable in a real-time system

BUT the most important consideration is spatial locality of reference.

  • In a vector objects will be contiguous in memory so accessing one will fetch its neighbors into the cache for faster access
  • In the linked list version each object is allocated independently so their placement in memory is unclear

In-Class Poll

True or False:

  1. In a stack, the most recently added element is the first one to be removed.

    • True ✓ (This is the definition of LIFO - Last In First Out)
  2. The pop() operation on a stack has O(n) time complexity when using a singly linked list implementation.

    • False ✗ (pop() is O(1) for both linked list and vector implementations)
  3. A vector-based stack implementation may occasionally have long operations due to resizing.

    • True ✓ (When the vector needs to grow, it must allocate new memory and copy elements)
  4. The top() or peek() operation removes the top element from the stack.

    • False ✗ (top/peek only returns a reference without removing the element; pop removes it)
  5. Vector-based stacks generally have better spatial locality of reference than linked list-based stacks.

    • True ✓ (Vector elements are contiguous in memory, improving cache performance)

Recap

  • Stacks are a fundamental data structure
  • They are implemented using a vector or a linked list
  • They are a Last-In-First-Out (LIFO) data structure

Queue Data Structure in Rust

About This Module

This module explores queue data structures, which follow the First-In-First-Out (FIFO) principle. Students will learn about queue operations, various implementation strategies, and the trade-offs between different approaches. The module covers both custom implementations and Rust's standard library VecDeque, with a focus on performance considerations and practical applications in data processing and algorithms.

Prework

Before this lecture, please read:

Pre-lecture Reflections

  1. What are some real-world scenarios where FIFO ordering is essential?
  2. Why might using a Vec with remove(0) be problematic for queue operations?
  3. How does memory layout affect performance in different queue implementations?

Learning Objectives

By the end of this lecture, you should be able to:

  • Understand the FIFO principle and queue operations
  • Implement queues using different underlying data structures
  • Analyze performance trade-offs between queue implementations
  • Use Rust's VecDeque effectively for both stack and queue operations
  • Choose appropriate data structures based on access patterns and performance requirements

Queues

Queue:

  • FIFO: first in first out
  • add items at the end
  • get items from the front

Question: Why is it problematic to use Vec as a Queue?

Generic Queue operations

Warning: This is not Rust syntax.

  • enqueue(object): Insert object at the end of the queue. Input: object, Output: none
  • dequeue(): Remove an object from the front of the queue and return it. Input: none, Output: object
  • size(): Number of objects in queue
  • isEmpty(): Return boolean indicated if queue is empty
  • front(): Return a reference to front object in the queue without removing it

Queue Complexity using Singly Linked List?

  • Remember in a singly linked list the most recent element is first pointer while the oldest is at the tail end of the list
  • Adding a queue element O(1)
  • Removing a queue element requires list traversal so O(n)

You can do better with doubly linked lists and tail pointer

Assume first points to most recently added element and last to oldest element

  • Adding a queue element still O(1)
  • Removing the older element O(1)
  • But the memory fragmentation issues persist

The VecDeque container in Rust

std::collections::VecDeque<T>

  • generalization of queue and stack
  • accessing front: methods push_front(x) and pop_front()
  • accessing back: methods push_back(x) and pop_back()
  • pop_front and pop_back return Option<T>

Using VecDeque as a Stack

Use push_back and pop_back

#![allow(unused)]
fn main() {
use std::collections::VecDeque;

// using as a stack: push_back & pop_back
let mut stack = VecDeque::new();

stack.push_back(1);
stack.push_back(2);
stack.push_back(3);

println!("{:?}",stack.pop_back());
println!("{:?}",stack.pop_back());

stack.push_back(4);
stack.push_back(5);

println!("{:?}",stack.pop_back());
}

Using VecDeque as a Queue

#![allow(unused)]
fn main() {
use std::collections::VecDeque;

// using as a queue: push_back & pop_front
let mut queue = VecDeque::new();

queue.push_back(1);
queue.push_back(2);
queue.push_back(3);

println!("{:?}",queue.pop_front());
println!("{:?}",queue.pop_front());

queue.push_back(4);
queue.push_back(5);

println!("{:?}",queue.pop_front());
}

VecDeque operation semantics

  • push_back + pop_back (Stack Behavior)
  • push_front + pop_front (Stack Behavior)
  • push_back + pop_front (Queue Behavior)
  • push_front + pop_back (Queue Behavior)

Implementation of VecDeque

  • use an array allocated on the heap (think of it as a circular buffer)
  • keep index of the front and end
  • wrap around

Out of space?

  • double the size
  • good complexity due to amortization

See Wikipedia: Circular Buffer for more details.

Priority Queues (for a later lecture)

module070_1.png

In-Class Poll

True or False:

  1. In a queue data structure, the first element added is the first element removed (FIFO principle).

    • True ✓ (This is the definition of FIFO - First In First Out)
  2. When using a singly linked list to implement a queue, both enqueue and dequeue operations can be performed in O(1) time complexity.

    • False ✗ (enqueue is O(1) and dequeue is O(n) for singly linked list)
  3. Rust's VecDeque can function as both a stack and a queue depending on which methods you use.

    • True ✓ (VecDeque can be used as both stack and queue depending on the methods used)
  4. To use a VecDeque as a queue, you should use push_back() to add elements and pop_back() to remove elements.

    • False ✗ (To use as a queue, you should use push_back() to add elements and pop_front() to remove elements)
  5. VecDeque is implemented using a doubly linked list that grows by 1 as needed.

    • False ✗ (VecDeque is implemented using a circular buffer)

Recap

Collections Deep Dive: Entry API, BTreeMap, and Circular Buffers

About This Module

This module provides a deep dive into advanced collection patterns essential for HW7. You'll master the Entry API for efficient HashMap/BTreeMap updates, learn BTreeMap for ordered data with range queries, use the ordered-float crate for float keys, and implement circular buffers with VecDeque.

Prework

Prework Reading

Please read the following:

Pre-lecture Reflections

  1. What's the difference between using .get() then .insert() vs using the Entry API?
  2. When would you want keys to be sorted (BTreeMap) vs unsorted (HashMap)?
  3. Why can't f64 be used directly as a HashMap/BTreeMap key?
  4. What's the difference between a regular queue and a circular buffer?

Learning Objectives

By the end of this module, you will be able to:

  • Use the Entry API to efficiently update collections without double lookups
  • Choose between HashMap and BTreeMap based on requirements
  • Use BTreeMap for ordered data, range queries, and percentile calculations
  • Work with float keys using the ordered-float crate
  • Implement circular buffers with VecDeque for rolling window calculations

Part 1: Mastering the Entry API

The Double-Lookup Problem

A common pattern when updating HashMaps:

#![allow(unused)]
fn main() {
use std::collections::HashMap;

fn count_words_inefficient(text: &str) -> HashMap<String, usize> {
    let mut counts = HashMap::new();
    
    for word in text.split_whitespace() {
        // DON'T: This does TWO lookups!
        if counts.contains_key(word) {
            let count = counts.get_mut(word).unwrap();
            *count += 1;
        } else {
            counts.insert(word.to_string(), 1);
        }
    }
    counts
}

let result = count_words_inefficient("rust is awesome rust is fast");
println!("{:?}", result);
}

Problem: We look up the key twice - once to check, once to modify.

The Entry API Solution

#![allow(unused)]
fn main() {
use std::collections::HashMap;

fn count_words_efficient(text: &str) -> HashMap<String, usize> {
    let mut counts = HashMap::new();
    
    for word in text.split_whitespace() {
        // DO: Single lookup with Entry API!
        *counts.entry(word.to_string()).or_insert(0) += 1;
    }
    counts
}

let result = count_words_efficient("rust is awesome rust is fast");
println!("{:?}", result);
}

Understanding Entry

The .entry() method returns an Entry enum:

#![allow(unused)]
fn main() {
use std::collections::HashMap;

let mut map: HashMap<&str, i32> = HashMap::new();

println!("{:?}", map.entry("key"));

// Entry can be Occupied or Vacant
match map.entry("key") {
    std::collections::hash_map::Entry::Occupied(entry) => {
        println!("Key exists with value: {}", entry.get());
    }
    std::collections::hash_map::Entry::Vacant(entry) => {
        println!("Key doesn't exist, inserting...");
        entry.insert(42);
    }
}

println!("{:?}", map.entry("key"));
}

Entry API Methods

  • or_insert: Insert default if vacant, return mutable reference
  • or_insert_with: Insert computed value if vacant (lazy evaluation)
  • or_default: Insert Default::default() if vacant, e.g. 0 for i32, "" for String, etc. (types with Default trait)
  • and_modify: Modify existing value, or insert default
#![allow(unused)]
fn main() {
use std::collections::HashMap;

let mut scores: HashMap<String, Vec<i32>> = HashMap::new();

// or_insert: Insert default if vacant, return mutable reference
scores.entry("Alice".to_string()).or_insert(vec![]).push(95);
scores.entry("Alice".to_string()).or_insert(vec![]).push(87);

// or_insert_with: Insert computed value if vacant (lazy evaluation)
scores.entry("Bob".to_string()).or_insert_with(|| {
    println!("Computing default for Bob...");
    vec![100]  // This only runs if key is vacant
});

// or_default: Insert Default::default() if vacant
let mut counts: HashMap<String, usize> = HashMap::new();
*counts.entry("hello".to_string()).or_default() += 1;

// and_modify: Modify existing value, or insert default
counts.entry("hello".to_string())
    .and_modify(|c| *c += 1)
    .or_insert(1);

println!("Scores: {:?}", scores);
println!("Counts: {:?}", counts);
}

Entry API for Grouping (Split-Apply-Combine)

Perfect for grouping data by categories:

#![allow(unused)]
fn main() {
use std::collections::HashMap;

let data = vec![("A", 10), ("B", 20), ("A", 30), ("B", 40), ("A", 50)];

// Group values by category
let mut groups: HashMap<&str, Vec<i32>> = HashMap::new();

for (category, value) in data {
    groups.entry(category).or_default().push(value);
}

// Now calculate aggregates per group
for (category, values) in &groups {
    let sum: i32 = values.iter().sum();
    let mean = sum as f64 / values.len() as f64;
    println!("{}: values={:?}, sum={}, mean={:.1}", category, values, sum, mean);
}
}

HW7 Connection: This pattern is the foundation of GroupedSeries in Part 2!

Entry API Works with BTreeMap Too!

#![allow(unused)]
fn main() {
use std::collections::BTreeMap;

let mut sorted_counts: BTreeMap<String, usize> = BTreeMap::new();

for word in "rust is awesome rust is fast".split_whitespace() {
    *sorted_counts.entry(word.to_string()).or_insert(0) += 1;
}

// BTreeMap iterates in sorted key order!
for (word, count) in &sorted_counts {
    println!("{}: {}", word, count);
}
}

Part 2: BTreeMap for Ordered Data

HashMap vs BTreeMap

FeatureHashMapBTreeMap
LookupO(1) averageO(log n)
Iteration orderRandomSorted by key
Range queries❌ Not supported✅ Supported
Key requirementHash + EqOrd
MemoryLess predictableMore predictable

When to Use BTreeMap

Use BTreeMap when you need:

  • Sorted iteration over keys
  • Range queries (get all keys between X and Y)
  • Min/max key operations
  • Percentile calculations
  • Keys that don't implement Hash

Note: See modules on graphs, trees, and binary search trees for background.

BTreeMap: Sorted Iteration

#![allow(unused)]
fn main() {
use std::collections::BTreeMap;

let mut temps: BTreeMap<u32, f64> = BTreeMap::new();
temps.insert(2020, 14.9);
temps.insert(2018, 14.7);
temps.insert(2022, 15.1);
temps.insert(2019, 14.8);
temps.insert(2021, 15.0);

// Iteration is always in sorted order by key!
println!("Global temperatures by year:");
for (year, temp) in &temps {
    println!("  {}: {:.1}°C", year, temp);
}

// First and last entries
println!("\nFirst: {:?}", temps.first_key_value());
println!("Last: {:?}", temps.last_key_value());
}

Note the order of years inserted and the order from the iteration.

BTreeMap: Range Queries

One of BTreeMap's killer features:

#![allow(unused)]
fn main() {
use std::collections::BTreeMap;
use std::ops::Bound;

let mut events: BTreeMap<u64, String> = BTreeMap::new();
events.insert(100, "Login".to_string());
events.insert(150, "View page".to_string());
events.insert(200, "Click button".to_string());
events.insert(250, "Submit form".to_string());
events.insert(300, "Logout".to_string());

// Get events in time range [150, 250]
println!("Events from 150-250:");
for (time, event) in events.range(150..=250) {
    println!("  t={}: {}", time, event);
}

// Events before time 200
println!("\nEvents before 200:");
for (time, event) in events.range(..200) {
    println!("  t={}: {}", time, event);
}

// Using Bound for more control
use std::ops::Bound::{Included, Excluded};
println!("\nEvents in (150, 300):");
for (time, event) in events.range((Excluded(150), Excluded(300))) {
    println!("  t={}: {}", time, event);
}
}

BTreeMap for Histogram Bins

Perfect for building sorted histograms:

#![allow(unused)]
fn main() {
use std::collections::BTreeMap;

fn build_histogram(data: &[f64], bin_width: f64) -> BTreeMap<i64, usize> {
    let mut bins: BTreeMap<i64, usize> = BTreeMap::new();
    
    for &value in data {
        // Calculate bin index (floor division)
        let bin = (value / bin_width).floor() as i64;
        *bins.entry(bin).or_insert(0) += 1;
    }
    
    bins
}

let data = vec![1.2, 2.5, 2.7, 3.1, 3.8, 4.2, 4.5, 5.0, 5.5];
let hist = build_histogram(&data, 1.0);

println!("Histogram (bin_width=1.0):");
for (bin, count) in &hist {
    let start = *bin as f64;
    let end = start + 1.0;
    let bar = "*".repeat(*count);
    println!("  [{:.1}, {:.1}): {} {}", start, end, bar, count);
}
}

HW7 Connection: This is essentially what Histogram in Part 3 does!

Part 3: Using Floats as Keys with ordered-float

The Problem with Float Keys

use std::collections::BTreeMap;

// This WON'T compile!
let mut map: BTreeMap<f64, String> = BTreeMap::new();
map.insert(3.14, "pi".to_string());

// Error: the trait bound `f64: Ord` is not satisfied

Why? Floats have NaN (Not a Number) which breaks ordering:

  • NaN != NaN (violates reflexivity)
  • NaN is not less than, equal to, or greater than any value

Solution: ordered-float Crate

Add to Cargo.toml:

[dependencies]
ordered-float = "4.2"

Then use OrderedFloat:

use ordered_float::OrderedFloat;
use std::collections::BTreeMap;

fn main() {
    let mut map: BTreeMap<OrderedFloat<f64>, String> = BTreeMap::new();
    
    // Wrap floats in OrderedFloat
    map.insert(OrderedFloat(3.14), "pi".to_string());
    map.insert(OrderedFloat(2.72), "e".to_string());
    map.insert(OrderedFloat(1.41), "sqrt(2)".to_string());
    
    // Iteration is sorted by float value!
    for (key, value) in &map {
        println!("{:.2}: {}", key.0, value);
    }
    
    // Access the inner value with .0
    let pi_key = OrderedFloat(3.14);
    println!("\nLookup {}: {:?}", pi_key.0, map.get(&pi_key));
}

OrderedFloat for Histogram Bins

use ordered_float::OrderedFloat;
use std::collections::BTreeMap;

struct Histogram {
    bins: BTreeMap<OrderedFloat<f64>, usize>,
    bin_width: f64,
}

impl Histogram {
    fn new(bin_width: f64) -> Self {
        Histogram {
            bins: BTreeMap::new(),
            bin_width,
        }
    }
    
    fn add(&mut self, value: f64) {
        let bin_edge = (value / self.bin_width).floor() * self.bin_width;
        *self.bins.entry(OrderedFloat(bin_edge)).or_insert(0) += 1;
    }
    
    fn get_count(&self, value: f64) -> usize {
        let bin_edge = (value / self.bin_width).floor() * self.bin_width;
        self.bins.get(&OrderedFloat(bin_edge)).copied().unwrap_or(0)
    }
    
    fn cumulative_distribution(&self) -> Vec<(f64, f64)> {
        let total: usize = self.bins.values().sum();
        let mut cumulative = 0;
        
        self.bins.iter()
            .map(|(bin_edge, &count)| {
                cumulative += count;
                (bin_edge.0 + self.bin_width / 2.0, cumulative as f64 / total as f64)
            })
            .collect()
    }
}

HW7 Connection: This is exactly how Histogram in Part 3 is structured!

Part 4: VecDeque for Circular Buffers

What is a Circular Buffer?

A circular buffer (ring buffer) is a fixed-size data structure that:

  • Overwrites oldest data when full
  • Perfect for "sliding window" or "rolling" calculations
  • Efficient O(1) operations at both ends
Initial (capacity 4):
[_, _, _, _]  (empty)

After push 1, 2, 3:
[1, 2, 3, _]

After push 4:
[1, 2, 3, 4]  (full)

After push 5 (overwrites oldest):
[5, 2, 3, 4] → conceptually [2, 3, 4, 5]

VecDeque Review

#![allow(unused)]
fn main() {
use std::collections::VecDeque;

let mut deque: VecDeque<i32> = VecDeque::new();

// Add to back (queue behavior)
deque.push_back(1);
deque.push_back(2);
deque.push_back(3);

println!("Deque: {:?}", deque);  // [1, 2, 3]

// Remove from front
let first = deque.pop_front();
println!("Popped: {:?}", first);  // Some(1)
println!("Deque: {:?}", deque);   // [2, 3]

// Also supports push_front and pop_back
deque.push_front(0);
println!("Deque: {:?}", deque);   // [0, 2, 3]
}

Implementing a Rolling Buffer

#![allow(unused)]
fn main() {
use std::collections::VecDeque;

struct RollingBuffer {
    buffer: VecDeque<f64>,
    capacity: usize,
}

impl RollingBuffer {
    fn new(capacity: usize) -> Self {
        RollingBuffer {
            buffer: VecDeque::with_capacity(capacity),
            capacity,
        }
    }
    
    fn push(&mut self, value: f64) {
        if self.buffer.len() == self.capacity {
            self.buffer.pop_front();  // Remove oldest
        }
        self.buffer.push_back(value);  // Add newest
    }
    
    fn mean(&self) -> Option<f64> {
        if self.buffer.is_empty() {
            None
        } else {
            let sum: f64 = self.buffer.iter().sum();
            Some(sum / self.buffer.len() as f64)
        }
    }
    
    fn is_full(&self) -> bool {
        self.buffer.len() == self.capacity
    }
}

// Example: Rolling average of last 3 values
let mut rolling = RollingBuffer::new(3);

for value in [10.0, 20.0, 30.0, 40.0, 50.0] {
    rolling.push(value);
    println!("After {}: mean = {:?}, full = {}", 
             value, rolling.mean(), rolling.is_full());
}
}

Rolling Statistics Applications

#![allow(unused)]
fn main() {
use std::collections::VecDeque;

struct RollingStats {
    buffer: VecDeque<f64>,
    capacity: usize,
}

impl RollingStats {
    fn new(capacity: usize) -> Self {
        RollingStats {
            buffer: VecDeque::with_capacity(capacity),
            capacity,
        }
    }
    
    fn push(&mut self, value: f64) {
        if self.buffer.len() == self.capacity {
            self.buffer.pop_front();
        }
        self.buffer.push_back(value);
    }
    
    fn mean(&self) -> Option<f64> {
        if self.buffer.is_empty() {
            return None;
        }
        let sum: f64 = self.buffer.iter().sum();
        Some(sum / self.buffer.len() as f64)
    }
    
    fn std_dev(&self) -> Option<f64> {
        if self.buffer.len() < 2 {
            return None;
        }
        let mean = self.mean()?;
        let variance: f64 = self.buffer.iter()
            .map(|&x| (x - mean).powi(2))
            .sum::<f64>() / (self.buffer.len() - 1) as f64;
        Some(variance.sqrt())
    }
}

// Detect anomalies using rolling statistics
let data = [100.0, 102.0, 98.0, 101.0, 150.0, 99.0, 103.0];
let mut stats = RollingStats::new(4);

for &value in &data {
    stats.push(value);
    if let (Some(mean), Some(std)) = (stats.mean(), stats.std_dev()) {
        let z_score = (value - mean).abs() / std;
        if z_score > 2.0 {
            println!("ANOMALY: {} (z-score: {:.2})", value, z_score);
        } else {
            println!("Normal: {} (mean: {:.1}, std: {:.1})", value, mean, std);
        }
    }
}
}

HW7 Connection: This is the RollingBuffer you'll implement in Part 3!

Summary: Collections for HW7

HW7 PartCollections UsedKey Patterns
Part 1HashMap, HashSetEntry API for counting, set operations
Part 2HashMapEntry API for grouping, split-apply-combine
Part 3BTreeMap, VecDequeOrderedFloat for keys, circular buffer

Key Takeaways

  1. Entry API eliminates double lookups - use it everywhere!
  2. BTreeMap when you need sorted keys or range queries
  3. ordered-float enables float keys in ordered collections
  4. VecDeque is perfect for fixed-size sliding windows

In-Class Exercise: Rolling Window Statistics

Task: Implement a function that computes a rolling mean over a data stream.

Given a stream of temperature readings and a window size, output the rolling mean after each reading.

Use Rust Playground or VSCode to develop your solution.

fn rolling_mean(data: &[f64], window_size: usize) -> Vec<f64> {
    // TODO: Implement using VecDeque
    todo!()
}

// Test it
let data = vec![20.0, 22.0, 21.0, 23.0, 25.0, 24.0];
let means = rolling_mean(&data, 3);

for (i, (val, mean)) in data.iter().zip(means.iter()).enumerate() {
    println!("Step {}: value={}, rolling_mean={:.1}", i, val, mean);
}
// Output:
// Step 0: value=20, rolling_mean=20.0  (window: [20])
// Step 1: value=22, rolling_mean=21.0  (window: [20, 22])
// Step 2: value=21, rolling_mean=21.0  (window: [20, 22, 21])
// Step 3: value=23, rolling_mean=22.0  (window: [22, 21, 23])
// Step 4: value=25, rolling_mean=23.0  (window: [21, 23, 25])
// Step 5: value=24, rolling_mean=24.0  (window: [23, 25, 24])

Bonus: Add detection of values more than 2 standard deviations from the rolling mean.

Solution
#![allow(unused)]
fn main() {
use std::collections::VecDeque;

fn rolling_mean(data: &[f64], window_size: usize) -> Vec<f64> {
    let mut buffer: VecDeque<f64> = VecDeque::with_capacity(window_size);
    let mut results = Vec::new();
    
    for &value in data {
        // If buffer is full, remove oldest value
        if buffer.len() == window_size {
            buffer.pop_front();
        }
        
        // Add new value
        buffer.push_back(value);
        
        // Calculate mean of current window
        let sum: f64 = buffer.iter().sum();
        let mean = sum / buffer.len() as f64;
        results.push(mean);
    }
    
    results
}

// Test it
let data = vec![20.0, 22.0, 21.0, 23.0, 25.0, 24.0];
let means = rolling_mean(&data, 3);

for (i, (val, mean)) in data.iter().zip(means.iter()).enumerate() {
    println!("Step {}: value={}, rolling_mean={:.1}", i, val, mean);
}
// Output:
// Step 0: value=20, rolling_mean=20.0  (window: [20])
// Step 1: value=22, rolling_mean=21.0  (window: [20, 22])
// Step 2: value=21, rolling_mean=21.0  (window: [20, 22, 21])
// Step 3: value=23, rolling_mean=22.0  (window: [22, 21, 23])
// Step 4: value=25, rolling_mean=23.0  (window: [21, 23, 25])
// Step 5: value=24, rolling_mean=24.0  (window: [23, 25, 24])
}

Bonus Solution (with anomaly detection):

#![allow(unused)]
fn main() {
use std::collections::VecDeque;

fn rolling_mean_with_anomaly(data: &[f64], window_size: usize) -> Vec<(f64, bool)> {
    let mut buffer: VecDeque<f64> = VecDeque::with_capacity(window_size);
    let mut results = Vec::new();
    
    for &value in data {
        // Check for anomaly BEFORE adding to buffer (compare to previous window)
        let is_anomaly = if buffer.len() >= 2 {
            let mean: f64 = buffer.iter().sum::<f64>() / buffer.len() as f64;
            let variance: f64 = buffer.iter()
                .map(|&x| (x - mean).powi(2))
                .sum::<f64>() / (buffer.len() - 1) as f64;
            let std_dev = variance.sqrt();
            
            // Z-score > 2 means anomaly
            std_dev > 0.0 && (value - mean).abs() / std_dev > 2.0
        } else {
            false
        };
        
        // Update buffer
        if buffer.len() == window_size {
            buffer.pop_front();
        }
        buffer.push_back(value);
        
        // Calculate current mean
        let mean = buffer.iter().sum::<f64>() / buffer.len() as f64;
        results.push((mean, is_anomaly));
    }
    
    results
}

// Test with an anomaly
let data = vec![20.0, 21.0, 20.0, 22.0, 50.0, 21.0];  // 50.0 is anomaly
let results = rolling_mean_with_anomaly(&data, 4);

for (i, (val, (mean, anomaly))) in data.iter().zip(results.iter()).enumerate() {
    let flag = if *anomaly { " ⚠️ ANOMALY!" } else { "" };
    println!("value={}: mean={:.1}{}", val, mean, flag);
}
}

Key insights:

  • VecDeque gives O(1) push_back and pop_front operations
  • The window naturally "slides" by removing oldest and adding newest
  • For anomaly detection, compare the new value against the previous window's statistics

Next Lecture Preview

In the next lecture, we'll cover:

  • Quantile and percentile calculations
  • Graph representation and traversal (BFS, DFS)
  • Algorithm design patterns

Algorithms for Data Science: Quantiles, Graphs, and Algorithm Design

About This Module

This module covers essential algorithms for data science applications. You'll learn quantile calculations for statistical analysis, graph representation and traversal algorithms (BFS/DFS), and algorithm design patterns including split-apply-combine, greedy algorithms, and divide-and-conquer approaches.

Prework

Prework Reading

Please read the following:

Pre-lecture Reflections

  1. What's the difference between percentile and quantile?
  2. Why might BFS find the shortest path in an unweighted graph?
  3. When would you use DFS vs BFS for graph exploration?
  4. What is the "greedy" approach to solving problems?

Learning Objectives

By the end of this module, you will be able to:

  • Calculate quantiles and percentiles using linear interpolation
  • Understand interquartile range (IQR) and its uses
  • Implement ranking algorithms (standard and dense rank)
  • Represent graphs using adjacency lists
  • Implement BFS and DFS traversals
  • Apply algorithm design patterns to data problems

Part 1: Quantiles and Statistical Algorithms

What are Quantiles?

Quantiles divide sorted data into equal parts:

  • Quartiles (4 parts): Q1 (25%), Q2/median (50%), Q3 (75%)
  • Percentiles (100 parts): P50 = median, P95 = 95th percentile
  • Deciles (10 parts): D1 (10%), D5 (50%), etc.
Sorted data: [1, 2, 3, 4, 5, 6, 7, 8, 9]

       Q1    Q2    Q3
       ↓     ↓     ↓
[1, 2, 3, 4, 5, 6, 7, 8, 9]
      25%   50%   75%

Calculating Quantiles: Linear Interpolation

For quantile q (0.0 to 1.0) on sorted data of length n:

position = q * (n - 1)
lower_idx = floor(position)
upper_idx = ceil(position)
fraction = position - lower_idx

if lower_idx == upper_idx:
    result = data[lower_idx]
else:
    result = data[lower_idx] * (1 - fraction) + 
             data[upper_idx] * fraction

Quantile Implementation

fn quantile(data: &[f64], q: f64) -> Option<f64> {
    if data.is_empty() || !(0.0..=1.0).contains(&q) {
        return None;
    }
    
    // Sort the data
    let mut sorted = data.to_vec();
    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
    
    // Calculate position
    let pos = q * (sorted.len() - 1) as f64;
    let lower = pos.floor() as usize;
    let upper = pos.ceil() as usize;
    //println!("For q: {}, index position is: {}, lower is: {}, upper is: {}", q, pos, lower, upper);
    //println!("lower f[{}]: {}, upper f[{}]: {}", lower, sorted[lower], upper, sorted[upper]);
    
    if lower == upper {
        Some(sorted[lower])
    } else {
        // Linear interpolation
        let fraction = pos - lower as f64;
        Some(sorted[lower] * (1.0 - fraction) + sorted[upper] * fraction)
    }
}

fn main() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
//let data = vec![-1.0, 2.0, 4.4, 6.7, 11.2, 22.8, 83.1, 124.7];

println!("Q1 (25%): {:?}", quantile(&data, 0.25));  // 2.5
println!("Q2 (50%): {:?}", quantile(&data, 0.50));  // 5.0
println!("Q3 (75%): {:?}", quantile(&data, 0.75));  // 7.5
println!("P90: {:?}", quantile(&data, 0.90));       // 8.2
}

HW7 Connection: This is the quantile() function in Part 3 specifically for f64 values!

Interquartile Range (IQR)

IQR = Q3 - Q1 measures the spread of the middle 50% of data.

Uses:

  • Less sensitive to outliers than using the range (max - min)
  • Outlier detection:
    • , or
#![allow(unused)]
fn main() {
fn iqr(data: &[f64]) -> Option<f64> {
    let q1 = quantile(data, 0.25)?;
    let q3 = quantile(data, 0.75)?;
    Some(q3 - q1)
}

fn quantile(data: &[f64], q: f64) -> Option<f64> {
    if data.is_empty() || !(0.0..=1.0).contains(&q) {
        return None;
    }
    let mut sorted = data.to_vec();
    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
    let pos = q * (sorted.len() - 1) as f64;
    let lower = pos.floor() as usize;
    let upper = pos.ceil() as usize;
    if lower == upper {
        Some(sorted[lower])
    } else {
        let fraction = pos - lower as f64;
        Some(sorted[lower] * (1.0 - fraction) + sorted[upper] * fraction)
    }
}

let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
let iqr_val = iqr(&data).unwrap();

let q1 = quantile(&data, 0.25).unwrap();
let q3 = quantile(&data, 0.75).unwrap();

println!("Q1: {}, Q3: {}, IQR: {}", q1, q3, iqr_val);

// Outlier bounds
let lower_bound = q1 - 1.5 * iqr_val;
let upper_bound = q3 + 1.5 * iqr_val;
println!("Outlier bounds: [{:.1}, {:.1}]", lower_bound, upper_bound);
}
Reminder: The `?` operator is used to propagate errors up the call stack.

  • It is equivalent to `return Err(e)` if the expression is an `Err(e)`.
  • It is equivalent to `return Ok(x)` if the expression is an `Ok(x)`.
  • It is equivalent to `return x` if the expression is a value.

IQR with outliers

Let's use a slightly more interesting dataset:

#![allow(unused)]
fn main() {
fn iqr(data: &[f64]) -> Option<f64> {
    let q1 = quantile(data, 0.25)?;
    let q3 = quantile(data, 0.75)?;
    Some(q3 - q1)
}

fn quantile(data: &[f64], q: f64) -> Option<f64> {
    if data.is_empty() || !(0.0..=1.0).contains(&q) {
        return None;
    }
    let mut sorted = data.to_vec();
    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
    let pos = q * (sorted.len() - 1) as f64;
    let lower = pos.floor() as usize;
    let upper = pos.ceil() as usize;
    if lower == upper {
        Some(sorted[lower])
    } else {
        let fraction = pos - lower as f64;
        Some(sorted[lower] * (1.0 - fraction) + sorted[upper] * fraction)
    }
}

let data = vec![-1.0, 2.0, 4.4, 6.7, 11.2, 22.8, 83.1, 124.7];
let iqr_val = iqr(&data).unwrap();

let q1 = quantile(&data, 0.25).unwrap();
let q3 = quantile(&data, 0.75).unwrap();

println!("Q1: {}, Q3: {}, IQR: {}", q1, q3, iqr_val);

// Outlier bounds
let lower_bound = q1 - 1.5 * iqr_val;
let upper_bound = q3 + 1.5 * iqr_val;
println!("Outlier bounds: [{:.1}, {:.1}]", lower_bound, upper_bound);
}

Ranking Algorithms

Standard Rank: Position in sorted order (ties get same rank, gaps follow)

Dense Rank: Position in sorted order (ties get same rank, no gaps)

Values:      [100, 95, 95, 90, 85]
Standard:    [  1,  2,  2,  4,  5]  ← gap after ties
Dense:       [  1,  2,  2,  3,  4]  ← no gaps

Standard and Dense Ranking in Sports

Out of curiosity, I asked Anthropic Opus 4.5 to find examples of standard and dense ranking in sports.

Standard Competition Ranking (1, 2, 2, 4)Skips positions after ties

Most individual sports and races use this method:

  • Golf ⛳ — The classic example. You'll see "T2" (tied for 2nd) on leaderboards, and the next player is listed as 4th if two players tied for 2nd. This emphasizes that a player finished ahead of X competitors.

  • Tennis (ATP/WTA rankings) 🎾 — Points-based rankings, but when ties occur in tournament results, standard ranking applies.

  • Olympic events 🏅 — Track & field, swimming, skiing, etc. If two athletes tie for silver, no bronze is awarded (they give two silvers). The next finisher is 4th.

  • Marathon / Running races 🏃 — If two runners tie for 2nd, the next finisher is 4th place.

  • Horse racing 🐎 — Finish positions follow standard ranking.

  • Cycling (race stages) 🚴 — Stage finishes use standard ranking.


Dense Ranking (1, 2, 2, 3)Consecutive positions, no gaps

Less common in sports, but used in some contexts:

  • Soccer/Football league tables ⚽ — While ties on points are typically broken by goal difference (so ties are rare), some leagues display positions using dense-style numbering during the season.

  • Some fitness leaderboards — Particularly in CrossFit or gym competitions where continuous ranking is preferred.

  • Some esports standings — Varies by organization.


Key Insight

The distinction often comes down to what the rank is meant to communicate:

Standard RankDense Rank
"How many competitors finished ahead of you?""What tier/bracket are you in?"
Emphasizes individual achievementEmphasizes grouping/classification

Golf's use of standard ranking makes intuitive sense: if you tied for 2nd, there's still only one person who beat you, but two people share a position ahead of the 4th-place finisher—so that finisher had 3 people beat them.

Implementing Dense Rank

#![allow(unused)]
fn main() {
fn dense_rank(data: &[f64]) -> Vec<usize> {
    if data.is_empty() {
        return vec![];
    }
    
    // Create (index, value) pairs and sort by value
    let mut indexed: Vec<(usize, f64)> = data.iter()
        .enumerate()           // produces iter of (index, &value) pairs
        .map(|(i, &v)| (i, v)) // extract index and dereference value
        .collect();
    
    // sort by the values (second element of the tuples)
    indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
    
    // Assign dense ranks
    let mut ranks = vec![0; data.len()];
    let mut current_rank = 0;
    let mut prev_value: Option<f64> = None;
    
    for &(original_idx, value) in &indexed {
        if let Some(prev) = prev_value {
            // compare with some small epsilon to avoid floating point
            // precision issues (e.g. 1.0 and 1.0000000000000001)
            if (value - prev).abs() > 1e-10 { 
                current_rank += 1;  // Only increment for new values
            }
        }
        ranks[original_idx] = current_rank;
        prev_value = Some(value);
    }
    
    ranks
}

let scores = vec![85.0, 95.0, 90.0, 95.0, 80.0];
let ranks = dense_rank(&scores);

for (score, rank) in scores.iter().zip(ranks.iter()) {
    println!("Score: {}, Rank: {}", score, rank);
}
}

HW7 Connection: This is the dense_rank() function in Part 3!

Part 2: Graph Representation

What is a Graph?

A graph G = (V, E) consists of:

  • Vertices (V): nodes/points
  • Edges (E): connections between vertices
    0 --- 1
    |     |
    |     |
    3 --- 2

Vertices: {0, 1, 2, 3}
Edges: {(0,1), (1,2), (2,3), (3,0)}

Adjacency List Representation

Store graph as a list of neighbors for each vertex:

#![allow(unused)]
fn main() {
use std::collections::HashMap;

// Using Vec<Vec<usize>>
fn create_graph_vec(n: usize, edges: &[(usize, usize)]) -> Vec<Vec<usize>> {
    let mut adj = vec![vec![]; n];
    for &(u, v) in edges {
        adj[u].push(v);
        adj[v].push(u);  // For undirected graph
    }
    adj
}

// Using HashMap for sparse or labeled graphs
fn create_graph_map<'a>(edges: &[(&'a str, &'a str)]) -> HashMap<&'a str, Vec<&'a str>> {
    let mut adj: HashMap<&'a str, Vec<&'a str>> = HashMap::new();
    for &(u, v) in edges {
        adj.entry(u).or_default().push(v);
        adj.entry(v).or_default().push(u);
    }
    adj
}

// Example: Square graph
let edges = vec![(0, 1), (1, 2), (2, 3), (3, 0)];

let graph = create_graph_vec(4, &edges);
for (vertex, neighbors) in graph.iter().enumerate() {
    println!("Vertex {}: neighbors = {:?}", vertex, neighbors);
}

let edges_map = vec![("0", "1"), ("1", "2"), ("2", "3"), ("3", "0")];

let graph_map = create_graph_map(&edges_map);
for (vertex, neighbors) in graph_map.iter() {
    println!("Vertex {}: neighbors = {:?}", vertex, neighbors);
}

}

When to Use Each Representation

RepresentationBest ForLookupMemory
Vec<Vec<usize>>Dense graphs, integer verticesO(1)O(V + E)
HashMap<K, Vec<K>>Sparse graphs, labeled verticesO(1) avgO(V + E)

Part 3: Graph Traversal with BFS and DFS

Breadth-First Search (BFS)

BFS explores nodes level by level using a queue (VecDeque):

Graph:              BFS from vertex 0:
                    
    0               Level 0: [0]
   / \              Level 1: [1, 3]  (neighbors of 0)
  1   3             Level 2: [2]     (unvisited neighbors)
   \ /              
    2               Visit order: 0 → 1 → 3 → 2

BFS Implementation

This BFS implementation uses a HashSet to track visited nodes and a VecDeque as a FIFO queue. Starting from a given vertex, it repeatedly dequeues the front node, marks it visited, and enqueues all unvisited neighbors. The algorithm returns nodes in the order they were first discovered, which corresponds to visiting vertices level by level outward from the start.

#![allow(unused)]
fn main() {
use std::collections::{VecDeque, HashSet};

fn bfs(graph: &[Vec<usize>], start: usize) -> Vec<usize> {
    let mut visited = HashSet::new();
    let mut queue = VecDeque::new();
    let mut order = Vec::new();
    
    queue.push_back(start);
    visited.insert(start);
    
    while let Some(current) = queue.pop_front() {
        order.push(current);
        
        for &neighbor in &graph[current] {
            if !visited.contains(&neighbor) {
                visited.insert(neighbor);
                queue.push_back(neighbor);
            }
        }
    }
    
    order
}

// Square graph with diagonal
//   0 --- 3
//   |     |
//   |     |
//   1 --- 2

let graph = vec![
    vec![1, 3],     // 0
    vec![0, 2],     // 1
    vec![1, 3],     // 2
    vec![0, 2],     // 3
];

let order = bfs(&graph, 0);
println!("BFS order from 0: {:?}", order);
}

Note: VecDeque is essential for O(1) queue operations!

BFS for Shortest Path (Unweighted)

Why does BFS find shortest paths? Because BFS explores nodes level by level, the first time we reach any node is guaranteed to be via the shortest path. When we visit a node at distance d from the start, we've already visited all nodes at distances 0, 1, ..., d-1. This means we can't later find a shorter path to that node.

Key insight: In an unweighted graph, "shortest path" means fewest edges. BFS naturally discovers nodes in order of increasing distance from the start.

#![allow(unused)]
fn main() {
use std::collections::{VecDeque, HashMap};

fn bfs_distances(graph: &[Vec<usize>], start: usize) -> HashMap<usize, usize> {
    let mut distances = HashMap::new();
    let mut queue = VecDeque::new();
    
    queue.push_back(start);
    distances.insert(start, 0);
    
    while let Some(current) = queue.pop_front() {
        let current_dist = distances[&current];
        
        for &neighbor in &graph[current] {
            if !distances.contains_key(&neighbor) {
                distances.insert(neighbor, current_dist + 1);
                queue.push_back(neighbor);
            }
        }
    }
    
    distances
}

let graph = vec![
    vec![1, 3],     // 0
    vec![0, 2],     // 1
    vec![1, 3],     // 2
    vec![0, 2],     // 3
];

let distances = bfs_distances(&graph, 0);
for (node, dist) in &distances {
    println!("Distance from 0 to {}: {}", node, dist);
}
}

Depth-First Search (DFS)

DFS explores as deep as possible first using a stack (Vec or recursion):

Graph:              DFS from vertex 0:
                    
    0               Step 1: Visit 0, push neighbors [1,3]
   / \              Step 2: Pop 1, visit it, push neighbor [2]
  1   3             Step 3: Pop 2, visit it, push neighbor [3]
   \ /              Step 4: Pop 3, visit it (no new neighbors)
    2               
                    Visit order: 0 → 1 → 2 → 3
                    (Goes deep before exploring siblings)

DFS Implementation (Iterative)

This iterative DFS uses a Vec as a LIFO stack and a HashSet to track visited nodes. Starting from a given vertex, it pops the top node, marks it visited if not already seen, and pushes all unvisited neighbors onto the stack. Neighbors are added in reverse order to maintain consistent left-to-right traversal. The algorithm explores as deep as possible along each branch before backtracking.

#![allow(unused)]
fn main() {
use std::collections::HashSet;

fn dfs(graph: &[Vec<usize>], start: usize) -> Vec<usize> {
    let mut visited = HashSet::new();
    let mut stack = vec![start];  // Use Vec as stack
    let mut order = Vec::new();
    
    while let Some(current) = stack.pop() {
        if visited.contains(&current) {
            continue;
        }
        
        visited.insert(current);
        order.push(current);
        
        // Add neighbors to stack (reverse for consistent ordering)
        for &neighbor in graph[current].iter().rev() {
            if !visited.contains(&neighbor) {
                stack.push(neighbor);
            }
        }
    }
    
    order
}

let graph = vec![
    vec![1, 3],     // 0
    vec![0, 2],     // 1
    vec![1, 3],     // 2
    vec![0, 2],     // 3
];

let order = dfs(&graph, 0);
println!("DFS order from 0: {:?}", order);
}

BFS vs DFS Summary

FeatureBFSDFS
Data StructureQueue (VecDeque)Stack (Vec)
OrderLevel by levelDeep first
Shortest path✅ (unweighted)
MemoryO(width)O(depth)
Use caseShortest path, levelsCycle detection, components

Part 4: Algorithm Design Patterns

We'll cover the following patterns:

  • Split-Apply-Combine
  • Greedy Algorithms
  • Divide and Conquer

Let's start with the first pattern: Split-Apply-Combine.

Pattern 1: Split-Apply-Combine

Already covered in HW7 Part 2 with GroupedSeries:

1. SPLIT: Group data by category
2. APPLY: Calculate aggregate per group
3. COMBINE: Collect results

data = [(A, 10), (B, 20), (A, 30), (B, 40)]
         ↓ SPLIT
groups = {A: [10, 30], B: [20, 40]}
         ↓ APPLY (mean)
means = {A: 20.0, B: 30.0}
         ↓ COMBINE
result = HashMap with means

Pattern 2: Greedy Algorithms

Greedy: Make the locally optimal choice at each step.

Example: Coin change (when it works)

#![allow(unused)]
fn main() {
fn greedy_coin_change(amount: u32, coins: &[u32]) -> Vec<u32> {
    let mut result = Vec::new();
    let mut remaining = amount;
    
    // Sort coins in descending order
    let mut sorted_coins = coins.to_vec();
    sorted_coins.sort_by(|a, b| b.cmp(a));
    
    for &coin in &sorted_coins {
        while remaining >= coin {
            result.push(coin);
            remaining -= coin;
        }
    }
    
    result
}

let coins = vec![25, 10, 5, 1];  // US coins
let change = greedy_coin_change(67, &coins);
println!("67 cents: {:?}", change);  // [25, 25, 10, 5, 1, 1]
}

Greedy Coin Change is Not Always Optimal

Warning: Greedy doesn't always give optimal solutions!

The greedy approach to the coin change problem is not always optimal when the coin denominations are not in a canonical system. A canonical system is a system of coin denominations where each denomination is at least twice the value of the next smaller denomination.

For example, consider the coin denominations [25, 15, 1] and we want to make change of 30 cents.

#![allow(unused)]
fn main() {
fn greedy_coin_change(amount: u32, coins: &[u32]) -> Vec<u32> {
    let mut result = Vec::new();
    let mut remaining = amount;
    
    // Sort coins in descending order
    let mut sorted_coins = coins.to_vec();
    sorted_coins.sort_by(|a, b| b.cmp(a));
    
    for &coin in &sorted_coins {
        while remaining >= coin {
            result.push(coin);
            remaining -= coin;
        }
    }
    
    result
}

let coins = vec![25, 15, 1];
let change = greedy_coin_change(30, &coins);
println!("30 cents: {:?}", change);  // [25, 1, 1, 1, 1, 1]
}

Pattern 3: Divide and Conquer

Divide and Conquer:

  1. Divide problem into smaller subproblems
  2. Conquer subproblems recursively
  3. Combine solutions

Classic example: Binary Search

#![allow(unused)]
fn main() {
fn binary_search(sorted: &[i32], target: i32) -> Option<usize> {
    let mut left = 0;
    let mut right = sorted.len();
    
    while left < right {
        let mid = left + (right - left) / 2;
        
        match sorted[mid].cmp(&target) {
            std::cmp::Ordering::Equal => return Some(mid),
            std::cmp::Ordering::Less => left = mid + 1,
            std::cmp::Ordering::Greater => right = mid,
        }
    }
    
    None
}

let data = vec![1, 3, 5, 7, 9, 11, 13, 15];
println!("Index of 7: {:?}", binary_search(&data, 7));   // Some(3)
println!("Index of 8: {:?}", binary_search(&data, 8));   // None
}

If we just searched item by item we would need O(n) time. Binary search gives us O(log n) time assuming the data is sorted which we get if we use a sorted data structure like BTreeMap. Otherwise we would need to sort the data first which is O(n log n) time.

Algorithm Design Summary

PatternKey IdeaWhen to Use
Split-Apply-CombineGroup, aggregate, collectData aggregation by category
GreedyBest local choiceOptimization with greedy property
Divide & ConquerSplit, solve, mergeProblems with optimal substructure

Summary: HW7 Algorithm Connections

HW7 ComponentConcepts Used
FrequencyTableCounting, Entry API
GroupedSeriesSplit-apply-combine, closures
HistogramBTreeMap, binning
quantile/iqrSorting, interpolation
RollingBufferVecDeque, circular buffer
rank/dense_rankSorting, index tracking

Key Takeaways

  1. Quantiles require sorted data and linear interpolation
  2. IQR is robust to outliers (Q3 - Q1)
  3. BFS uses VecDeque, finds shortest paths
  4. DFS uses Vec as stack, explores deeply
  5. Algorithm patterns help structure solutions

In-Class Exercise: Outlier Detection

Task: Implement a function that finds all outliers in a dataset using the IQR method covered earlier in this lecture.

Recall: A value is an outlier if it falls outside the bounds:

  • Lower bound: Q1 - 1.5 × IQR
  • Upper bound: Q3 + 1.5 × IQR
fn find_outliers(data: &[f64]) -> Vec<f64> {
    // TODO: Return a Vec containing all outlier values
    // Hint: You can use the quantile() function from earlier
    
    // Step 1: Calculate Q1 and Q3
    
    // Step 2: Calculate IQR
    
    // Step 3: Calculate bounds
    
    // Step 4: Filter and collect outliers
    
    todo!()
}

// Example:
// data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0]
// Q1 = 2.25, Q3 = 6.25, IQR = 4.0
// Lower bound = 2.25 - 6.0 = -3.75
// Upper bound = 6.25 + 6.0 = 12.25
// Output: [100.0]  (only 100.0 is outside the bounds)

Hints:

  1. First implement or copy the quantile() function from the slides
  2. Use .iter().filter().cloned().collect() to find values outside bounds
  3. Remember to handle the empty data case
Solution
#![allow(unused)]
fn main() {
fn quantile(data: &[f64], q: f64) -> Option<f64> {
    if data.is_empty() || !(0.0..=1.0).contains(&q) {
        return None;
    }
    
    let mut sorted = data.to_vec();
    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
    
    let pos = q * (sorted.len() - 1) as f64;
    let lower = pos.floor() as usize;
    let upper = pos.ceil() as usize;
    
    if lower == upper {
        Some(sorted[lower])
    } else {
        let fraction = pos - lower as f64;
        Some(sorted[lower] * (1.0 - fraction) + sorted[upper] * fraction)
    }
}

fn find_outliers(data: &[f64]) -> Vec<f64> {
    if data.is_empty() {
        return vec![];
    }
    
    // Step 1: Calculate Q1 and Q3
    let q1 = quantile(data, 0.25).unwrap();
    let q3 = quantile(data, 0.75).unwrap();
    
    // Step 2: Calculate IQR
    let iqr = q3 - q1;
    
    // Step 3: Calculate bounds
    let lower_bound = q1 - 1.5 * iqr;
    let upper_bound = q3 + 1.5 * iqr;
    
    // Step 4: Filter and collect outliers
    data.iter()
        .filter(|&&x| x < lower_bound || x > upper_bound)
        .cloned()
        .collect()
}

// Test it
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0];
let outliers = find_outliers(&data);

let q1 = quantile(&data, 0.25).unwrap();
let q3 = quantile(&data, 0.75).unwrap();
let iqr = q3 - q1;

println!("Q1: {}, Q3: {}, IQR: {}", q1, q3, iqr);
println!("Lower bound: {}", q1 - 1.5 * iqr);
println!("Upper bound: {}", q3 + 1.5 * iqr);
println!("Outliers: {:?}", outliers);

// Test with data that has outliers on both ends
let data2 = vec![-50.0, 10.0, 12.0, 14.0, 15.0, 16.0, 18.0, 200.0];
println!("\nData with outliers on both ends:");
println!("Outliers: {:?}", find_outliers(&data2));
}

Key insights:

  • Reuses the quantile() function from earlier in the lecture
  • The filter closure uses &&x because we're iterating over &f64 references
  • .cloned() converts &f64 to f64 before collecting
  • This pattern (compute statistics, then filter) is common in data science

HW7 Brings It All Together

HW7 combines everything:

  • Generics and traits (Numeric)
  • Collections (HashMap, HashSet, BTreeMap, VecDeque)
  • Closures (aggregation functions)
  • Iterators (data processing)
  • Algorithm design (statistics, grouping)

Good luck on HW7!

A1 FA25 Final Exam Review

Table of Contents:

Suggested way to use this review material

  1. The material is organized by major topics.
  2. For each topic, there are:
    • high level overview
    • examples
    • true/false questions
    • find the bug questions
    • predict the output questions
    • coding challenges
  3. Try to answer the questions without peeking at the solutions.
  4. This material focuses on the topics covered in the final third of the course, building on what you learned for midterms 1 and 2.

Exam Format:

The exam will be in four parts:

  • Part 1 (10 pts): 5 questions, 2 points each -- select all that are true
  • Part 2 (16 pts): 4 questions, 4 points each -- find the bug in the code and fix it
  • Part 3 (12 pts): 4 questions, 3 points each -- Predict the output and explain why
  • Part 4 (12 pts): 2 questions, 6 points each -- hand-coding problems

Total Points: 50

Suggested time budget for each part:

  • Part 1: (~10 min)
  • Part 2: (~16 min)
  • Part 3: (~12 min)
  • Part 4: (~22 min)

for a total of 60 minutes and then another 60 minutes to check your work (if needed).


Preliminaries

The final exam is cumulative but emphasizes the material from the final third of the course. You should be comfortable with:

  • Basic Rust syntax (functions, variables, types) (see midterm 1 review)
  • Structs, enums, and pattern matching
  • Ownership, borrowing, and references
  • Generics and traits
  • Iterators and closures

See a1 midterm 2 review for more details.

This review focuses on new material: collections (HashMap, HashSet, BTreeMap, VecDeque) and algorithm complexity.

References and Dereferencing

When References Are Created

References are created with &:

#![allow(unused)]
fn main() {
let x = 5;
let r = &x;      // r is &i32
let s = "hello"; // s is already &str (string slice)
let v = vec![1, 2, 3];
let slice = &v[..]; // slice is &[i32]
}

And are common patterns in Rust code. For example, to sum a slice of integers:

#![allow(unused)]
fn main() {
fn process_ints(ints: &[i32]) -> i32 {
    let mut sum = 0;
    for int in ints {
        sum += *int;
    }
    sum
}

let ints = [1, 2, 3];
println!("sum: {}", process_ints(&ints));
}

When Double References (&&) Occur

Double references commonly appear when:

Iterating over a slice of references:

#![allow(unused)]
fn main() {
fn process(words: &[&str]) {
    for word in words {  // word is &&str

        // Rust auto-dereferences `word: &&str` to `word: &str`
        print!("word: {}, len: {} ", word, word.len());
    }
    println!();
}

let words = vec!["art", "bees"];
process(&words);
}

Automatic Dereferencing

Rust automatically dereferences in several situations:

1. Method calls (auto-deref):

#![allow(unused)]
fn main() {
let s = String::from("hello");
let r = &s;
let rr = &&s;
// All of these work - Rust auto-derefs to call len()
s.len();   // String::len(&s)
r.len();   // auto-derefs &String to String
rr.len();  // auto-derefs &&String through &String to String
}

2. Deref coercion in function arguments:

#![allow(unused)]
fn main() {
fn print_len(s: &str) { println!("{}", s.len()); }

let owned = String::from("hello");
print_len(&owned);  // &String coerces to &str automatically
print_len("hello"); // Already a &str
}

3. Comparison operators:

#![allow(unused)]
fn main() {
let x = 5;
let r = &x;
// assert!(r == 5); // ERROR! r is a reference, not a value
assert!(r == &5);  // Compares values, not addresses, but types must match
assert!(*r == 5);  // Explicit deref to i32 also works
}

When Explicit Dereferencing (*) Is Required

1. Assigning to or modifying the underlying value:

#![allow(unused)]
fn main() {
let mut x = 5;
let r = &mut x;
*r += 1;  // Must deref to modify x
println!("x: {}", x);
}

2. When types don't match and coercion doesn't apply:

#![allow(unused)]
fn main() {
let words: &[&str] = &["a", "b"];
for word in words {
    // word is &&str, but HashMap wants &str as key
    let key: &str = *word;  // Explicit deref needed
}
}

3. Using entry() or insert() with reference keys:

#![allow(unused)]
fn main() {
use std::collections::HashMap;
fn count<'a>(words: &[&'a str]) -> HashMap<&'a str, i32> {
    let mut map = HashMap::new();
    for word in words {       // word: &&str
        *map.entry(*word)     // *word dereferences &&str to &str
            .or_insert(0) += 1;
    }
    map
}

let words = vec!["a", "b"];
println!("{:?}", count(&words));
println!("{:?}", words);
}

Quick Reference Table

SituationTypeNeed explicit *?
Method calls&T, &&T, etc.No (auto-deref)
Deref coercion (&String&str)Function argsNo
Modifying through &mut T*r = valueYes
HashMap key from &&strentry(*word)Yes
Pattern matching&x patternAlternative to *

1. HashMap and the Entry API

Module(s)

Quick Review

HashMap<K, V> is a hash table that maps keys to values:

  • Keys must implement Hash and Eq traits
  • O(1) average lookup, insertion, and deletion
  • Does NOT maintain insertion order
  • f64 cannot be used directly as a key (doesn't implement Hash due to NaN)

Key Methods:

  • insert(key, value) - inserts or overwrites
  • get(&key) - returns Option<&V>
  • get_mut(&key) - returns Option<&mut V>
  • contains_key(&key) - returns bool
  • remove(&key) - removes and returns Option<V>

The Entry API is the idiomatic way to insert-or-update:

#![allow(unused)]
fn main() {
*map.entry(key).or_insert(default) += 1;
}
  • .entry(key) returns an Entry enum, which can be either Occupied or Vacant
  • Entry API methods:
    • or_insert(default) inserts the default value if the key is not present
    • or_insert_with(f) inserts the value returned by the function if the key is not present
    • or_default() inserts the default value for the type if the key is not present
    • and_modify(f) modifies the value if the key is present

Examples

#![allow(unused)]
fn main() {
use std::collections::HashMap;

// Basic HashMap usage
let mut scores = HashMap::new();

// .insert returns None if the key was not in the map
let mut result = scores.insert("Alice", 85);
println!("result: {:?}", result); // None, because "Alice" was not in the map

// .insert() returns Some(&value), where value is the old value if the key was
// already in the map
result = scores.insert("Alice", 87);
println!("result: {:?}", result); // Some(&85)

scores.insert("Bob", 90);

// get() returns Option
println!("scores.get(\"Alice\"): {:?}", scores.get("Alice"));  // Some(&85)
println!("scores.get(\"Carol\"): {:?}", scores.get("Carol"));  // None

// unwrap_or provides a default
println!("scores.get(\"Carol\").unwrap_or(&0): {:?}", scores.get("Carol").unwrap_or(&0));  // &0
}
#![allow(unused)]
fn main() {
use std::collections::HashMap;

// Entry API for counting
let mut word_counts = HashMap::new();
for word in ["apple", "banana", "apple"] {
    *word_counts.entry(word).or_insert(0) += 1;
}
// word_counts: {"apple": 2, "banana": 1}
println!("word_counts: {:?}", word_counts);
}
#![allow(unused)]
fn main() {
use std::collections::HashMap;

// Entry API - or_insert only inserts if key is missing
let mut map = HashMap::new();
*map.entry("a").or_insert(0) += 1;  // a = 1
*map.entry("a").or_insert(10) += 1; // a = 2 (10 is NOT used, key exists)
println!("map: {:?}", map);
}

True/False Questions

  1. T/F: Keys in a HashMap must implement the Hash and Eq traits.

  2. T/F: HashMap maintains insertion order of elements.

  3. T/F: f64 can be used directly as a HashMap key.

  4. T/F: The entry() API allows efficient insert-or-update operations.

  5. T/F: map.get(&key) returns V directly.

  6. T/F: Looking up a value by key in a HashMap is O(1) on average.

Answers
  1. True - HashMap requires Hash and Eq traits for keys
  2. False - HashMap does not maintain insertion order (use IndexMap for that)
  3. False - f64 doesn't implement Hash due to NaN issues; use OrderedFloat
  4. True - The entry() API is designed for efficient insert-or-update patterns
  5. False - get() returns Option<&V>, not V directly
  6. True - HashMap lookup is O(1) average case

Find the Bug

Question 1:

#![allow(unused)]
fn main() {
use std::collections::HashMap;

fn count_words<'a>(words: &[&'a str]) -> HashMap<&'a str, i32> {
    let mut counts = HashMap::new();
    for word in words {
        counts.entry(word).or_insert(0) += 1;
    }
    counts
}
}
Answer

Bug: We need to dereference the key word to get the &str, not the &&str and then dereference counts... so we can modify the value.

Fix:

#![allow(unused)]
fn main() {
use std::collections::HashMap;

fn count_words<'a>(words: &[&'a str]) -> HashMap<&'a str, i32> {
    let mut counts = HashMap::new();
    for word in words {
        *counts.entry(*word).or_insert(0) += 1;
    }
    counts
}
}

Question 2:

#![allow(unused)]
fn main() {
use std::collections::HashMap;

fn merge_maps(map1: HashMap<String, i32>, map2: HashMap<String, i32>) -> HashMap<String, i32> {
    let mut result = map1;
    for (key, value) in map2 {
        result.insert(key, result.get(&key).unwrap() + value);
    }
    result
}
}
Answer

Bug: Using get(&key).unwrap() on a key that might not exist in result (map1). If a key from map2 is not in map1, this panics.

Fix:

#![allow(unused)]
fn main() {
use std::collections::HashMap;

fn merge_maps(map1: HashMap<String, i32>, map2: HashMap<String, i32>) -> HashMap<String, i32> {
    let mut result = map1;
    for (key, value) in map2 {
        *result.entry(key).or_insert(0) += value;
    }
    result
}
}

Predict the Output

Question 1:

use std::collections::HashMap;

fn main() {
    let mut scores = HashMap::new();
    scores.insert("Alice", 85);
    scores.insert("Bob", 90);
    scores.insert("Alice", 95);
    
    let alice_score = scores.get("Alice").unwrap_or(&0);
    let carol_score = scores.get("Carol").unwrap_or(&0);
    
    println!("{} {}", alice_score, carol_score);
}
Answer

Output: 95 0

Reasoning:

  • "Alice" is inserted twice. The second insert (95) overwrites the first (85).
  • get("Alice") returns Some(&95), unwrap_or gives 95
  • get("Carol") returns None, unwrap_or provides default &0

Question 2:

use std::collections::HashMap;

fn main() {
    let mut map: HashMap<&str, i32> = HashMap::new();
    
    *map.entry("a").or_insert(0) += 1;
    *map.entry("b").or_insert(5) += 1;
    *map.entry("a").or_insert(10) += 1;
    
    let a = map.get("a").unwrap();
    let b = map.get("b").unwrap();
    println!("{} {}", a, b);
}
Answer

Output: 2 6

Reasoning:

  • First entry("a"): key doesn't exist, inserts 0, then += 1 → a = 1
  • entry("b"): key doesn't exist, inserts 5, then += 1 → b = 6
  • Second entry("a"): key exists (value 1), or_insert(10) does NOT insert, just returns &mut to existing value, then += 1 → a = 2

Coding Challenge

Challenge: Implement most_frequent

Write a function that takes a slice of integers and returns the value that appears most frequently. Return None if the slice is empty.

use std::collections::HashMap;

fn most_frequent(numbers: &[i32]) -> Option<i32> {
    // Your code here
}

fn main() {
    let nums = vec![1, 2, 2, 3, 3, 3, 4];
    println!("{:?}", most_frequent(&nums)); // Should print Some(3)
    println!("{:?}", most_frequent(&[]));   // Should print None
}
Solution
use std::collections::HashMap;

fn most_frequent(numbers: &[i32]) -> Option<i32> {
    if numbers.is_empty() {
        return None;
    }
    
    let mut counts = HashMap::new();
    for &num in numbers {
        *counts.entry(num).or_insert(0) += 1;
    }
    
    counts.into_iter()
        .max_by_key(|&(_, count)| count)
        .map(|(num, _)| num)
}

fn main() {
    let nums = vec![1, 2, 2, 3, 3, 3, 4];
    println!("{:?}", most_frequent(&nums)); // Should print Some(3)
    println!("{:?}", most_frequent(&[]));   // Should print None
}

2. HashSet and Set Operations

Quick Review

HashSet stores unique values:

  • Elements must implement Hash and Eq traits
  • O(1) average lookup, insertion, deletion
  • Automatically removes duplicates
  • Does NOT maintain insertion order

Key Methods:

  • insert(value) - returns bool (true if new)
  • contains(&value) - returns bool (true if value is in the set)
  • remove(&value) - returns bool (true if value was in the set)

Set Operations:

  • intersection(&other) - returns a set with elements in both sets
  • union(&other) - returns a set with elements in either set
  • difference(&other) - returns a set with elements in self but not other
  • symmetric_difference(&other) - returns a set with elements in one but not both

Examples

#![allow(unused)]
fn main() {
use std::collections::HashSet;

// Creating HashSets
let set1: HashSet<i32> = vec![1, 2, 3, 4].into_iter().collect();
let set2: HashSet<i32> = vec![3, 4, 5, 6].into_iter().collect();

// Set operations
let inter: HashSet<_> = set1.intersection(&set2).copied().collect();
println!("inter: {:?}", inter); // inter = {3, 4}

let uni: HashSet<_> = set1.union(&set2).copied().collect();
println!("uni: {:?}", uni); // uni = {1, 2, 3, 4, 5, 6}

let diff: HashSet<_> = set1.difference(&set2).copied().collect();
println!("diff: {:?}", diff); // diff = {1, 2} (in set1 but not set2)

let sym_diff: HashSet<_> = set1.symmetric_difference(&set2).copied().collect();
println!("sym_diff: {:?}", sym_diff); // sym_diff = {1, 2, 5, 6}

// Checking membership
let has_three = set1.contains(&3);  // true
println!("has_three: {}", has_three);

// HashSet for uniqueness
let words = vec!["apple", "banana", "apple", "cherry"];
let unique: HashSet<_> = words.into_iter().collect();
println!("unique: {:?}", unique); // unique = {"apple", "banana", "cherry"}
}

True/False Questions

  1. T/F: HashSet automatically removes duplicate values.

  2. T/F: Elements in a HashSet must implement Hash and Eq traits.

  3. T/F: HashSet maintains elements in sorted order.

  4. T/F: The intersection() method returns elements common to two sets.

  5. T/F: Checking if an element exists in a HashSet is O(n).

Answers
  1. True - HashSet stores only unique values
  2. True - HashSet requires Hash and Eq traits for elements
  3. False - HashSet doesn't maintain any particular order (use BTreeSet for sorted)
  4. True - intersection() returns elements present in both sets
  5. False - HashSet lookup is O(1) average, not O(n)

Find the Bug

Question:

#![allow(unused)]
fn main() {
use std::collections::HashSet;

fn find_common<T: PartialEq>(set1: &HashSet<T>, set2: &HashSet<T>) -> HashSet<T> {
    set1.intersection(set2).cloned().collect()
}
}
Answer

Bug: HashSet requires Hash + Eq trait bounds, not just PartialEq. The function also needs Clone for .cloned().

Fix:

#![allow(unused)]
fn main() {
use std::collections::HashSet;
use std::hash::Hash;

fn find_common<T: Hash + Eq + Clone>(set1: &HashSet<T>, set2: &HashSet<T>) -> HashSet<T> {
    set1.intersection(set2).cloned().collect()
}
}

Predict the Output

Question:

use std::collections::HashSet;

fn main() {
    let set1: HashSet<&str> = vec!["apple", "banana", "cherry"].into_iter().collect();
    let set2: HashSet<&str> = vec!["cherry", "date", "elderberry"].into_iter().collect();
    
    let inter: HashSet<_> = set1.intersection(&set2).copied().collect();
    println!("inter: {:?}", inter);

    let diff: HashSet<_> = set1.difference(&set2).copied().collect();
    println!("diff: {:?}", diff);

    let sym_diff: HashSet<_> = set1.symmetric_difference(&set2).copied().collect();
    println!("sym_diff: {:?}", sym_diff);
    
    println!("{} {} {}", inter.len(), diff.len(), sym_diff.len());
}
Answer

Output:

inter: {"cherry"}
diff: {"banana", "apple"}
sym_diff: {"elderberry", "apple", "date", "banana"}
1 2 4

Reasoning:

  • set1: {"apple", "banana", "cherry"}
  • set2: {"cherry", "date", "elderberry"}
  • intersection: {"cherry"} → length = 1
  • difference (in set1 but not set2): {"apple", "banana"} → length = 2
  • symmetric difference: {"apple", "banana", "date", "elderberry"} → length = 4

Coding Challenge

Challenge: Find duplicates

Write a function that takes a slice of integers and returns a Vec containing only the values that appear more than once. The result should not contain duplicates itself.

use std::collections::{HashMap, HashSet};

fn find_duplicates(numbers: &[i32]) -> Vec<i32> {
    // Your code here
}

fn main() {
    let nums = vec![1, 2, 2, 3, 3, 3, 4, 5, 5];
    println!("{:?}", find_duplicates(&nums)); // [2, 3, 5] (order may vary)
}
Solution
use std::collections::HashSet;

fn find_duplicates(numbers: &[i32]) -> Vec<i32> {
    let mut seen = HashSet::new();
    let mut duplicates = HashSet::new();
    
    for &num in numbers {
        if !seen.insert(num) {
            // insert returns false if value already existed
            duplicates.insert(num);
        }
    }
    
    duplicates.into_iter().collect()
}

fn main() {
    let nums = vec![1, 2, 2, 3, 3, 3, 4, 5, 5];
    println!("{:?}", find_duplicates(&nums)); // [2, 3, 5] (order may vary)
}

3. BTreeMap and Ordered Collections

Quick Review

BTreeMap<K, V> is a sorted map based on B-trees:

  • Keys are always in sorted order
  • Keys must implement Ord trait (not Hash)
  • O(log n) lookup, insertion, deletion
  • Efficient for range queries
  • Iteration yields key-value pairs in sorted key order

When to use BTreeMap vs HashMap:

  • HashMap: faster single-key operations (O(1) vs O(log n))
  • BTreeMap: need sorted order, range queries, or keys don't implement Hash

Examples

#![allow(unused)]
fn main() {
use std::collections::BTreeMap;

let mut map = BTreeMap::new();
map.insert(3, "three");
map.insert(1, "one");
map.insert(4, "four");

// Iteration is in sorted key order
for (k, v) in map.iter() {
    println!("{}: {}", k, v);
}
// Output:
// 1: one
// 3: three
// 4: four

// First and last keys
let first = map.keys().next();      // Some(&1)
let last = map.keys().last();       // Some(&4)

// Range queries
for (k, v) in map.range(2..=4) {
    println!("{}: {}", k, v);
}
// Output: 3: three, 4: four
}

True/False Questions

  1. T/F: BTreeMap stores keys in sorted order.

  2. T/F: Insertion into a BTreeMap is O(1).

  3. T/F: BTreeMap requires keys to implement the Hash trait.

  4. T/F: Iterating over a BTreeMap yields key-value pairs in sorted key order.

  5. T/F: BTreeMap is faster than HashMap for all operations.

Answers
  1. True - BTreeMap keys are always in sorted order
  2. False - BTreeMap insertion is O(log n), not O(1)
  3. False - BTreeMap requires Ord trait, not Hash
  4. True - Iteration yields pairs in sorted key order
  5. False - HashMap is faster (O(1) vs O(log n)) for single lookups; BTreeMap is better for range queries and ordered iteration

Predict the Output

Question:

use std::collections::BTreeMap;

fn main() {
    let mut scores = BTreeMap::new();
    scores.insert("Charlie", 85);
    scores.insert("Alice", 95);
    scores.insert("Bob", 90);
    
    let first_key = scores.keys().next().unwrap();
    let last_key = scores.keys().last().unwrap();
    println!("{} {}", first_key, last_key);
}
Answer

Output: Alice Charlie

Reasoning:

  • BTreeMap stores keys in sorted (alphabetical) order
  • Sorted order: Alice, Bob, Charlie
  • first key (next()): "Alice"
  • last key: "Charlie"

4. VecDeque and Circular Buffers

Quick Review

VecDeque is a double-ended queue:

  • O(1) push/pop from both ends
  • Implemented as a circular/ring buffer
  • Can be used as a stack OR a queue
  • Grows dynamically like Vec

Key Methods:

  • push_front(value) - add to front
  • push_back(value) - add to back
  • pop_front() - remove from front, returns Option<T>
  • pop_back() - remove from back, returns Option<T>
  • front() / back() - peek without removing

Use Cases:

  • Queue (FIFO): push_back + pop_front
  • Stack (LIFO): push_back + pop_back
  • Rolling windows / circular buffers

Examples

#![allow(unused)]
fn main() {
use std::collections::VecDeque;

let mut deque: VecDeque<i32> = VecDeque::new();

// Building a deque
deque.push_back(1);   // [1]
deque.push_back(2);   // [1, 2]
deque.push_front(3);  // [3, 1, 2]
deque.push_back(4);   // [3, 1, 2, 4]

// Removing elements
let front = deque.pop_front();  // Some(3), deque is [1, 2, 4]
let back = deque.pop_back();    // Some(4), deque is [1, 2]

// Using as a queue (FIFO)
let mut queue = VecDeque::new();
queue.push_back("first");
queue.push_back("second");
let next = queue.pop_front();  // Some("first")

// Iteration
for val in deque.iter() {
    println!("{}", val);
}
}

True/False Questions

  1. T/F: VecDeque allows efficient O(1) insertion and removal at both ends.

  2. T/F: VecDeque is implemented as a circular buffer.

  3. T/F: VecDeque can only store elements that implement Copy.

  4. T/F: push_front() and push_back() are the primary insertion methods.

  5. T/F: VecDeque maintains elements in sorted order.

  6. T/F: VecDeque::push_front() is O(n).

Answers
  1. True - VecDeque provides O(1) operations at both ends
  2. True - VecDeque is implemented as a growable ring/circular buffer
  3. False - VecDeque can store any type
  4. True - push_front() and push_back() are the main insertion methods
  5. False - VecDeque maintains insertion order, not sorted order
  6. False - VecDeque::push_front() is O(1), that's its main advantage over Vec

Predict the Output

Question 1:

use std::collections::VecDeque;

fn main() {
    let mut buffer: VecDeque<i32> = VecDeque::new();
    buffer.push_back(1);
    buffer.push_back(2);
    buffer.push_front(3);
    buffer.push_back(4);
    buffer.pop_front();
    
    let sum: i32 = buffer.iter().sum();
    println!("{}", sum);
}
Answer

Output: 7

Reasoning:

  • push_back(1): [1]
  • push_back(2): [1, 2]
  • push_front(3): [3, 1, 2]
  • push_back(4): [3, 1, 2, 4]
  • pop_front() removes 3: [1, 2, 4]
  • sum = 1 + 2 + 4 = 7

Question 2:

use std::collections::VecDeque;

fn main() {
    let mut q: VecDeque<i32> = VecDeque::new();
    q.push_back(10);
    q.push_back(20);
    q.push_back(30);
    
    let first = q.pop_front().unwrap();
    q.push_back(first + 5);
    
    for val in q.iter() {
        print!("{} ", val);
    }
    println!();
}
Answer

Output: 20 30 15

Reasoning:

  • Initial pushes: [10, 20, 30]
  • pop_front() removes 10, first = 10
  • push_back(10 + 5) adds 15: [20, 30, 15]
  • Iteration prints: 20 30 15

Coding Challenge

Challenge: Implement a Rolling Average

Write a function that calculates the running (cumulative) average at each position. The running average at position i is the mean of all elements from index 0 to i.

fn running_average(values: &[f64]) -> Vec<f64> {
    // Your code here
}

fn main() {
    let data = vec![2.0, 4.0, 6.0, 8.0];
    let result = running_average(&data);
    println!("{:?}", result); // Should print [2.0, 3.0, 4.0, 5.0]
}
Solution
fn running_average(values: &[f64]) -> Vec<f64> {
    let mut result = Vec::new();
    let mut sum = 0.0;
    
    for (i, &value) in values.iter().enumerate() {
        sum += value;
        let avg = sum / (i + 1) as f64;
        result.push(avg);
    }
    
    result
}

fn main() {
    let data = vec![2.0, 4.0, 6.0, 8.0];
    let result = running_average(&data);
    println!("{:?}", result); // Should print [2.0, 3.0, 4.0, 5.0]
}

5. Iterators and Iterator Chains

Quick Review

Iterator Creation:

  • iter() - yields &T (immutable references)
  • iter_mut() - yields &mut T (mutable references)
  • into_iter() - consumes collection, yields owned T

Key Iterator Methods:

  • map(|x| ...) - transform each element
  • filter(|x| ...) - keep elements matching predicate
  • fold(init, |acc, x| ...) - accumulate into single value
  • collect() - consume iterator into collection
  • sum() - sum all elements
  • count() - count elements
  • take(n) - take first n elements
  • skip(n) - skip first n elements
  • enumerate() - yields (index, value) pairs

Important: Iterator adaptors (map, filter, etc.) are lazy - they don't execute until consumed by a method like collect(), sum(), or for loop.

Examples

#![allow(unused)]
fn main() {
let numbers = vec![1, 2, 3, 4, 5, 6];

// Filter and map
let result: Vec<i32> = numbers.iter()
    .filter(|&&x| x % 2 == 0)  // keep even: [2, 4, 6]
    .map(|x| x * 2)            // double: [4, 8, 12]
    .collect();

// Sum
let sum: i32 = numbers.iter().sum();  // 21

// Filter, map, take
let result: Vec<i32> = numbers.iter()
    .filter(|&&x| x % 2 == 1)  // keep odd: [1, 3, 5]
    .map(|x| x * x)            // square: [1, 9, 25]
    .take(2)                   // first 2: [1, 9]
    .collect();

// Enumerate
for (i, val) in numbers.iter().enumerate() {
    println!("Index {}: {}", i, val);
}

// Fold for custom accumulation
let product: i32 = numbers.iter()
    .fold(1, |acc, x| acc * x);  // 720
}

True/False Questions

  1. T/F: Iterator methods like map() and filter() are lazily evaluated.

  2. T/F: The collect() method transforms an iterator into a collection.

  3. T/F: Calling .iter() on a Vec transfers ownership of the elements.

  4. T/F: The fold() method requires an initial accumulator value.

  5. T/F: Iterator chains are evaluated from right to left.

Answers
  1. True - Iterator adaptors are lazy; they don't execute until consumed
  2. True - collect() consumes the iterator and builds a collection
  3. False - iter() borrows elements (&T); into_iter() takes ownership
  4. True - fold() requires an initial value as its first argument
  5. False - Iterator chains are evaluated left to right (and lazily)

Find the Bug

Question 1:

#![allow(unused)]
fn main() {
fn double_evens(numbers: &[i32]) -> Vec<i32> {
    numbers.iter()
        .filter(|&x| x % 2 == 0)
        .map(|x| x * 2)
}
}
Answer

Bug: Missing .collect() at the end of the iterator chain. Iterator adaptors are lazy and return an iterator, not a Vec.

Fix:

#![allow(unused)]
fn main() {
fn double_evens(numbers: &[i32]) -> Vec<i32> {
    numbers.iter()
        .filter(|&x| x % 2 == 0)
        .map(|x| x * 2)
        .collect()
}
}

Question 2:

#![allow(unused)]
fn main() {
fn sum_positive(numbers: &[i32]) -> i32 {
    numbers.iter()
        .filter(|x| x > 0)
        .sum()
}
}
Answer

Bug: The filter closure receives &&i32 (reference to reference), but comparing x > 0 tries to compare a reference with an integer.

Fix:

#![allow(unused)]
fn main() {
fn sum_positive(numbers: &[i32]) -> i32 {
    numbers.iter()
        .filter(|&&x| x > 0)  // or .filter(|x| **x > 0)
        .sum()
}
}

Predict the Output

Question 1:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6];
    let result: Vec<i32> = numbers.iter()
        .filter(|&x| x % 2 == 1)
        .map(|x| x * x)
        .take(2)
        .collect();
    println!("{:?}", result);
}
Answer

Output: [1, 9]

Reasoning:

  • filter keeps odd numbers: [1, 3, 5]
  • map squares them: [1, 9, 25]
  • take(2) keeps first 2: [1, 9]

Question 2:

fn main() {
    let data = vec![10, 20, 30, 40, 50];
    let result: i32 = data.iter()
        .skip(1)
        .take(3)
        .filter(|&&x| x > 25)
        .sum();
    println!("{}", result);
}
Answer

Output: 70

Reasoning:

  • Original: [10, 20, 30, 40, 50]
  • skip(1): [20, 30, 40, 50]
  • take(3): [20, 30, 40]
  • filter(x > 25): [30, 40]
  • sum: 30 + 40 = 70

Question 3:

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];
    
    let sum: i32 = numbers.iter()
        .enumerate()
        .filter(|(i, _)| i % 2 == 0)
        .map(|(_, v)| v)
        .sum();
    
    println!("{}", sum);
}
Answer

Output: 9

Reasoning:

  • enumerate gives: [(0,1), (1,2), (2,3), (3,4), (4,5)]
  • filter where index % 2 == 0: [(0,1), (2,3), (4,5)]
  • map extracts values: [1, 3, 5]
  • sum: 1 + 3 + 5 = 9

Coding Challenge

Challenge: Count elements in range

Write a function that counts how many elements in a slice fall within a given range [low, high] (inclusive).

fn count_in_range(numbers: &[i32], low: i32, high: i32) -> usize {
    // Your code here - use iterator methods
}

fn main() {
    let nums = vec![1, 5, 10, 15, 20, 25];
    println!("{}", count_in_range(&nums, 5, 20)); // Should print 4
}
Solution
fn count_in_range(numbers: &[i32], low: i32, high: i32) -> usize {
    numbers.iter()
        .filter(|&&x| x >= low && x <= high)
        .count()
}

fn main() {
    let nums = vec![1, 5, 10, 15, 20, 25];
    println!("{}", count_in_range(&nums, 5, 20)); // Should print 4
}

6. Algorithm Complexity

Quick Review

Big O Notation describes how runtime grows with input size:

ComplexityNameExample
O(1)ConstantHashMap lookup, Vec::push (amortized)
O(log n)LogarithmicBTreeMap operations, binary search
O(n)LinearLinear search, single loop
O(n log n)LinearithmicSorting (merge sort, quicksort)
O(n²)QuadraticNested loops, bubble sort

Common Operations:

Data StructureInsertLookupDelete
Vec (end)O(1)*O(1)O(1)
Vec (middle)O(n)O(1)O(n)
HashMapO(1)O(1)O(1)
BTreeMapO(log n)O(log n)O(log n)
VecDeque (ends)O(1)O(1)O(1)

*amortized

Graph Algorithms:

  • BFS (Breadth-First Search): uses a queue (FIFO)
  • DFS (Depth-First Search): uses a stack (LIFO)

True/False Questions

  1. T/F: A Vec::push() operation is O(1) amortized.

  2. T/F: Searching for a key in a HashMap is O(n) in the average case.

  3. T/F: Sorting a vector with .sort() is O(n log n).

  4. T/F: Graph BFS traversal uses a queue data structure.

  5. T/F: Inserting into a BTreeMap is O(1).

Answers
  1. True - Vec::push() is amortized O(1) due to capacity doubling
  2. False - HashMap lookup is O(1) average, not O(n)
  3. True - Rust's sort uses a modified merge sort, which is O(n log n)
  4. True - BFS uses a queue (FIFO); DFS uses a stack (LIFO)
  5. False - BTreeMap insertion is O(log n), not O(1)

7. Option and Result Types

Quick Review

Option - for values that might not exist:

  • Some(value) - contains a value
  • None - no value

Result<T, E> - for operations that might fail:

  • Ok(value) - success with value
  • Err(error) - failure with error

Common Methods:

  • unwrap() - get value or panic
  • unwrap_or(default) - get value or default
  • unwrap_or_else(|| ...) - get value or compute default
  • ? operator - propagate errors (Result) or None (Option)
  • is_some() / is_ok() - check variant
  • map(|x| ...) - transform if Some/Ok

Examples

#![allow(unused)]
fn main() {
// Option
let maybe_value: Option<i32> = Some(5);
let no_value: Option<i32> = None;

let x = maybe_value.unwrap_or(0);  // 5
let y = no_value.unwrap_or(0);     // 0

// Result
fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err(String::from("division by zero"))
    } else {
        Ok(a / b)
    }
}

let result = divide(10, 2);  // Ok(5)
let error = divide(10, 0);   // Err("division by zero")

// Using ? to propagate
fn calculate(a: i32, b: i32) -> Result<i32, String> {
    let quotient = divide(a, b)?;  // Returns Err early if divide fails
    Ok(quotient * 2)
}
}

True/False Questions

  1. T/F: Option::unwrap() will panic if the value is None.

  2. T/F: The ? operator can be used to propagate errors from Result.

  3. T/F: Some(5) and None are both variants of Option<i32>.

  4. T/F: Result<T, E> is used for operations that might fail with an error.

  5. T/F: unwrap_or(default) returns the contained value or a provided default.

Answers
  1. True - unwrap() panics on None
  2. True - The ? operator propagates Result errors
  3. True - Some and None are Option variants
  4. True - Result is for fallible operations
  5. True - unwrap_or() provides a default value

Final Tips for the Exam

  1. HashMap vs BTreeMap: Use HashMap for fast O(1) lookups. Use BTreeMap when you need sorted keys or range queries.

  2. Entry API: Always use entry().or_insert() for counting patterns instead of get().unwrap().

  3. HashSet trait bounds: Remember that HashSet requires Hash + Eq, not just PartialEq.

  4. Iterator laziness: Remember to call .collect() or another consumer - map/filter alone don't execute!

  5. Reference patterns in closures:

    • iter() yields &T
    • filter(|x| ...) receives &&T when used with iter()
    • Use |&x| or |&&x| to destructure
  6. VecDeque for both ends: Use VecDeque when you need efficient push/pop from both front and back.

  7. Complexity matters: Know that HashMap is O(1), BTreeMap is O(log n), and sorting is O(n log n).

  8. Understand references and when to dereference: Remember that iterators yield references, not values.

  9. Review the preliminaries as well!

Good luck on your final exam! 🦀

Graph Representation

About This Module

This module introduces graph data structures and various ways to represent graphs in computer programs. You'll learn about different graph representation methods including edge lists, adjacency lists, and adjacency matrices, along with their trade-offs and use cases.

Definition: A graph is a collection of nodes (a.k.a. vertices) connected by edges.

Prework

Prework Readings

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. What real-world problems can be modeled as graphs?
  2. What are the trade-offs between different graph representation methods?
  3. How does the density of a graph affect the choice of representation?
  4. When would you choose an adjacency matrix vs. adjacency list?
  5. How do directed vs. undirected graphs differ in their representation?

Lecture

Learning Objectives

By the end of this module, you should be able to:

  • Identify real-world problems that can be modeled as graphs
  • Implement three major graph representations: edge lists, adjacency lists, and adjacency matrices
  • Choose appropriate graph representations based on problem requirements
  • Convert between different graph representation formats
  • Understand the space and time complexity trade-offs of each representation
  • Handle graphs with non-numeric vertex labels

Graph Definition and Applications

Definition: A graph is a collection of nodes (a.k.a. vertices) connected by edges.

Why are graphs useful?

Lots of problems reduce to graph problems

  • The Internet is a graph: nodes are devices that have IP address, routes are edges
    • (Example algorithms: Shortest path, min/max flow)
    • (how many devices on the internet?)
  • The Web is a graph (crawlers) (how many web pages on the internet?)
  • Social networks are graphs (graph degrees, k-connectivity)
  • Logistics hubs and transportation are graphs (Traveling salesman, subway and bus schedules)
  • Computer scheduling (instruction dependencies)
  • GSM frequency scheduling (Graph coloring -- no two connected nodes are same color)
  • Medical school residency assignments (stable marriage algorithm)
  • And many others....

What types of graphs are there?

  • Directed vs Undirected
  • Weighted (cost on the edge) vs Unweighted (all edges have same cost)
  • Special
    • Tree (every node has a single parent, no cycles)
    • Rooted tree (single parent node, very common and useful, includes heaps, b-trees, tries etc).
    • DAG (most workflows are DAGs)
    • Bi-partite (can be separated to 2 sides with all edges crossing sides)
    • Cliques (every node is connected to every other node)

Graph representations: various options

  • What information we want to access

  • What efficiency required

[sample image]

Today:

  • Edges List
  • Vertex Adjacency lists
  • Vertex Adjacency matrix

Focus on undirected graphs:

  • easy to adjust for directed

Edges List

EdgesDirected-3.png

  • List of directed or undirected edges
  • Can be sorted/ordered for easier access/discovery EdgesUndirected-2.png
#![allow(unused)]
fn main() {
// number of vertices
let n : usize = 6;

// list of edges
let edges : Vec<(usize,usize)> = vec![(0,1), (0,2), (0,3), (1,2), (2,3), (2,4), (2,5)];
println!("{:?}", edges);
println!("{:?}",edges.binary_search(&(2,3)));
println!("{:?}",edges.binary_search(&(1,3)));
}

Adjacency lists

For each vertex, store the list of its neighbors


[sample graph]

Collection:

  • classical approach: linked list
  • vectors
#![allow(unused)]
fn main() {
// Create a vector of length n of empty vectors
let mut graph_list : Vec<Vec<usize>> = vec![vec![];n];

// iterate through the node pairs
for (v,w) in edges.iter() {
    graph_list[*v].push(*w);
    graph_list[*w].push(*v);  // for undirected, v is also connected to w
};

for i in 0..graph_list.len() {
    println!("{}: {:?}", i, graph_list[i]);
};

println!("{:?}", graph_list[2].binary_search(&3));
println!("{:?}", graph_list[1].binary_search(&3));
}
0: [1, 2, 3]
1: [0, 2]
2: [0, 1, 3, 4, 5]
3: [0, 2]
4: [2]
5: [2]
Ok(2)
Err(2)

Adjacency matrix

  • vertices
  • matrix
  • For each pair of vertices, store a boolean value: edge present or not
  • Matrix is symmetric for undirected graph
sample dense graph
#![allow(unused)]
fn main() {
// make a vector of n vectors of length n
// initialized to false
let mut graph_matrix = vec![vec![false;n];n];

// iterate and set entries to true where edges exist
for (v,w) in edges.iter() {
    graph_matrix[*v][*w] = true;
    graph_matrix[*w][*v] = true; 
};
for row in &graph_matrix {
    for entry in row.iter() {
        print!(" {} ",if *entry {"1"} else {"0"});
    }
    println!("");
};
println!("{}", graph_matrix[2][3]);
println!("{}", graph_matrix[1][3]);
}
 0  1  1  1  0  0 
 1  0  1  0  0  0 
 1  1  0  1  1  1 
 1  0  1  0  0  0 
 0  0  1  0  0  0 
 0  0  1  0  0  0 
true
false

Sample Graph Recap

[sample image]

This lecture's graphs:

  • undirected
  • no self-loops
    • self-loop: edge connecting a vertex to itself
  • no parallel edges (connecting the same pair of vertices)

Simplifying assumption:

  • vertices labeled

What if labels are not in ?

For example:

  • nodel labels are not numbers, e.g. strings

Ttype of labels

Solution 1: Map everything to this range (most common)

  • Create hash maps from input labels to
  • Create a reverse hash map to recover labels when needed

Solution 2: Replace with hash maps and hash sets (less common, harder to use)

  • Adjacency lists: use HashMap<T,Vec<T>>
    • first node is the key and values is the adjacency list
  • Adjacency matrix: use HashSet<(T,T)>
    • Sets of tuples where node pairs that are connected are inserted
  • Bonus gain: HashSet<(T,T)> better than adjacency matrix for sparse graphs

What if the graph is directed?

Adjacency lists:

  • separate lists incoming/outgoing edges
  • depends on what information needed for your algorithm

Adjacency matrix:

  • example: edge and no edge in the opposite direction:
    • matrix[u][v] = true
    • matrix[v][u] = false
  • Matrix is no longer symmetric

Graph Algorithms: Counting Triangles

About This Module

This module introduces a fundamental graph algorithm: counting triangles in a graph. You'll learn multiple approaches to solve this problem, understand their complexity trade-offs, and explore real-world applications including spam detection and social network analysis.

Prework

Prework Readings

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. Why might counting triangles be useful in real-world applications?
  2. What are the trade-offs between using adjacency matrices vs. adjacency lists?
  3. How does algorithm complexity change with graph density?
  4. What strategies can reduce duplicate counting in graph algorithms?
  5. When would you choose recursion vs. iteration for graph traversal?

Lecture

Learning Objectives

By the end of this module, you should be able to:

  • Implement multiple algorithms for counting triangles in graphs
  • Analyze the time complexity of different graph algorithms
  • Choose appropriate graph representations for specific algorithms
  • Handle duplicate counting issues in graph traversal
  • Apply triangle counting to real-world problems like spam detection
  • Understand the relationship between graph density and algorithm performance

Triangle Counting Problem

Problem to solve: Consider all triples of vertices. What is the number of those in which all vertices are connected? And alternatively how many unique triangles does a vertex belong to?

  • Why is this important? Turns out that hosts that contain spam pages have a very different triangle patterns then regular hosts (https://chato.cl/papers/becchetti_2007_approximate_count_triangles.pdf)
spam graph
Separation of non-spam and spam hosts in the histogram of triangles
  • Also clustering coefficients in social networks: https://cs.stanford.edu/~rishig/courses/ref/l1.pdf

Solution 1

Enumerate explicitly over all triples and check which are triangles, using the adjacency matrix.

graph_matrix[][]

sample dense graph
let mut count: u32 = 0;
let mut coefficients: Vec<u32> = vec![0;n];
for u in 0..n {
    for v in u+1..n {
        for w in v+1..n {
            if (graph_matrix[u][v] && graph_matrix[v][w] && graph_matrix[w][u]) {
                count += 1;
                coefficients[u] += 1;
                coefficients[v] += 1;
                coefficients[w] += 1;
            }
        }
    }
}
println!("{}", count);
println!("{:?}", coefficients);
2
[2, 1, 2, 1, 0, 0]

Complexity of the algorithm above is

Why?

hint: look at the nested loops.


Solution 2

Follow links from each vertex to see if you come back in three steps, using adjacency list.

[sample graph]
let mut count: u32 = 0;
for u in 0..n {
    for v in &graph_list[u] {  // v is the adjacency list for node u
        for w in &graph_list[*v] {  // w is the adjacency list for node v
            for u2 in &graph_list[*w] {  // now iterate through this 3rd list
                if u == *u2 {  // if we find the origin node, we closed the loop in 3 steps
                    count += 1;
                    break;
                }
            }
        }
    }
}
count
12

Now we need to account for duplicate counting.

For every triangle there are 2 different directions to traverse the same nodes.

For every triangle, we can start with one each of the 3 nodes to count.

// need to divide by 6
// due to symmetries triangles counted multiple times
count / (2*3)
2

Complexity

We have 4 nested loops, so worse case where every node is connected to every other node, we have complexity of .

In practice, nodes are connected more sparsely and the complexity can be shown to be , where is number of nodes and is number of edges.

Question: How did we avoid duplicate counting in the matrix version, Solution 1?


Solution 2 -- Alternate using recursion

Different implementation of solution 2, using a recursive algorithm.

  • Do a recursive walk through the graph and terminate after 3 steps.
  • If I land where I started, then I found a triangle.
fn walk(current:usize, destination:usize, steps:usize, adjacency_list:&Vec<Vec<usize>>) -> u32 {
    match steps {
        0 => if current == destination {1} else {0},
        _ => {
            let mut count = 0;
            for v in &adjacency_list[current] {
                count += walk(*v,destination,steps-1,adjacency_list);
            }
            count
        }
    }
}
let mut count = 0;
for v in 0..n {
    count += walk(v,v,3,&graph_list);
}
count / 6
2

Solution 3

For each vertex try all pairs of neighbors (via adjacency lists) and see if they are connected (via adjacency matrix).

let mut count: u32 = 0;

for u in 0..n {
    let neighbors = &graph_list[u];  // get the adjacency list for node `u`

    // now try all combinations of node pairs from that adjacency list
    for v in neighbors {
        for w in neighbors {
            if graph_matrix[*v][*w] {  // v,w are connected and both connected to u since they are in u's adjacency list
                count += 1;
            }
        }
    }
}

// again we duplicated counted
count / 6
2

Complexity Analysis

The code is using both an adjacency list (graph_list) and an adjacency matrix (graph_matrix) representation of the graph.

Let's break down the complexity:

  1. The outer loop iterates through all nodes u from 0 to n-1: O(n)
  2. For each node u, it gets its adjacency list neighbors
  3. For each node v in the neighbors list, it iterates through all nodes w in the same neighbors list
  4. For each pair (v,w), it checks if they are connected using the adjacency matrix: O(1)

The key insight is that for each node u, we're looking at all pairs of its neighbors. The number of pairs is proportional to the square of the degree of node u.

Let's denote:

  • n = number of nodes
  • m = number of edges
  • d(u) = degree of node u

The complexity can be expressed as: Σ (d(u)²) for all nodes u

In the worst case, if the graph is complete (every node connected to every other node), each node has degree n-1, so the complexity would be O(n³).

However, in practice, most real-world graphs are sparse (m << n²). For sparse graphs, the average degree is much smaller than n. If we assume the average degree is d, then the complexity would be O(n * d²).

This is more efficient than the naive O(n³) approach of checking all possible triplets of nodes, but it's still quite expensive for dense graphs or graphs with high-degree nodes (hubs).

The final division by 6 is just a constant factor adjustment to account for counting each triangle 6 times (once for each permutation of the three nodes), so it doesn't affect the asymptotic complexity.

Graph Exploration and Search Algorithms

About This Module

This module introduces fundamental graph exploration techniques, focusing on breadth-first search (BFS) and depth-first search (DFS). Students will learn to represent graphs using adjacency lists, implement graph data structures in Rust, and understand the theoretical foundations of graph traversal algorithms. The module provides essential building blocks for more advanced graph algorithms and demonstrates practical applications in data science and network analysis.

Prework

Before this lecture, please read:

Pre-lecture Reflections

  1. What are the advantages and disadvantages of different graph representations (adjacency matrix vs. adjacency list)?
  2. How do BFS and DFS differ in their exploration patterns and use cases?
  3. What types of real-world problems can be modeled as graph traversal problems?

Lecture

Learning Objectives

By the end of this lecture, you should be able to:

  • Represent graphs using adjacency lists in Rust
  • Understand the difference between directed and undirected graphs
  • Implement basic graph data structures with type aliases and structs
  • Create utility functions for graph manipulation
  • Identify appropriate graph representations for different problem types

Graph exploration overview

Graph exploration

Sample popular methods:

  • breadth–first search (BFS)

    • uses a queue
  • depth–first search (DFS)

    • uses a stack
  • random walks

    • example: PageRank (see Homework 7)

BFS and DFS

Useful graph subroutines

We'll start by defining some useful routines.

// Define some datatype synonyms
type Vertex = usize;
type ListOfEdges = Vec<(Vertex,Vertex)>;
type AdjacencyLists = Vec<Vec<Vertex>>;

#[derive(Debug)]
struct Graph {
    n: usize, // vertex labels in {0,...,n-1}
    outedges: AdjacencyLists,
}

// reverse direction of edges on a list
fn reverse_edges(list:&ListOfEdges)
        -> ListOfEdges {
    let mut new_list = vec![];
    for (u,v) in list {
        new_list.push((*v,*u));
    }
    new_list
}

reverse_edges(&vec![(3,2),(1,1),(0,100),(100,0)])
[(2, 3), (1, 1), (100, 0), (0, 100)]
impl Graph {
    fn add_directed_edges(&mut self,
                          edges:&ListOfEdges) {
        for (u,v) in edges {
            self.outedges[*u].push(*v);
        }
    }
    fn sort_graph_lists(&mut self) {
        for l in self.outedges.iter_mut() {
            l.sort();
        }
    }
    fn create_directed(n:usize,edges:&ListOfEdges)
                                            -> Graph {
        let mut g = Graph{n,outedges:vec![vec![];n]};
        g.add_directed_edges(edges);
        g.sort_graph_lists();
        g                                        
    }
    
    fn create_undirected(n:usize,edges:&ListOfEdges)
                                            -> Graph {
        let mut g = Self::create_directed(n,edges);
        g.add_directed_edges(&reverse_edges(edges));
        g.sort_graph_lists();
        g                                        
    }
}

Breadth-First Search (BFS) Algorithm

About This Module

This module provides an in-depth exploration of the breadth-first search (BFS) algorithm, a fundamental graph traversal technique. Students will learn to implement BFS in Rust, understand its computational complexity, and apply it to solve important graph problems such as shortest path finding and connected component detection. The module emphasizes both theoretical understanding and practical implementation skills.

Prework

Before this lecture, please read:

Pre-lecture Reflections

  1. Why is BFS particularly useful for finding shortest paths in unweighted graphs?
  2. How does the queue data structure enable BFS's level-by-level exploration?
  3. What are some real-world applications where BFS would be the preferred search strategy?

Lecture

Learning Objectives

By the end of this lecture, you should be able to:

  • Implement the BFS algorithm using queues and proper graph traversal techniques
  • Analyze BFS's O(V+E) time complexity and understand why it achieves this bound
  • Apply BFS to solve shortest path problems in unweighted graphs
  • Use BFS for connected component detection in undirected graphs
  • Trace through BFS execution and predict its behavior on different graph structures

Sample graph

[first sample graph]
let n: usize = 10;
let mut edges: ListOfEdges = vec![(0,1),(0,2),(1,2),(2,4),(2,3),(4,3),(4,5),(5,6),(4,6),(6,8),(6,7),(8,7),(1,9)];
edges.sort();
println!("{:?}",edges);
let graph = Graph::create_undirected(n,&edges);
for (i, l) in graph.outedges.iter().enumerate() {
    println!("{} {:?}", i, *l);
}
[(0, 1), (0, 2), (1, 2), (1, 9), (2, 3), (2, 4), (4, 3), (4, 5), (4, 6), (5, 6), (6, 7), (6, 8), (8, 7)]
0 [1, 2]
1 [0, 2, 9]
2 [0, 1, 3, 4]
3 [2, 4]
4 [2, 3, 5, 6]
5 [4, 6]
6 [4, 5, 7, 8]
7 [6, 8]
8 [6, 7]
9 [1]




()

Breadth–first search (BFS)

General idea:

  • start from some vertex and explore its neighbors (distance 1)
  • then explore neighbors of neighbors (distance 2)
  • then explore neighbors of neighbors of neighbors (distance 3)
  • ...

Our example: start from vertex 2

[first sample graph]

Whiteboard walk-through

Let's first walk through the process interactively, then look at the code implementaiton.

The following reorganizes the graph to group by number of steps from the starting node.

[first sample graph, grouped into layers]

Implementation: compute distances from vertex 2 via BFS -- I

distance[v]: distance of v from vertex 2 (None is unknown)

let start: Vertex = 2; // <= we'll start from this vertex

let mut distance: Vec<Option<u32>> = vec![None;graph.n];
distance[start] = Some(0); // <= we know this distance
distance
[None, None, Some(0), None, None, None, None, None, None, None]

queue: vertices to consider, they will arrive layer by layer

use std::collections::VecDeque;
let mut queue: VecDeque<Vertex> = VecDeque::new();
queue.push_back(start);
queue
[2]

Implementation: compute distances from vertex 2 via BFS -- II

Main loop:
   consider vertices one by one
   add their new neighbors to the processing queue

println!("{:?}",queue);
while let Some(v) = queue.pop_front() { // new unprocessed vertex
    println!("top {:?}",queue);
    for u in graph.outedges[v].iter() {
        if let None = distance[*u] { // consider all unprocessed neighbors of v
            distance[*u] = Some(distance[v].unwrap() + 1);
            queue.push_back(*u);
            println!("In {:?}",queue);
        }
    }
};
[2]
top []
In [0]
In [0, 1]
In [0, 1, 3]
In [0, 1, 3, 4]
top [1, 3, 4]
top [3, 4]
In [3, 4, 9]
top [4, 9]
top [9]
In [9, 5]
In [9, 5, 6]
top [5, 6]
top [6]
top []
In [7]
In [7, 8]
top [8]
top []

Implementation: compute distances from vertex 2 via BFS -- III

Compare results:

[layers]
print!("vertex:distance");
for v in 0..graph.n {
    print!("   {}:{}",v,distance[v].unwrap());
}
println!();
vertex:distance   0:1   1:1   2:0   3:1   4:1   5:2   6:2   7:3   8:3   9:2

What if we wanted the distance from all to all?




// let's wrap the previous code in a function
fn compute_and_print_distance_bfs(start: Vertex, graph: &Graph) {
    let mut distance: Vec<Option<u32>> = vec![None;graph.n];
    distance[start] = Some(0); // <= we know this distance
    let mut queue: VecDeque<Vertex> = VecDeque::new();
    queue.push_back(start);
    while let Some(v) = queue.pop_front() { // new unprocessed vertex
        for u in graph.outedges[v].iter() {
            if let None = distance[*u] { // consider all unprocessed neighbors of v
                distance[*u] = Some(distance[v].unwrap() + 1);
                queue.push_back(*u);
            }
        }
    }
    print!("vertex:distance");
    for v in 0..graph.n {
        print!("   {}:{}",v,distance[v].unwrap());
    }
    println!();
}

// Then loop to start BFS from each node
for i in 0..graph.n {
    println!("Distances from node {}", i);
    compute_and_print_distance_bfs(i, &graph);
}

Distances from node 0
vertex:distance   0:0   1:1   2:1   3:2   4:2   5:3   6:3   7:4   8:4   9:2
Distances from node 1
vertex:distance   0:1   1:0   2:1   3:2   4:2   5:3   6:3   7:4   8:4   9:1
Distances from node 2
vertex:distance   0:1   1:1   2:0   3:1   4:1   5:2   6:2   7:3   8:3   9:2
Distances from node 3
vertex:distance   0:2   1:2   2:1   3:0   4:1   5:2   6:2   7:3   8:3   9:3
Distances from node 4
vertex:distance   0:2   1:2   2:1   3:1   4:0   5:1   6:1   7:2   8:2   9:3
Distances from node 5
vertex:distance   0:3   1:3   2:2   3:2   4:1   5:0   6:1   7:2   8:2   9:4
Distances from node 6
vertex:distance   0:3   1:3   2:2   3:2   4:1   5:1   6:0   7:1   8:1   9:4
Distances from node 7
vertex:distance   0:4   1:4   2:3   3:3   4:2   5:2   6:1   7:0   8:1   9:5
Distances from node 8
vertex:distance   0:4   1:4   2:3   3:3   4:2   5:2   6:1   7:1   8:0   9:5
Distances from node 9
vertex:distance   0:2   1:1   2:2   3:3   4:3   5:4   6:4   7:5   8:5   9:0




()

Computational complexity of BFS

O(V+E) where V is the number of vertices and E is the number of edges

Why?

Two loops:

  • The outside loop goes through vertices and will go through every vertex and will only do so once!
  • The inside loop goes through the edges of that vertex.
  • But if you go through the edges of every vertex that is equal to the total number of edges. So it's not multiplicative but additive.

Strength of BFS

So we see one strength of BFS is that it is good at calculating distances.

Connected components via BFS




Connected component (in an undirected graph):


a maximal set of vertices that are connected
[second graph]

Sample graph:

let n: usize = 9;
let edges: Vec<(Vertex,Vertex)> = vec![(0,1),(0,2),(1,2),(2,4),(0,4),(5,7),(6,8)];
let graph = Graph::create_undirected(n, &edges);

Discovering vertices of a connected component via BFS

component[v]: v's component's number (Nonenot assigned yet)

type Component = usize;

/*
 * Loop through all the vertices connected to the passed in vertex and assign it the same 
 * component group number.
 */
fn mark_component_bfs(vertex:Vertex, // current vertex number
                      graph:&Graph,  // graph structure
                      component:&mut Vec<Option<Component>>, // the component assignment for each node 
                      component_no:Component  // the component group of the current vertex
                     ) {
    component[vertex] = Some(component_no);
    
    let mut queue = std::collections::VecDeque::new();
    queue.push_back(vertex);
    
    while let Some(v) = queue.pop_front() {
        for w in graph.outedges[v].iter() {
            if let None = component[*w] {   // if node not assigned to a component group yet
                component[*w] = Some(component_no);  // assign it the current component number
                queue.push_back(*w);      // push it onto the queue to search since it is connected
            }
        }
    }
}

Marking all connected components

Loop over all unassigned vertices and assign component numbers

// Vec of component assignments for each node, initialized to None
let mut component: Vec<Option<Component>> = vec![None;n];

let mut component_count = 0;

for v in 0..n {
    if let None = component[v] {
        component_count += 1;
        mark_component_bfs(v, &graph, &mut component, component_count);
    }
};
// Let's verify the assignment!
print!("{} components:\n[  ",component_count);
for v in 0..n {
    print!("{}:{}  ",v,component[v].unwrap());
}
println!("]\n");
4 components:
[  0:1  1:1  2:1  3:2  4:1  5:3  6:4  7:3  8:4  ]
[components]

Question: What is complexity of this algorithm?


It is also .


Depth-First Search (DFS) Algorithm

About This Module

This module covers the depth-first search (DFS) algorithm, a fundamental graph traversal technique that explores as far as possible along each branch before backtracking. Students will learn to implement DFS in Rust, understand its applications in topological sorting and strongly connected components, and compare it with BFS. The module emphasizes both recursive and iterative implementations while exploring DFS's unique strengths in graph analysis.

Prework

Before this lecture, please read:

Pre-lecture Reflections

  1. How does DFS's exploration pattern differ from BFS, and when might this be advantageous?
  2. What are the advantages and challenges of implementing DFS recursively vs. iteratively?
  3. In what types of graph problems would DFS be preferred over BFS?

Lecture

Learning Objectives

By the end of this lecture, you should be able to:

  • Implement DFS using both recursive and iterative approaches
  • Understand DFS's applications in topological sorting and cycle detection
  • Analyze the computational complexity and memory usage of DFS
  • Apply DFS to find strongly connected components in directed graphs
  • Compare DFS and BFS for different graph analysis tasks

DFS Example

Depth–First Search (DFS) -- I

General idea:

  • keep going to an unvisited vertex
  • when stuck make a step back and try again
[dfs demo]

Depth–First Search (DFS) -- II

General idea:

  • keep going to an unvisited vertex
  • when stuck make a step back and try again
[dfs demo]

Depth–First Search (DFS) -- III

General idea:

  • keep going to an unvisited vertex
  • when stuck make a step back and try again
[dfs demo]

Depth–First Search (DFS) -- IV

General idea:

  • keep going to an unvisited vertex
  • when stuck make a step back and try again
[dfs demo]

Depth–First Search (DFS) -- V

General idea:

  • keep moving to an unvisited neighbor
  • when stuck make a step back and try again
[dfs demo]

Depth–First Search (DFS) -- VI

General idea:

  • keep going to an unvisited vertex
  • when stuck make a step back and try again
[dfs demo]

Depth–First Search (DFS) -- VII

General idea:

  • keep going to an unvisited vertex
  • when stuck make a step back and try again
[dfs demo]

Depth–First Search (DFS) -- VIII

General idea:

  • keep going to an unvisited vertex
  • when stuck make a step back and try again
[dfs demo]

Depth–First Search (DFS) -- IX

General idea:

  • keep going to an unvisited vertex
  • when stuck make a step back and try again
[dfs demo]

Depth–First Search (DFS) -- X

General idea:

  • keep going to an unvisited vertex
  • when stuck make a step back and try again
[dfs demo]

Depth–First Search (DFS) -- XI

General idea:

  • keep going to an unvisited vertex
  • when stuck make a step back and try again
[dfs demo]

Depth–First Search (DFS) -- XII

General idea:

  • keep going to an unvisited vertex
  • when stuck make a step back and try again
[dfs demo]

Depth–First Search (DFS) -- XIII

General idea:

  • keep going to an unvisited vertex
  • when stuck make a step back and try again
[dfs demo]

Depth–First Search (DFS) -- XIV

General idea:

  • keep going to an unvisited vertex
  • when stuck make a step back and try again
[dfs demo]

Depth–First Search (DFS) -- XV

General idea:

  • keep going to an unvisited vertex
  • when stuck make a step back and try again
[dfs demo]

Our sample graph from BFS

[first sample graph]

DFS Implementation

fn dfs(vertex:Vertex,  // starting vertex
       graph: &Graph,  // graph structure
       d: usize,       // distance of node connected to passed in vertex
       visited: &mut Vec<bool>,  // Vec of bools indicating if we visited a node 
       distance: &mut Vec<usize>   // Vec of distances of each vertex from the starting vertex
      ){
    // loop through every vertex connected to the current vertex
    for w in graph.outedges[vertex].iter() {
      if visited[*w] == false {
        distance[*w] = d;
        visited[*w] = true;
        dfs(*w, graph, d+1, visited, distance);
      }
    }
}
let n: usize = 10;
let edges: ListOfEdges = vec![(0,1),(0,2),(1,2),(2,4),(2,3),(4,3),(4,5),(5,6),(4,6),(6,8),(6,7),(8,7),(1,9)];
let graph = Graph::create_undirected(n,&edges);
let mut visited = vec![false;graph.n];
let mut distance = vec![0;graph.n];

visited[2] = true;
distance[2] = 0;
dfs(2, &graph, 1, &mut visited, &mut distance);
println!("vertex:distance");
for v in 0..graph.n {
    print!("   {}:{}",v,distance[v]);
}
println!();

// For comparison this was the distance from bfs 
// vertex:distance   0:1   1:1   2:0   3:1   4:1   5:2   6:2   7:3   8:3   9:2
vertex:distance
   0:1   1:2   2:0   3:1   4:2   5:3   6:4   7:5   8:6   9:3

Connected Components and Strongly Connected Components

About This Module

This module explores connected components in undirected graphs and strongly connected components in directed graphs. Students will learn advanced applications of DFS, including the two-pass algorithm for finding strongly connected components (Kosaraju's algorithm). The module covers both theoretical concepts and practical implementations, with applications to network analysis and graph decomposition problems.

Prework

Before this lecture, please read:

  • Introduction to Algorithms Chapter 22.5: "Strongly connected components" (if available)
  • The Rust Book Chapter 8.1: "Storing Lists of Values with Vectors" - https://doc.rust-lang.org/book/ch08-01-vectors.html
  • Graph Theory reference on strongly connected components (any introductory graph theory text)

Pre-lecture Reflections

  1. What is the difference between connected components and strongly connected components?
  2. Why do we need different algorithms for directed vs. undirected graphs?
  3. How might strongly connected components be useful in analyzing real-world networks?

Lecture

Learning Objectives

By the end of this lecture, you should be able to:

  • Understand the difference between connected and strongly connected components
  • Implement connected component detection using both BFS and DFS
  • Apply Kosaraju's algorithm to find strongly connected components
  • Analyze the computational complexity of component detection algorithms
  • Apply component analysis to real-world graph problems

Connected components via DFS

Recursive DFS exploration: walk all connected vertices depth-first rather than breadth-first

fn mark_component_dfs(vertex:Vertex, graph:&Graph, component:&mut Vec<Option<Component>>, component_no:Component) {
    component[vertex] = Some(component_no);
    for w in graph.outedges[vertex].iter() {
        if let None = component[*w] {
            mark_component_dfs(*w,graph,component,component_no);
        }        
    }
}

Going over all components and assigning vertices:

let n: usize = 9;
let edges: Vec<(Vertex,Vertex)> = vec![(0,1),(0,2),(1,2),(2,4),(0,4),(5,7),(6,8)];
let graph = Graph::create_undirected(n, &edges);

let mut component = vec![None;graph.n];
let mut component_count = 0;

for v in 0..graph.n {
    if let None = component[v] {
        component_count += 1;
        mark_component_dfs(v,&graph,&mut component,component_count);
    }
};

Connected components via DFS

Let's verify the results:

print!("{} components:\n[  ",component_count);
for v in 0..n {
    print!("{}:{}  ",v,component[v].unwrap());
}
println!("]\n");
4 components:
[  0:1  1:1  2:1  3:2  4:1  5:3  6:4  7:3  8:4  ]
[components]

Takeaway: Works just as well as BFS for finding connected components.

BFS vs. DFS

Both have complexity of

BFS

  • gives graph distances between vertices (fundamental problem!)
  • connectivity

DFS

  • What is it good for?

Lots of things!

Examples:

  • find edges/vertices crucial for connectivity
  • orient edges of a graph so it is still connected
  • strongly connected components in directed graphs
  • Traversal of trees (inorder, preorder, postorder)
  • Topological Sorting (Scheduling)
  • Matching people and jobs

Topological sort (pseudocode for home study)

Represents an order of the nodes that respects dependencies from directed graphs.

Assume this is a graph of dependent tasks.

Used whenever you have to schedule work only after satisfying dependencies.

Used in Neural Network Backpropagation.

Valid topo sorts:

  • 0, 1, 3, 2
  • 0, 3, 1, 2
  • 3, 0, 1, 2

Invalid topo sorts:

  • 0, 1, 2, 3
  • 1, 3, 0, 2
  • ...

Pseudocode (For study at home)

Every node can have one of three marks:

  • unmarked: initial state
  • temporary mark: being processed
  • permanent mark: fully processed

In the beginngin all nodes are unmarked.

Pick an unmarked node and call visit() on that node.

L ← Empty list that will contain a valid order of nodes
while exists nodes without a permanent mark do
    select an unmarked node n
    visit(n)

function visit(node n)
    if n has a permanent mark then
        return
    if n has a temporary mark then
        stop   (graph has at least one cycle -- can't be sorted)

    mark n with a temporary mark

    for each node m with an edge from n to m do
        visit(m)

    remove temporary mark from n
    mark n with a permanent mark
    add n to head of L

Complexith of topological sort

Complexity is . Just doing a DFS.

Strongly connected components (for directed graphs)

Only applies to directed graphs.

Strong connectivity


What does connectivity mean in directed graphs?
What if you can get from to , but not from to ?

Strongly connected component:

A maximal set of vertices such that you can get from any of them to any other one. So like connected components but taking directionality into account

Strongly connected: nodes 0, 1, 2

Strongly connected: nodes, 3, 4, 5


Fact: There is a unique decomposition

Find the unique decomposition via two DFS runs

General idea

First DFS:

  • maintain auxiliary stack
  • visit all vertices, starting DFS multiple times from unvisited vertices as needed
  • put each vertex, when done going over its neighbors, on the stack

Second DFS:

  • reverse edges of the graph!!!
  • consider vertices in order from the stack
  • for each unvisited vertex, start DFS: it will visit a new strongly connected component

Implementation

let n: usize = 7;
let edges: ListOfEdges = vec![(0,1),(1,2),(2,0),(3,4),(4,5),(5,3),(2,3),(6,5)];
let graph = Graph::create_directed(n, &edges);
let graph_reverse = Graph::create_directed(n,&reverse_edges(&edges));
println!("{:?}\n{:?}",graph,graph_reverse);
Graph { n: 7, outedges: [[1], [2], [0, 3], [4], [5], [3], [5]] }
Graph { n: 7, outedges: [[2], [0], [1], [2, 5], [3], [4, 6], []] }

Implementation (first DFS)

let mut stack: Vec<Vertex> = Vec::new();
let mut visited = vec![false;graph.n];
// push vertex onto stack after all descendents are processed (a.k.a. finishing times)
fn dfs_collect_stack(v:Vertex, graph:&Graph, stack:&mut Vec<Vertex>, visited:&mut Vec<bool>) {
    if !visited[v] {
        visited[v] = true;
        for w in graph.outedges[v].iter() {
            dfs_collect_stack(*w, graph, stack, visited);
        }
        stack.push(v);
        println!("pushed {}", v);
    }
}
for v in 0..graph.n {
    dfs_collect_stack(v,&graph,&mut stack,&mut visited);
};
stack
pushed 5
pushed 4
pushed 3
pushed 2
pushed 1
pushed 0
pushed 6




[5, 4, 3, 2, 1, 0, 6]

Implementation (second DFS, reversed graph)

let mut component: Vec<Option<Component>> = vec![None;graph.n];
let mut component_count = 0;

while let Some(v) = stack.pop() {
    if let None = component[v] {
        component_count += 1;
        mark_component_dfs(v, &graph_reverse, &mut component, component_count);
    }
};
print!("{} components:\n",component_count);
for c in 1..=component_count {
    print!("Component {}: ", c);
    for v in 0..n {
       if component[v].unwrap() == c {
          print!("{} ",v);
       }
    }
    println!();
}
println!();
3 components:
Component 1: 6 
Component 2: 0 1 2 
Component 3: 3 4 5 

Rust project version

Rust project version in strongly_connected folder in case you want to debug and inspect.

Code Formatting and Rust Best Practices

About This Module

This module focuses on code formatting, readability, and best practices in Rust programming. Students will learn about the importance of clean, readable code and how to use Rust's built-in formatting tools. The module covers rustfmt, code style guidelines, and techniques for writing maintainable code that follows Rust community standards.

Prework

Before this lecture, please read:

Pre-lecture Reflections

  1. Why is consistent code formatting important in team development?
  2. How might automated formatting tools improve code quality and developer productivity?
  3. What are some examples of code that is technically correct but difficult to read?

Lecture

Learning Objectives

By the end of this lecture, you should be able to:

  • Use rustfmt to automatically format Rust code
  • Understand Rust community style conventions
  • Write readable and maintainable code
  • Apply refactoring techniques to improve code clarity
  • Configure and customize formatting tools for your development workflow

Don't give up on code formatting!

  • Rust doesn't require any specific indentation
  • Still a good idea to make your code readable
//This is bad code 
fn h(z:i32)->i32{
     let mut t=0.max(z.min(1)-0.max(z-1));
     for y in 1..=2.min(z){
         t+=h(z-y)
     }
     t
}
//This is even worse code 
fn g(z:i32)->i32{let mut t=0.max(z.min(1)-0.max(z-1));for y in 1..=2.min(z){t+=g(z-y)}t}
// This is good code
fn f(z:i32)->i32 {
    let t;
    if z==0{
        t = 0;
    } else if z == 1 {
        t = 1;
    } else {
        t = f(z-1) + f (z-2);
    }
    t
}
for i in 0..10 {
    println!("{}:{},{},{}",i,h(i),g(i),f(i));
};
0:0,0,0
1:1,1,1
2:1,1,1
3:2,2,2
4:3,3,3
5:5,5,5
6:8,8,8
7:13,13,13
8:21,21,21
9:34,34,34

Tool for formatting Rust code: rustfmt

  • If you have Rust installed, you should already have it.

  • rustfmt [filename] replaces the file with nicely formatted version

    • use rustfmt --backup [filename] to save the original file
  • rustfmt --help: see the command line parameters

  • rustfmt --print-config default: default config that can be adjusted

Other style tips:

  1. If you repeat sections of code, move it to a function
  2. If you have many if, else if, ... --> move it to a match statement
  3. if the body of a match variant is large, move content to a function...
  4. ...

Priority Queues in Rust

About This Module

This module introduces priority queues, a fundamental data structure that serves elements based on their priority rather than insertion order. We'll explore Rust's BinaryHeap implementation, custom ordering with structs, and analyze the time complexity of various priority queue operations.

Prework

Prework Reading

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. How do priority queues differ from regular FIFO queues in their use cases?
  2. What are some real-world applications where priority-based ordering is essential?
  3. How does the Ord trait enable custom priority ordering in Rust?
  4. What trade-offs exist between different priority queue implementations?
  5. When would you choose a priority queue over sorting all elements at once?

Lecture

Learning Objectives

By the end of this module, you should be able to:

  • Understand the difference between standard queues and priority queues
  • Use Rust's BinaryHeap for priority queue operations
  • Implement custom ordering with structs using derive and manual trait implementation
  • Apply the Reverse wrapper to change ordering behavior
  • Analyze time complexity of priority queue operations
  • Design priority-based systems for real-world applications

Priority Queues

Standard queue:

  • things returned in order in which they were inserted

Priority queue:

  • items have priorities
  • highest priority items returned first

Example of Priority Queue

Triage in a hospital emergency room.

For non-critical injuries, perhaps FIFO, but heart attack gets priority.

Rust standard library implementation: BinaryHeap<T>


  • Priorities provided by the ordering of elements of T (via trait Ord)


  • Method push(T):
    push element onto the heap


  • Method pop() -> Option<T>:
    remove the greatest and return it Some(T) or None
use std::collections::BinaryHeap;

let mut pq = BinaryHeap::new();

pq.push(2);
pq.push(7);
pq.push(3);

println!("{:?}",pq.pop());
println!("{:?}",pq.pop());

pq.push(3);
pq.push(4);

println!("\n{:?}",pq.pop());
println!("{:?}",pq.pop());
println!("{:?}",pq.pop());
println!("{:?}",pq.pop());
Some(7)
Some(3)

Some(4)
Some(3)
Some(2)
None

You can use more complex data types, like your own structs with a priority field, then you have to implement your own Ord trait.

Getting the smallest element out first

Reverse<T>: wrapper that reverses the ordering of elements of a type

3 < 4
true
use std::cmp::Reverse;
Reverse(3) < Reverse(4)
false
5 < 3
false
Reverse(5) < Reverse(3)
true
let mut pq = BinaryHeap::new();

pq.push(Reverse(3));
pq.push(Reverse(1));
pq.push(Reverse(7));

println!("{:?}",pq.pop());
println!("{:?}",pq.pop());

pq.push(Reverse(0));

println!("\n{:?}",pq.pop());
println!("\n{:?}",pq.pop());
println!("\n{:?}",pq.pop());
Some(Reverse(1))
Some(Reverse(3))

Some(Reverse(0))

Some(Reverse(7))

None

How to extract inner value from Reverse()

// Method 1: Using .0 to access the inner value (since Reverse is a tuple struct)
let rev = Reverse(3);
let value = rev.0;  // value = 3
println!("{value}");

// Method 2: Using pattern matching
let rev = Reverse(5);
let Reverse(value) = rev;  // value = 5
println!("{value}");
3
5

Default lexicographic ordering on tuples and structs

Lexicographic ordering:

  • Compare first elements
  • If equal, compare second elements
  • If equal, compare third elements...

Tuples

(3,4) < (2,7)
false
(11,2,7) < (11,3,4)
true

Struct (derive Ord)

A custom struct does not support lexicographic ordering, but we can derive traits.

#[derive(PartialEq,Eq,PartialOrd,Ord,Debug)]
struct Point {
    x: i32,
    y: i32,
}
let p = Point{x:3,y:4};
let q = Point{x:2,y:7};
println!("{}", p < q);
println!("{}", p > q);
false
true
let p = Point{x:3,y:4};
let q = Point{x:3,y:7};
println!("{}", p < q);
println!("{}", p > q);
true
false

Another option: implement your own comparison

  • More complicated, see below

  • See the documentation for Ord or examples online

#[derive(PartialEq,Eq,Ord,Debug)]
struct Point2 {
    x: i32,
    y: i32,
}

// Let's assume we want to compare point by their distance to the origin
impl std::cmp::PartialOrd for Point2 {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        // 'Self' (capital) is a shorthand for type Point2
        let this = self.x * self.x + self.y * self.y;
        let that = other.x * other.x + other.y * other.y;
        return this.partial_cmp(&that);  // use the partial_cmp() on integer
    }
}

let p = Point2{x:3,y:1};
let q = Point2{x:2,y:100};
println!("{}", p < q);
println!("{}", p > q);
true
false

Complexity of a priority queue?

Assumptions:

  • At most elements
  • Comparison takes time

Straightforward

Representation: a vector of elements

Push:

  • add to the end of the vector
  • Time complexity: (amortized) time

Pop:

  • go over all elements, select the greatest
  • Time complexity:

Binary Heap Implementation

About This Module

This module explores the binary heap data structure, which is the foundation of efficient priority queue implementations. We'll examine the binary heap's tree structure, array-based storage, and implement core operations like push and pop with O(log n) complexity.

Prework

Prework Reading

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. Why is the binary heap structure optimal for priority queue operations?
  2. How does the complete binary tree property enable efficient array-based storage?
  3. What are the key invariants that must be maintained in a max heap?
  4. How do the parent-child index relationships work in array representation?
  5. What are the trade-offs between binary heaps and other priority queue implementations?

Lecture

Learning Objectives

By the end of this module, you should be able to:

  • Understand the binary heap structure and its key properties
  • Navigate binary heap using array indices for parent and child nodes
  • Implement heapify operations to maintain heap ordering
  • Build push and pop operations with O(log n) complexity
  • Analyze the relationship between tree height and operation complexity
  • Compare custom implementations with Rust's standard library BinaryHeap

Binary heaps

  • Data organized into a binary tree (one root, each node with 0, 1 or 2 children)
  • Every internal node not smaller (or greater) than its children
  • Every layer is filled before the next layer starts

Basic property: The root has the current maximum (minimum), i.e., the answer to next pop

[picture of basic binary heap with heap ordering]

Heap storage properties

Efficient storage:

  • Tree levels filled from left to right

  • Can be mapped to a vector

  • Easy to move to the parent or children using vector indices

[picture of basic binary heap with heap ordering] [picture of basic binary heap with heap ordering]

How are operations implemented?

Push

  • add at the end the vector
  • fix the ordering by swapping with the parent if needed
Question: What is the maximum number of swaps you will have to do? Why?





Pop

  • remove and return the root
  • replace with the last element
  • fix the ordering by comparing with children and swapping with each that is greater

Complexity of push and pop

  • Proportional to the number of levels

  • So

Implementation

Utility methods

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct MyBinaryHeap<T> {
    heap: Vec<T>,
    heap_size: usize,
}

impl<T> MyBinaryHeap<T> {
    fn new() -> MyBinaryHeap<T> {
        let heap: Vec<T> = vec![];
        let heap_size = 0;
        MyBinaryHeap { heap, heap_size }
    }

    // left child
    fn left(i: usize) -> usize {
        2 * i + 1
    }

    // right child of node i
    fn right(i: usize) -> usize {
        2 * i + 2
    }

    //parent of  node i
    fn parent(i: usize) -> usize {
        (i - 1) / 2  // integer divide
    }
}
}

Heapify -- Put everything in proper order

Make it so children are parents.

#![allow(unused)]
fn main() {
impl<T:PartialOrd+PartialEq+Copy> MyBinaryHeap<T> {
    fn heapify(&mut self, loc: usize) {
        let l = Self::left(loc);
        let r: usize = Self::right(loc);
        
        let mut largest = loc; // index of largest
        
        if l < self.heap_size && self.heap[l] > self.heap[largest] {
            largest = l;
        }
        if r < self.heap_size && self.heap[r] > self.heap[largest] {
            largest = r;
        }
        
        if largest != loc {
            // swap with child
            let tmp = self.heap[loc];
            self.heap[loc] = self.heap[largest];
            self.heap[largest] = tmp;
            
            self.heapify(largest);
        }
    }
}
}

Insert and Extract

#![allow(unused)]
fn main() {
impl<T:PartialOrd+PartialEq+Copy> MyBinaryHeap<T> {
    
    fn insert_val(&mut self, val: T) {
        self.heap_size += 1;
        self.heap.push(val);
        let mut i = self.heap_size - 1;

        // loop until we reach root and parent is less than current node
        while i != 0 && self.heap[Self::parent(i)] < self.heap[i] {

            // swap node with parent
            let tmp = self.heap[Self::parent(i)];
            self.heap[Self::parent(i)] = self.heap[i];
            self.heap[i] = tmp;

            // update node number
            i = Self::parent(i);  // Self is stand-in for data strucutre MyBinaryHeap
        }
    }
    
    fn extract_max(&mut self) -> Option<T> {
        if self.heap_size == 0 {
            return None;
        }
        
        if self.heap_size == 1 {
            self.heap_size -= 1;
            return Some(self.heap[0]);
        }
        
        let root = self.heap[0];
        self.heap[0] = self.heap[self.heap_size - 1]; // copy last element
        self.heap_size -= 1;
        self.heapify(0);
        return Some(root);
    }
}
}

Let's run the code

#![allow(unused)]
fn main() {
:dep rand="0.8.5"
use rand::Rng;

let mut h:MyBinaryHeap::<i32> = MyBinaryHeap::new();

// Generate 10 random numberrs between -1000 and 1000 and insert
for _i in 0..10 {
    let x = rand::thread_rng().gen_range(-1000..1000) as i32;
    h.insert_val(x);
}

println!("Print the BinaryHeap structure.");
println!("{:?}", h);

println!("\nExtract max values.");
let size = h.heap_size;
for _j in 0..size {
    let z = h.extract_max().unwrap();
    print!("{} ", z);
}

println!("\n\nPrint what's left of the BinaryHeap structure");
println!("{:?}", h);

}
Print the BinaryHeap structure.
MyBinaryHeap { heap: [983, 827, 654, 135, 757, -686, 191, -822, -349, 263], heap_size: 10 }

Extract max values.
983 827 757 654 263 191 135 -349 -686 -822 

Print what's left of the BinaryHeap structure
MyBinaryHeap { heap: [-822, -822, -686, -822, -349, -686, -822, -822, -349, 263], heap_size: 0 }

Note: the last debug print shouldn't display values with heap_size: 0.

What is the property of the list of values we extracted?





Or use the built in one from std::collections

#![allow(unused)]
fn main() {
use std::collections::BinaryHeap;

let mut heap = BinaryHeap::new();
heap.push(5);
heap.push(2);
heap.push(8);

while let Some(max) = heap.pop() {
    println!("{}", max);
}
// Outputs: 8, 5, 2
}

The values are extracted in sorted order (largest first)!

Priority Queue Applications: Heap Sort and Dijkstra's Algorithm

About This Module

This module explores two major applications of priority queues: heap sort for efficient sorting and Dijkstra's algorithm for finding shortest paths in weighted graphs. We'll implement both algorithms using Rust's BinaryHeap and analyze their performance characteristics.

Prework

Prework Reading

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. How does heap sort compare to other O(n log n) sorting algorithms in terms of space complexity?
  2. Why is Dijkstra's algorithm considered a greedy algorithm, and when does this approach work?
  3. What are the limitations of Dijkstra's algorithm with negative edge weights?
  4. How do priority queues enable efficient implementation of graph algorithms?
  5. What are the trade-offs between different shortest path algorithms?

Lecture

Learning Objectives

By the end of this module, you should be able to:

  • Implement heap sort using priority queues and analyze its complexity
  • Understand and implement Dijkstra's shortest path algorithm
  • Use Rust's BinaryHeap for efficient priority-based operations
  • Compare heap sort with other sorting algorithms
  • Design graph representations suitable for shortest path algorithms
  • Analyze time and space complexity of priority queue applications

Application 1: Sorting a.k.a. HeapSort

  • Put everything into a priority queue
  • Remove items in order
#![allow(unused)]
fn main() {
use std::collections::BinaryHeap;

fn heap_sort(v:&mut Vec<i32>) {
    let mut pq = BinaryHeap::new();
    for v in v.iter() {
        pq.push(*v);
    }

    // to sort smallest to largest we iterate in reverse
    for i in (0..v.len()).rev() {
        v[i] = pq.pop().unwrap();
    }
}
}
#![allow(unused)]
fn main() {
let mut v = vec![23,12,-11,-9,7,37,14,11];
heap_sort(&mut v);
v
}
[-11, -9, 7, 11, 12, 14, 23, 37]

Total running time: for numbers

More direct, using Rust operations

#![allow(unused)]
fn main() {
fn heap_sort_2(v:Vec<i32>) -> Vec<i32> {
   BinaryHeap::from(v).into_sorted_vec()
}
}

No extra memory allocated: the initial vector, intermediate binary heap, and final vector all use the same space on the heap

  • BinaryHeap::from(v) consumes v
  • into_sorted_vec() consumes the intermediate binary heap
#![allow(unused)]
fn main() {
let mut v = vec![7,17,3,1,8,11];
heap_sort_2(v)
}
[1, 3, 7, 8, 11, 17]

Sorting already provided for vectors (currently use other algorithms): sort and sort_unstable

HeapSort is faster in time, but takes twice the memory.

#![allow(unused)]
fn main() {
let mut v = vec![7,17,3,1,8,11];
v.sort();
v
}
[1, 3, 7, 8, 11, 17]
#![allow(unused)]
fn main() {
let mut v = vec![7,17,3,1,8,11];
v.sort_unstable(); // doesn't preserve order for equal elements
v
}
[1, 3, 7, 8, 11, 17]

Application 2: Shortest weighted paths (Dijkstra's algorithm)

  • Input graph: edges with positive values, directed or undirected
  • Edge is now (starting node, ending node, cost)
  • Goal: Compute all distances from a given vertex

Some quotes from Edsger Dijkstra

Pioneer in computer science. Very opinionated.

The use of COBOL cripples the mind; its teaching should, therefore, be regarded as a criminal offence.

It is practically impossible to teach good programming to students that have had a prior exposure to BASIC: as potential programmers they are mentally mutilated beyond hope of regeneration.

One morning I was shopping in Amsterdam with my young fiancée, and tired, we sat down on the café terrace to drink a cup of coffee and I was just thinking... Eventually, that algorithm became to my great amazement, one of the cornerstones of my fame

Shortest Weighted Path Description

  1. Mark all nodes unvisited. Create a set of all the unvisited nodes called the unvisited set.

  2. Assign to every node a tentative distance value:

    1. set it to zero for our initial node and to infinity for all other nodes.
    2. During the run of the algorithm, the tentative distance of a node v is the length of the shortest path discovered so far between the node v and the starting node.
    3. Since initially no path is known to any other vertex than the source itself (which is a path of length zero), all other tentative distances are initially set to infinity.
    4. Set the initial node as current.
  3. For the current node, consider all of its unvisited neighbors and calculate their tentative distances through the current node.

    1. Compare the newly calculated tentative distance to the one currently assigned to the neighbor and assign it the smaller one.
    2. For example, if the current node A is marked with a distance of 6, and the edge connecting it with a neighbor B has length 2, then the distance to B through A will be 6 + 2 = 8.
    3. If B was previously marked with a distance greater than 8 then change it to 8. Otherwise, the current value will be kept.
  4. When we are done considering all of the unvisited neighbors of the current node, mark the current node as visited and remove it from the unvisited set.

    1. A visited node will never be checked again
    2. (this is valid and optimal in connection with the behavior in step 6.: that the next nodes to visit will always be in the order of 'smallest distance from initial node first' so any visits after would have a greater distance).
  5. If all nodes have been marked visited or if the smallest tentative distance among the nodes in the unvisited set is infinity (when planning a complete traversal; occurs when there is no connection between the initial node and remaining unvisited nodes), then stop. The algorithm has finished.

  6. Otherwise, select the unvisited node that is marked with the smallest tentative distance, set it as the new current node, and go back to step 3.

How it works:

  • Greedily take the closest unprocessed vertex

    • Its distance must be correct
  • Keep updating distances of unprocessed vertices

Example

Let's illustrate by way of example: Directed graph with weighted edges.

Pick 0 as a starting node and assign it's distance as 0.

Assign distance of to all other nodes.

From 0, travel to each connected node and update the tentative distances.

Now go to the "closest" node (node 2) and update the distances to its immediate neighbors.

Mark node 2 as visited.

Randomly pick between nodes 4 and 1 since they both have updated distances 3.

Pick node 1.

Update the distances to it's nearest neighbors.

Mark node 1 as visited.

Now, pick the node with the lost distance (node 4) and update the distances to it's nearest neighbors.

Distances to nodes 3 and 4 improved.

Then, go to node 3 and update its distance to its nearest neighbor.

Nowhere else to go so mark everything as done.

BinaryHeap to the rescue

Since we always want to pick the cheapest node, we can use a BinaryHeap to find the next node to check.

Dijkstra's Shortest Path Implementation

Auxiliary graph definitions

#![allow(unused)]
fn main() {
use std::collections::BinaryHeap;

type Vertex = usize;
type Distance = usize;
type Edge = (Vertex, Vertex, Distance);  // Updated edge definition.

#[derive(Debug,Copy,Clone)]
struct Outedge {
    vertex: Vertex,
    length: Distance,
}

type AdjacencyList = Vec<Outedge>;   // Adjacency list of Outedge's

#[derive(Debug)]
struct Graph {
    n: usize,
    outedges: Vec<AdjacencyList>,
}

impl Graph {
    fn create_directed(n:usize,edges:&Vec<Edge>) -> Graph {
        let mut outedges = vec![vec![];n];
        for (u, v, length) in edges {
            outedges[*u].push(Outedge{vertex: *v, length: *length});  // 
        }
        Graph{n,outedges}
    }
}
}

Load our graph

#![allow(unused)]
fn main() {
let n = 6;
let edges: Vec<Edge> = vec![(0,1,5),(0,2,2),(2,1,1),(2,4,1),(1,3,5),(4,3,1),(1,5,11),(3,5,5),(4,5,8)];
let graph = Graph::create_directed(n, &edges);
graph
}
Graph { n: 6, outedges: [[Outedge { vertex: 1, length: 5 }, Outedge { vertex: 2, length: 2 }], [Outedge { vertex: 3, length: 5 }, Outedge { vertex: 5, length: 11 }], [Outedge { vertex: 1, length: 1 }, Outedge { vertex: 4, length: 1 }], [Outedge { vertex: 5, length: 5 }], [Outedge { vertex: 3, length: 1 }, Outedge { vertex: 5, length: 8 }], []] }

Our implementation

#![allow(unused)]
fn main() {
let start: Vertex = 0;

let mut distances: Vec<Option<Distance> > = vec![None; graph.n];  // use None instead of infinity
distances[start] = Some(0);
}
#![allow(unused)]
fn main() {
use core::cmp::Reverse;

let mut pq = BinaryHeap::<Reverse<(Distance,Vertex)>>::new();  // make a min-heap with Reverse
pq.push(Reverse((0,start)));
}
#![allow(unused)]

fn main() {
// loop while we can pop -- deconstruct
while let Some(Reverse((dist,v))) = pq.pop() { // boolean, not assignment
    
    for Outedge{vertex,length} in graph.outedges[v].iter() {
        
        let new_dist = dist + *length;

        
        let update = match distances[*vertex] { // assignment match
            None => {true}
            Some(d) => {new_dist < d}
        };

        // update the distance of the node
        if update {
            distances[*vertex] = Some(new_dist);  // record the new distance
            pq.push(Reverse((new_dist,*vertex)));
        }
    }
};
}
#![allow(unused)]
fn main() {
distances
}
[Some(0), Some(3), Some(2), Some(4), Some(3), Some(9)]

Complexity and properties of Dijkstra's algorithm

  • -- worst case visit (N-1) nodes on every node
  • Works just as well with undirected graphs
  • Doesn't work if path weights can be negative (why?)

Traveling salesman

On the surface similar to shortest paths

Given an undirected graph with weighted non-negative edges find the shortest path that starts at a specified vertex, traverses every vertex in the graph and returns to the starting point.

Applications:

  1. Amazon deliver route optimization
  2. Drilling holes in circuit boards -- minimize stepping motor travel.

BUT

Much harder to solve (will not cover here). Provably NP-complete (can not be solved in Polynomial time)

Held-Karp algorithm one of the best exact algorithms with complexity
Many heuristics that run fast but yield suboptimal results

Greedy Heuristic

Mark all nodes as unvisited. Use your starting node and pick the next node with shortest distance and visit it. DFS using minimum distance criteria until all nodes have been visited.

Minimum Spanning Tree Heuristic

https://en.wikipedia.org/wiki/Minimum_spanning_tree
Runs in time and guaranteed to be no more than 50% worse than the optimal.

Binary search

About This Module

This module introduces the binary search algorithm, a fundamental technique for efficiently finding elements in sorted collections. You'll learn how binary search works, its time complexity, and how to implement it in Rust. The module also covers practical applications of binary search and common pitfalls to avoid.

Prework

Prework Reading

Please read the following sections from The Rust Programming Language Book:

  • Chapter 3.2: Guessing Game - Focus on input handling and basic control flow
  • Chapter 4.1: Ownership - Understand how Rust manages memory
  • Chapter 4.2: References and Borrowing - Learn about references, mutable references, and borrowing rules
  • Chapter 4.3: The Slice Type - Understand slices and how they relate to arrays and vectors

Pre-lecture Reflections

Before class, consider these questions:

  1. What is the time complexity of binary search, and how does it compare to linear search?
  2. How does Rust's ownership and borrowing system impact the implementation of algorithms like binary search?
  3. What are some common pitfalls when implementing binary search, and how can they be avoided?
  4. How can binary search be adapted for different data structures, such as linked lists or trees?

Lecture

Learning Objectives

By the end of this module, you will be able to:

  • Understand the binary search algorithm and its time complexity
  • Implement binary search in Rust
  • Apply binary search to solve practical problems
  • Recognize common pitfalls and edge cases in binary search implementations

Binary Search

Input: sorted array/vector v

Task: find if a specific element x appears in it

General strategy:

  • maintain range left..right within v of where x could be
  • repeat:
    • ask about the middle of the range
    • if x found, celebrate
    • if the middle element x, narrow search to the right half
    • otherwise, narrow search to the left half

Let's implement it!

Might be harder than you think!

Jon Bentley in "Programming pearls" claims that only 10% programmers succeed, even given unlimited amount of time

Ad:

  • Great book if you are interested in programming and basic algorithms
  • First edition mostly uses pseudocode
  • 2nd edition focused on C++ (probably still great)
  • very cheap if you want to buy second hand (< $10)

Let's implement it!

/// Employ binary search on a sorted vector to find a value
///
/// # Arguments
///
/// * data:&[i32] -- sorted array
///
/// # Returns
///
/// * Option<(usize, i32)> -- Tuple of index and value
fn present(mut data:&[i32], element:i32) -> Option<(usize,i32)> {
    let (mut left, mut right) = (0, data.len());
    // invariant: if element has not been found, it must be in data[left..right]
    while left < right {
        let middle = (left+right)/2;
        if data[middle] == element {
            return Some((middle, element));
        } else if data[middle] > element {
            right = middle - 1 
        } else {
            left = middle + 1
        }
    }
    None
}
let v = vec![-16,-4,0,2,4,12,32,48,48,111];
present(&v, 111)
Some((9, 111))
present(&v, 5)
None
present(&v, 1000)
None
present(&v, 32)
Some((6, 32))

Technical Coding Challenge

Coding Challenge

Coding Challenge Review

Binary trees

About This Module

This module introduces binary trees, a fundamental data structure in computer science. You'll learn about the properties of binary trees, different types of binary trees (such as binary search trees and heaps), and how to implement and traverse them in Rust. The module will also cover common operations on binary trees, including insertion, deletion, and searching.

Prework

Prework Reading

Please read the following sections from The Rust Programming Language Book:

  • Chapter 3.2: Guessing Game - Focus on input handling and basic control flow
  • Chapter 4.1: Ownership - Understand how Rust manages memory
  • Chapter 4.2: References and Borrowing - Learn about references, mutable references, and borrowing rules
  • Chapter 4.3: The Slice Type - Understand slices and how they relate to arrays and vectors

Pre-lecture Reflections

Before class, consider these questions:

  1. What are the key properties of binary trees that differentiate them from other tree structures?
  2. How do binary search trees maintain order, and what are the implications for search, insertion and deletion operations?
  3. What are the advantages and disadvantages of using binary trees compared to other data structures like arrays or linked lists?
  4. How does Rust's ownership and borrowing system impact the implementation of binary trees?
  5. What are some common applications of binary trees in computer science and software development?

Lecture

Learning Objectives

By the end of this module, you will be able to:

  • Understand the structure and properties of binary trees
  • Implement binary trees in Rust
  • Perform common operations on binary trees, including insertion, deletion, and searching
  • Traverse binary trees using different algorithms (e.g., in-order, pre-order, post-order)
  • Recognize different types of binary trees (e.g., binary search trees, heaps) and their use cases
  • Analyze the time and space complexity of binary tree operations

Trees vs Graphs

Trees are a special type of graph with a few key features

  • Hierarchical structure with a root node
  • Acyclic
  • Parent and child nodes with well-defined ancestry (every node besides the root has only one parent)
  • Only n-1 edges for n nodes
  • May constrain the number of children (in a binary tree each parent can have at most two children)

A general tree:

[non-binary tree example]
[binary tree example]

Why are trees useful?

✅ Anything with a hierarchical data structure (e.g., File systems, HTML DOM structure)

  • File systems: Directories and files form a natural tree—folders contain subfolders and files, and this nesting forms a hierarchy.
  • HTML DOM: The structure of a webpage is a tree where elements nest inside each other; the root is usually the <html> tag.

🗜 Data compression (Huffman coding)

  • Builds a binary tree where the most frequent characters are near the top and less frequent ones further down.
  • Encoding: Each character gets a unique binary string based on its path from the root—shorter for frequent characters.

🧠 Compilers use syntax trees (Abstract Syntax Trees - ASTs)

  • Represents code structure: A program’s nested expressions, blocks, and statements naturally form a tree.
  • Compiler passes: Syntax trees are walked recursively for semantic checks, optimization, and code generation.
  • In Rust: The Rust compiler (rustc) builds and transforms ASTs as part of its pipeline

🔡 Prefix trees (Tries) (discussed below)

  • Spell checking and autocomplete: Fast prefix search to suggest or validate words.
  • Internet routing: IP addresses share prefixes, and tries are used for longest prefix match routing.
  • Memory vs speed tradeoff: Tries use more memory but offer O(k) search time, where k is the key length.

💾 Databases use trees for indexing (e.g., B-trees, B+ trees)

  • Efficient range queries and lookups: Balanced trees ensure O(log n) insert/search/delete.
  • Disk-friendly: B-trees minimize disk reads by keeping nodes wide and shallow.
  • Used in practice: PostgreSQL, SQLite, and others use variants of B-trees for indexing.

🔐 Merkle trees

  • Used in blockchains: Efficient verification of large data sets by checking only a small number of hashes.
  • Tamper detection: Changing one leaf changes hashes all the way up—perfect for integrity checking.
  • Applications: Bitcoin, Git, BitTorrent, and more rely on Merkle trees.

🌐 Spanning trees in networks

  • Minimal spanning tree (MST): Ensures all nodes in a network are connected with minimal total edge weight.
  • Used in routing: Algorithms like Kruskal’s or Prim’s help avoid loops in protocols like Ethernet (Spanning Tree Protocol).

🌳 Decision trees

  • Machine learning: Trees split data based on feature thresholds to make predictions.
  • Easy to interpret: Each path in the tree represents a rule for classification.

🔃 Sorting algorithms

  • Binary heaps: Used in heap sort—trees where the parent is smaller/larger than children.
  • BST-based sorts: In-order traversal of a Binary Search Tree gives sorted order.
  • AVL/Red-Black Trees: Self-balancing trees used to maintain order during dynamic inserts/deletes.

🔍 Search algorithms

  • Binary Search Trees (BSTs): Allow for fast search, insert, and delete in O(log n) time if balanced.
  • Trie-based search: Efficient for prefix and word lookup.
  • Tree traversal: DFS and BFS strategies apply directly—recursion meets queue/stack use in Rust.

Important Terms

  • Root - the topmost node of the tree, only node without a parent
  • Ancestor vs Descendant - If two nodes are connected, the node that is closer to the root is the ancestor of the other node, similarly the further node its descendant
  • Parent vs Child - A nodes immediate descendant is its child, and a nodes immediate ancestor is its parent
  • Leaf - a node with no child nodes
  • Subtree - A subset of the tree, with a parent of the original tree becoming the root of the subtree
  • Depth - the greatest number of descendants between the root and a leaf node
  • Level - the number of edges between a node and the root. Root node has level 0
  • Sibling - nodes that share the same parent

Why are binary trees useful?

  • Simple to analyze
  • Many algorithms boil down to a series of "True/False" decisions that map well to binary trees
  • Ordering in particular fits well (either you are "<" or you aren't)
  • Easy to use with recursive algorithms

What types of binary trees are there?

  • Complete (every level except the last one are full)
  • Perfect (all leaves are at the same level, all interior nodes have 2 children)
  • Balanced (left and right subtrees differ by at most 1 level)
  • Special
    • Binary Heaps
    • Binary Search Trees

Any other interesting trees?

  • Prefix trees or tries
[binary tree example]

Using Vectors to implement a Binary Tree

TreeNode struct and new() method

#[derive(Debug)]
struct TreeNode {
    value: usize,
    left: Option<usize>,
    right: Option<usize>,
}

impl TreeNode {
    // Constructor method
    // This method creates a new TreeNode with the given value
    // and initializes left and right child pointers to None
    fn new(value: usize) -> Self {
        TreeNode {
            value,
            left: None,
            right: None,
        }
    }
}

BinaryTree struct and methods

#[derive(Debug)]
struct BinaryTree {
    // A vector of TreeNode structs
    // The index of the vector is the node index
    nodes: Vec<TreeNode>,
    root: Option<usize>,
}

impl BinaryTree {
    // Constructor method
    // This method creates a new BinaryTree with an empty vector of nodes
    // and a root pointer set to None
    fn new() -> Self {
        BinaryTree { nodes: Vec::new(), root: None }
    }

    fn insert(&mut self, value: usize) {
        let new_node_index = self.nodes.len();
        self.nodes.push(TreeNode::new(value));

        match self.root {
            Some(root_index) => self.insert_at(root_index, new_node_index),
            None => self.root = Some(new_node_index),
        }
    }

    // Inserts a new node into a binary tree by updating the left or right child
    // index of the current node, or if full, a left child node.
    // * Start at the specified current node index:
    //   * if both children are empty, insert the left child
    //   * if left child is occupied, insert the right child
    //   * if both children are occupied, recursively attempt to insert the new
    //     node in the left subtree
    fn insert_at(&mut self, current_index: usize, new_node_index: usize) {
        let current_node = &mut self.nodes[current_index];
        if current_node.left.is_none() {
            current_node.left = Some(new_node_index);
        } else if current_node.right.is_none() {
            current_node.right = Some(new_node_index);
        } else {
            // Insert in left subtree for simplicity, could be more complex to keep balanced
            let left = current_node.left.unwrap();
            self.insert_at(left, new_node_index);
        }
    }
    
}

Remember that left and right are indices, but value is the value we associated with the node.

  • Predict the BinaryTree structure as we repeatedly insert.
let mut tree = BinaryTree::new();
tree.insert(1);
tree.insert(2);
tree.insert(3);
tree.insert(6);
tree.insert(7);
tree.insert(11);
tree.insert(23);
tree.insert(34);

println!("{:#?}", tree);

BinaryTree {
    nodes: [
        TreeNode {
            value: 1,
            left: Some(
                1,
            ),
            right: Some(
                2,
            ),
        },
        TreeNode {
            value: 2,
            left: Some(
                3,
            ),
            right: Some(
                4,
            ),
        },
        TreeNode {
            value: 3,
            left: None,
            right: None,
        },
        TreeNode {
            value: 6,
            left: Some(
                5,
            ),
            right: Some(
                6,
            ),
        },
        TreeNode {
            value: 7,
            left: None,
            right: None,
        },
        TreeNode {
            value: 11,
            left: Some(
                7,
            ),
            right: None,
        },
        TreeNode {
            value: 23,
            left: None,
            right: None,
        },
        TreeNode {
            value: 34,
            left: None,
            right: None,
        },
    ],
    root: Some(
        0,
    ),
}

Reminder: The numbers in the circles are the values, not the indices of the node.

[binary tree]

This isn't super readable, is there a better way to output this tree?

Tree Traversal

There are several ways to traverse trees using algorithms we have seen before:

  • Using BFS
    • Level-order Traversal
  • Using DFS
    • Pre-order Traversal
    • In-order Traversal
    • Post Order Traversal

We are going to use level-order traversal to visit each level of the tree in order

Level-Order Traversal (BFS)

Just like before.

  • Create an empty queue
  • Add the root of tree to queue
  • While the queue is not empty
    • Remove node from queue and visit it
    • Add the left child of node to the queue if it exists
    • Add the right child of node to the queue if it exists
use std::collections::VecDeque;
fn level_order_traversal(tree: &BinaryTree) {
  if let Some(root_index) = tree.root {
    let mut queue = VecDeque::new();
    queue.push_back(root_index);
    while let Some(node_index) = queue.pop_front() {
      let node = &tree.nodes[node_index];
      println!("{}", node.value);
      if let Some(left_index) = node.left {
        queue.push_back(left_index);
      }
      if let Some(right_index) = node.right {
        queue.push_back(right_index);
      }
    }
  }
}

let mut tree2 = BinaryTree::new();
tree2.insert(1);
tree2.insert(2);
tree2.insert(3);
tree2.insert(6);
tree2.insert(7);
tree2.insert(11);
tree2.insert(23);
tree2.insert(34);
level_order_traversal(&tree2);
1
2
3
6
7
11
23
34
// Slightly more complex version that prints each level on a different line
use std::collections::VecDeque;
fn level_order_traversal2(tree: &BinaryTree) {
  if let Some(root_index) = tree.root {
    let mut queue = VecDeque::new();
    queue.push_back(root_index);
    while !queue.is_empty() {
      let level_size = queue.len();
        for _ in 0..level_size {
          if let Some(node_index) = queue.pop_front() {
            let node = &tree.nodes[node_index];
            print!("{} ", node.value);

            if let Some(left_index) = node.left {
              queue.push_back(left_index);
            }
            if let Some(right_index) = node.right {
              queue.push_back(right_index);
            }
        }
      }
      println!(); // New line after each level
    }
  }
}

let mut tree2 = BinaryTree::new();
tree2.insert(1);
tree2.insert(2);
tree2.insert(3);
tree2.insert(6);
tree2.insert(7);
tree2.insert(11);
tree2.insert(23);
tree2.insert(34);
level_order_traversal2(&tree2);
1 
2 3 
6 7 
11 23 
34 

Depth Traversals

Pre-Order Traversal

Often used when making a copy of a tree, uses DFS

Algorithm:

  • Visit the current node (e.g. print the current node value)
  • Recursively traverse the current node's left subtree
  • Recursively traverse the current node's right subtree

The pre-order traversal is a topologically sorted one, because a parent node is processed before any of its child nodes is done.

Reminder: The numbers in the circles are the values, not the indices of the node.

[binary tree]
fn pre_order_traversal(tree: &BinaryTree, node_index: Option<usize>) {
  if let Some(index) = node_index {        // If the node index is not None
    let node = &tree.nodes[index];         // Get the node at the index
    println!("{}", node.value);            // Visit (print) the current node value
    pre_order_traversal(tree, node.left);  // Traverse the left subtree
    pre_order_traversal(tree, node.right); // Traverse the right subtree
  }
}

pre_order_traversal(&tree2, tree2.root)
1
2
6
11
34
23
7
3





()

In-Order Traversal

In-Order traversal:

  • Recursively traverse the current node's left subtree.
  • Visit the current node (e.g. print the node's current value).
  • Recursively traverse the current node's right subtree.

In a binary search tree ordered such that in each node the value is greater than all values in its left subtree and less than all values in its right subtree, in-order traversal retrieves the keys in ascending sorted order.

Reminder: The numbers in the circles are the values, not the indices of the node.

[binary tree]

How do we update pre_order_traversal() to get in_order_traversal()?

fn in_order_traversal(tree: &BinaryTree, node_index: Option<usize>) {
    if let Some(index) = node_index {       // If the node index is not None
      let node = &tree.nodes[index];        // Get the node at the index
      in_order_traversal(tree, node.left);  // Traverse the left subtree
      println!("{}", node.value);           // Visit (print) the current node value
      in_order_traversal(tree, node.right); // Traverse the right subtree
    }
  }
  
  in_order_traversal(&tree2, tree2.root)
34
11
6
23
2
7
1
3





()

Post-Order traversal

Good for deleting the tree or parts of it, must delete children before parents:

  • Recursively traverse the current node's left subtree.
  • Recursively traverse the current node's right subtree.
  • Visit the current node.

Reminder: The numbers in the circles are the values, not the indices of the node.

[binary tree]

How do we update in_order_traversal() to post_order_traversal()?

fn post_order_traversal(tree: &BinaryTree, node_index: Option<usize>) {
    if let Some(index) = node_index {       // If the node index is not None
      let node = &tree.nodes[index];        // Get the node at the index
      post_order_traversal(tree, node.left);  // Traverse the left subtree
      post_order_traversal(tree, node.right); // Traverse the right subtree
      println!("{}", node.value);           // Visit (print) the current node value
    }
  }
  
  post_order_traversal(&tree2, tree2.root)
34
11
23
6
7
2
3
1





()

Technical Coding Challenge

Coding Challenge

Coding Challenge Review

Binary search trees

About This Module

Prework

Prework Reading

Pre-lecture Reflections

Lecture

Learning Objectives

Understand binary search trees

  • Organize data into a binary tree

  • Similar to binary heaps where the parent was greater (or lesser) than either of its children

  • Binary Search Tree: the value of a node is greater than the values of all nodes in its left subtree and less than the value of all nodes in the right subtree.

  • Enables binary search for efficient lookup, addition and removal of items.

  • Each comparison skips half of the remaining tree.

  • Complexity for search, insert and delete is , assuming balanced trees.

[sample tree]
  • Invariant at each node:
    • all left descendants parent
    • parent all right descendants
[sample tree]
  • Compared to binary heaps:
    • different ordering of elements

Basic operations: find a key

How can we do this?

  • Descend recursively from the root until found or stuck:
    • If current node key is , return.
    • If value at the current node, go left
    • If value at the current node, go right
    • If finished searching, return not found


[Example: Find the key 7 in the tree above.]

Basic operations: insert a key

How can we do this?

  • Keep descending from the root until you leave the tree
    • If value at the current node, go left
    • If value at the current node, go right
  • Create a new node containing there


[Example: Insert value 6 in tree above.]



Basic operations: delete a node

How can we do this?

This can be more complicated because we might have to find a replacement.

Case 1 -- Leaf Node

If node is a leaf node, just remove and return.

Case 2 -- One Child

If node has only one child, remove and move the child up.

Example: Remove 4.

      7
     / \
    4   11
     \
      6

Remove 4 and move 6 up.

      7
     / \
    6   11

Case 3 -- Else

Otherwise, find the left-most descendant of the right subtree and move up.

This is the inorder successor.

Example? Remove 4.

        7
       / \
      4   11
     / \
    2   6
   / \ / \
  1  3 5  8

In this case it is 5, so replace 4 with 5.

        7
       / \
      5   11
     / \
    2   6
   / \   \
  1  3    8

Cost of these operations?

O( depth of the tree )

Bad news: the depth can be made proportional to , the number of nodes. How?

Good news: smart ways to make the depth

Balanced binary search trees

There are smart ways to rebalance the tree!

  • Depth:

Binary search trees -- Implementation

  1. Applications (range searching)
  2. Rust: BTreeMap and BTreeSet
  3. Tries (Prefix Trees)
  • Let's slightly revise our TreeNode and BinaryTree structs and methods.

  • The main difference is the insert_at() function.

#[derive(Debug)]
struct TreeNode {
    value: usize,
    left: Option<usize>,
    right: Option<usize>,
}

impl TreeNode {
    fn new(value: usize) -> Self {
        TreeNode {
            value,
            left: None,
            right: None,
        }
    }
}

#[derive(Debug)]
struct BinaryTree {
    nodes: Vec<TreeNode>,
    root: Option<usize>,
}

impl BinaryTree {
    fn new() -> Self {
        BinaryTree { nodes: Vec::new(), root: None }
    }

    fn insert(&mut self, value: usize) {
        let new_node_index = self.nodes.len();
        self.nodes.push(TreeNode::new(value));

        match self.root {
            Some(root_index) => self.insert_at(root_index, new_node_index, value),
            None => self.root = Some(new_node_index),
        }
    }

    fn insert_at(&mut self, current_index: usize, new_node_index: usize, value:usize) {
        let current_node = &mut self.nodes[current_index];
        if current_node.value < value {
            if current_node.right.is_none() {
                current_node.right = Some(new_node_index);
            } else {
                let right = current_node.right.unwrap();
                self.insert_at(right, new_node_index, value);
            }
        } else {
            if current_node.left.is_none() {
                current_node.left = Some(new_node_index);
            } else {
                let left = current_node.left.unwrap();
                self.insert_at(left, new_node_index, value);
            }
        }
    }
}

  • What happens if we insert the following values?

let mut tree = BinaryTree::new();
tree.insert(1);
tree.insert(2);
tree.insert(3);
tree.insert(6);
tree.insert(7);
tree.insert(11);
tree.insert(23);
tree.insert(34);
println!("{:#?}", tree);

BinaryTree {
    nodes: [
        TreeNode {
            value: 1,
            left: None,
            right: Some(
                1,
            ),
        },
        TreeNode {
            value: 2,
            left: None,
            right: Some(
                2,
            ),
        },
        TreeNode {
            value: 3,
            left: None,
            right: Some(
                3,
            ),
        },
        TreeNode {
            value: 6,
            left: None,
            right: Some(
                4,
            ),
        },
        TreeNode {
            value: 7,
            left: None,
            right: Some(
                5,
            ),
        },
        TreeNode {
            value: 11,
            left: None,
            right: Some(
                6,
            ),
        },
        TreeNode {
            value: 23,
            left: None,
            right: Some(
                7,
            ),
        },
        TreeNode {
            value: 34,
            left: None,
            right: None,
        },
    ],
    root: Some(
        0,
    ),
}
1
 \
  2
   \
    3
     \
      6
       \
        7
         \
         11
          \
          23
           \
           34

Unbalanced binary search trees can be inefficient!

This is a degenerate tree -- basically a linked list.

Balanced binary search trees

There are smart ways to rebalance the tree!

  • Depth:

  • Usually additional information has to be kept at each node

  • Many popular, efficient examples:

    • Red–black trees
    • AVL trees
    • BTrees (Used in Rust)
    • ...

    Fundamentally they all support rebalancing operations using some form of tree rotation.

    We'll look at some simple approaches.

Basic operations: rebalance a tree

How can we do this?

  • Quite a bit more complicated

  • One basic idea. Find the branch that has gotten too long

    • Swap the parent with the child that is at the top of the branch by making the child the parent and the parent the child
    • If you picked a left child take the right subtree and make it a left child of the old parent
    • if you picked a right child take left subtree and make it a right child of the old parent


Example

Let's go back to our degenerate tree. It's just one long branch.

1
 \
  2
   \
    3
     \
      6
       \
        7
         \
         11
          \
          23
           \
           34

Step 1: Rotate around 1-2-3.

  2
 / \
1   3
     \
      6
       \
        7
         \
         11
          \
          23
           \
           34

Step 2: Rotate around 3-6-7.

  2
 / \
1   6
   / \
  3   7
       \
       11
        \
        23
         \
         34

Step 3: Rotate around 7-11-23.

  2
 / \
1   6
   / \
  3   11
     /  \
    7    23
          \
          34

A simple way to rebalance a binary tree

  • First do an in-order traversal to get the nodes in sorted order
  • Then use the middle of the sorted vector to be the root of the tree and recursively build the rest
impl BinaryTree {
    fn in_order_traversal(&self, node_index: Option<usize>)->Vec<usize> {
        let mut u: Vec<usize> = vec![];
        if let Some(index) = node_index {
            let node = &self.nodes[index];
            u = self.in_order_traversal(node.left);
            let mut v: Vec<usize> = vec![node.value];
            let mut w: Vec<usize> = self.in_order_traversal(node.right);
            u.append(&mut v);
            u.append(&mut w);
        }
        return u;
    }
}


let mut z = tree.in_order_traversal(tree.root);
z.sort();
println!("{:?}", z);
[1, 2, 3, 6, 7, 11, 23, 34]
impl BinaryTree {
    // This function recursively builds a perfectly balanced BST by:
    // 1. Finding the middle element of a sorted array to use as the root
    // 2. Recursively building the left and right subtrees using the elements
    //    before and after the middle element, respectively.
    fn balance_bst(&mut self, v: &[usize], start:usize, end:usize) -> Option<usize> {
        if start >= end {
            return None;
        }
        let mid = (start+end) / 2;
        let node_index = self.nodes.len();
        self.insert(v[mid]);
        self.nodes[node_index].left = self.balance_bst(v, start, mid);
        self.nodes[node_index].right = self.balance_bst(v, mid+1, end);
        Some(node_index)
    }
}


let mut bbtree = BinaryTree::new();
bbtree.balance_bst(&z, 0, z.len());
println!("{:#?}", bbtree);
println!("{:?}", level_order_traversal2(&bbtree));
BinaryTree {
    nodes: [
        TreeNode {
            value: 7,
            left: Some(
                1,
            ),
            right: Some(
                5,
            ),
        },
        TreeNode {
            value: 3,
            left: Some(
                2,
            ),
            right: Some(
                4,
            ),
        },
        TreeNode {
            value: 2,
            left: Some(
                3,
            ),
            right: None,
        },
        TreeNode {
            value: 1,
            left: None,
            right: None,
        },
        TreeNode {
            value: 6,
            left: None,
            right: None,
        },
        TreeNode {
            value: 23,
            left: Some(
                6,
            ),
            right: Some(
                7,
            ),
        },
        TreeNode {
            value: 11,
            left: None,
            right: None,
        },
        TreeNode {
            value: 34,
            left: None,
            right: None,
        },
    ],
    root: Some(
        0,
    ),
}
7 
3 23 
2 6 11 34 
1 
()

Produces the result:

        7
      /   \
    3      23
   / \    /  \
  2   6  11  34
 /
1

Why use binary search trees?

  • Hash maps and hash sets give us time operations?

Reason 1:

  • Good worst case behavior: no need for a good hash function

Reason 2:

  • Can answer efficiently questions such as:
    • What is the smallest/greatest element?
    • What is the smallest element greater than ?
    • List all elements between and

Example: find the smallest element greater than

Question: How can you list all elements in order in time?



Answer: recursively starting from the root

  • visit left subtree
  • output current node
  • visit right subtree

Outputting smallest element greater than :

  • Like above, ignoring whole subtrees smaller than
  • Will get the first element greater than in time

For balanced trees: listing first greater elements takes time

-Trees

Are there binary search trees in Rust's standard library?

  • Not exactly

  • Binary Search Trees are computationally very efficent for search/insert/delete ().

  • In practive, very inefficient on modern computer architectures.

    • Every insert triggers a heap allocation
    • Every single comparison is a cache-miss.

Enter -trees:

  1. B-trees are balanced search trees where each node contains between B and 2B keys, with one more subtree than keys.

  2. All leaf nodes are at the same depth, ensuring consistent O(log n) performance for search, insert, and delete operations.

  3. B-trees are widely used in database systems for indexing and efficient range queries.

  4. They're implemented in Rust's standard library as BTreeMap and BTreeSet for in-memory operations.

  5. The structure is optimized for both disk and memory operations, with nodes sized appropriately for the storage medium.

By CyHawk, CC BY-SA 3.0, https://commons.wikimedia.org/w/index.php?curid=11701365

What does the B stand for?

Invented by Bayer and McCreight at Boeing. Suggested explanations have been Boeing, balanced, broad, bushy, and Bayer. McCreight has said that "the more you think about what the B in B-trees means, the better you understand B-trees.

BTreeSet and BTreeMap

std::collections::BTreeSet
std::collections::BTreeMap

Sets and maps, respectively

// let's create a set
use std::collections::BTreeSet;
let mut set: BTreeSet<i32> = BTreeSet::new();
set.insert(11);
set.insert(7);
set.insert(5);
set.insert(23);
set.insert(25);
// listing a range
set.range(7..25).for_each(|x| println!("{}", x));
7
11
23
// listing a range: left and right bounds and 
// whether they are included or not
use std::ops::Bound::{Included,Excluded};
set.range((Excluded(5),Included(11))).for_each(|x| println!("{}", x));
7
11
// Iterating through the items is the in-order traversal that will give you sorted output
for i in &set {
    println!("{}", *i);
}
5
7
11
23
25





()
// let's make a map now
use std::collections::BTreeMap;
// We can rely on type inference to avoid having to fully write out the types
let mut map = BTreeMap::new();
map.insert("DS310", "Data Mechanics");
map.insert("DS210", "Programming for Data Science");
map.insert("DS120", "Foundations of Data Science I");
map.insert("DS121", "Foundations of Data Science II");
map.insert("DS122", "Foundations of Data Science III");


// Try to find a course
if !map.contains_key("DS111") {
    println!("Course not found");
}
Course not found





()
for (course, name) in &map {
    println!("{course}: \"{name}\"");
}
DS120: "Foundations of Data Science I"
DS121: "Foundations of Data Science II"
DS122: "Foundations of Data Science III"
DS210: "Programming for Data Science"
DS310: "Data Mechanics"





()

Note that the keys are sorted.

// listing a range
map.range("DS000".."DS199").for_each(|(x,y)| println!("{}: \"{}\"", x, y));
DS120: "Foundations of Data Science I"
DS121: "Foundations of Data Science II"
DS122: "Foundations of Data Science III"

BTreeMap vs HashMap

  1. Use HashMap when:

    • You need O(1) average-case lookup time for specific keys
    • You don't need to maintain any particular order of elements
    • You're doing mostly individual key lookups rather than range queries
    • Your keys implement Hash, Eq, and PartialEq traits
  2. Use BTreeMap when:

    • You need to efficiently list all values in a specific range
    • You need ordered iteration over keys/values
    • You need to find the smallest/greatest element or elements between two values
    • You need consistent O(log n) performance for all operations
    • Your keys implement Ord trait
  3. Key Differences:

    • HashMap provides O(1) average-case lookup but can have O(n) worst-case
    • BTreeMap provides guaranteed O(log n) performance for all operations
    • BTreeMap maintains elements in sorted order
    • HashMap is generally faster for individual lookups when you don't need ordering
  4. Practical Examples:

    • Use HashMap for: counting word frequencies, caching, quick lookups
    • Use BTreeMap for: maintaining ordered data, range queries, finding nearest values
  5. Memory Considerations:

    • HashMap may use more memory due to its underlying array structure which needs resizing (doubling and copyng) when full
    • BTreeMap's memory usage is more predictable and grows linearly with the number of elements -- adds new nodes as needed.

Prefix Tree (Trie)

Can be pronounced /ˈtraɪ/ (rhymes with tide) or /ˈtriː/ (tree).

A very efficient data structure for dictionary search, word suggestions, error corrections etc.

A trie for keys "A", "to", "tea", "ted", "ten", "i", "in", and "inn". Each complete English word has an arbitrary integer value associated with it. Wikipedia

Available in Rust as an external create https://docs.rs/trie-rs/latest/trie_rs/

:dep trie-rs="0.1.1"
fn testme() {
    use std::str;
    use trie_rs::TrieBuilder;
    let mut builder = TrieBuilder::<u8>::new();  
    
    builder.push("to");
    builder.push("tea");
    builder.push("ted");
    builder.push("ten");
    builder.push("teapot");
    builder.push("in");
    builder.push("inn");
    let trie = builder.build();
    println!("Find suffixes of \"te\"");
    let results_in_u8s: Vec<Vec<u8>> = trie.predictive_search("te");
    let results_in_str: Vec<&str> = results_in_u8s
        .iter()
        .map(|u8s| str::from_utf8(u8s).unwrap())
        .collect();
    println!("{:?}", results_in_str);
}

testme();

Find suffixes of "te"
["tea", "teapot", "ted", "ten"]

To Insert a Word

  1. Start at the Root

    • Begin at the root node of the trie
    • The root node represents an empty string
  2. Process Each Character

    • For each character in the word you want to insert:
      • Check if the current node has a child node for that character
      • If yes: Move to that child node
      • If no: Create a new node for that character and make it a child of the current node
  3. Mark the End

    • After processing all characters, mark the final node as an end-of-word node
    • This indicates that a complete word ends at this node

Let me illustrate with an example. Suppose we want to insert the word "tea" into an empty trie:

Step 1: Start at root
   (root)

Step 2: Insert 't'
   (root)
     |
     t

Step 3: Insert 'e'
   (root)
     |
     t
     |
     e

Step 4: Insert 'a'
   (root)
     |
     t
     |
     e
     |
     a*

Step 5: Mark 'a' as end-of-word (shown with *)

If we then insert "ted", the trie would look like:

   (root)
     |
     t
     |
     e
    / \
   a*  d*

The asterisk (*) marks nodes where words end. This structure allows us to:

  • Share common prefixes between words
  • Efficiently search for words
  • Find all words with a given prefix
  • Check if a word exists in the trie

Most likely your spellchecker is based on a trie

In this case, we will compare possible close matches and then sort by frequency of occurence in some corpus and present top 3-5.

If your word is not in the trie do the following:

  • Step 1: Find the largest prefix that is present and find the trie words with that prefix
  • Step 2: Delete the first letter from your word and redo Step 1
  • Step 3: Insert a letter (for all letters) to the beginning of the word and redo Step 1
  • Step 4: Replace the beginning letter with a different one (for all letters) and redo Step 1
  • Step 5: Transpose the first two letters and redo Step 1
  • Step 6: Collect all words from Steps 1-5 sort by frequency of occurrence and present top 3-5 to user

Technical Coding Challenge

Coding Challenge

Coding Challenge Review

Algorithm Design

About This Module

Prework

Prework Reading

Pre-lecture Reflections

Lecture

Learning Objectives

Big picture

Review a few approaches to algorithm design:

  • Greedy approach
  • Divide and conquer

Others:

  • Dynamic Programming
  • Branch and Bound
  • Backtracking

You'll likely see these in DS320 -- Algorithms for Data Science

Why do we care about algorithm design?

  • Many problems share similar structure
  • Having an algorithmic approach for a certain style of problem makes things a lot easier!
  • Different approaches have tradeoffs depending on what you want to prioritize

Algorithms to Live By

From book page.

A fascinating exploration of how computer algorithms can be applied to our everyday lives, helping to solve common decision-making problems and illuminate the workings of the human mind.

All our lives are constrained by limited space and time, limits that give rise to a particular set of problems. What should we do, or leave undone, in a day or a lifetime? How much messiness should we accept? What balance of new activities and familiar favorites is the most fulfilling? These may seem like uniquely human quandaries, but they are not: computers, too, face the same constraints, so computer scientists have been grappling with their version of such problems for decades. And the solutions they’ve found have much to teach us.

In a dazzlingly interdisciplinary work, acclaimed author Brian Christian and cognitive scientist Tom Griffiths show how the simple, precise algorithms used by computers can also untangle very human questions. They explain how to have better hunches and when to leave things to chance, how to deal with overwhelming choices and how best to connect with others. From finding a spouse to finding a parking spot, from organizing one’s inbox to understanding the workings of human memory, Algorithms to Live By transforms the wisdom of computer science into strategies for human living.

Technical Coding Challenge

Coding Challenge

Coding Challenge Review

Greedy algorithms

About This Module

Prework

Prework Reading

Pre-lecture Reflections

Lecture

Learning Objectives

Greedy algorithms

  • Make locally best (or any) decision towards solving a problem

Warm Up: Coin Change Problem

You are a cashier and you need to give US $0.43 back to your customer.

Problem: We want to find the smallest number of coins that sum up to a given value.

Assumption: We're not limited by number of coins in any denomination.

Input: Set of coin denominations, Target value

Output: Minimal set of coins adding up to value

What would a greedy approach look like here?




Idea: Pick the largest denomination for the remaining value and repeat until done.


Let's try it out.

fn coin_change(denominations: &[usize], value: usize) {
    // Arguments:
    // denominations: list of coin denominations sorted in decreasing value
    // value: target value we are trying to match

    // count notes using Greedy approach
    let mut coin_counter = vec![0; denominations.len()];
    
    let mut remainder = value;
    for i in 0..denominations.len() {
        // will at least one of the current denomination work?
        if remainder >= denominations[i] {
            // check how many of denomination i we can use.
            coin_counter[i] = remainder / denominations[i];
            remainder = remainder % denominations[i];
        }
    }
     
    // Print notes
    println!("Currency Count ->");
    for i in 0..denominations.len() {
        if coin_counter[i] != 0 {
            println!("{} : {}", denominations[i], coin_counter[i]);
        }
    }
}
let denoms = [25,10,5,1];  // US coin denominations in decreasing order
let val = 41;
coin_change(&denoms, val);
Currency Count ->
25 : 1
10 : 1
5 : 1
1 : 1
// lets try a different set of denominations
let denoms = [25,15,1];
let val = 30;
coin_change(&denoms, val);
Currency Count ->
25 : 1
1 : 5

Warning: The greedy approach is only optimal for certain sets of denominations! These are known as canonical coin systems.

Canonical Coin System:

For the greedy algorithm to work correctly and always give the minimum number of coins, the coin denominations must have the following property:

  • The denominations must be in a "canonical" system, meaning that each coin value is at least twice the value of the next smaller denomination.

This fails because 25 is not at least twice the value of 15.

When this property doesn't hold (like in our example with [25, 15, 1]), we need to use a more sophisticated algorithm like dynamic programming to find the optimal solution.

Is there a country with an non-canonical coin system?

// United Kingdom before decimalization in 1971
let denoms = [30,24,12,6,3,1];
let val = 48;
coin_change(&denoms, val);
Currency Count ->
30 : 1
12 : 1
6 : 1

We could have done better with 2x 24p.

Data science connection in a future lecture

  • Heuristics for creating decision trees
    • select "best" single split and recurse

Example we have seen

  • Shortest paths (Dijkstra's algorithm)
    • select the vertex known to be closest
    • try routing paths through it
    • Gives globally optimal solution!!!

Another Example: Minimum spanning tree

Find cheapest overall subset of edges so that the graph is connected

  • Kruskal's algorithm: keep adding the cheapest edge that connects disconnected groups vertices

  • Complexity

Why is MST useful?

  • Connecting N locations with fiber using the least amount of fiber

  • Traveling salesman approximate solution

Algorithm Outline

Start with a graph with weighted edges.

Disconnect all edges...

  1. Find the cheapest edge remaining
  2. Look at the 2 nodes it connects and find the root of the tree they belong to
  3. If they belong to the same tree go back to step 1
  4. Else join the two trees into a single tree

Define Graph Structure

We'll define a modified graph structure that simultaneously represents a weighted undirected graph and a tree.

type Vertex = usize;
type Distance = usize;
type Edge = (Vertex, Vertex, Distance);

#[derive(Debug,Copy,Clone)]
struct Outedge {
    vertex: Vertex,
    length: Distance,
}

type AdjacencyList = Vec<Outedge>;

// We're updating this struct to include parent and rank
#[derive(Debug)]
struct Graph {
    n: usize,
    outedges: Vec<Edge>,  // list of edges
    parent: Vec<Vertex>,  // parent[i] is the parent ID of node i
    rank: Vec<usize>,     // how deep is the tree rooted at this node?
}

impl Graph {
    fn create_undirected(n:usize, outedges:Vec<Edge>) -> Graph {
        // Create a graph from a list of edges
        //
        // This function creates a graph from a list of edges and initializes
        // the parent and rank vectors.  It also sorts the edges by distance.
        // Internally, the graph is represented as a list of edges, a parent
        // vector, and a rank vector, with parents and ranks indexed in the
        // same order as the outedges vector.
        //
        // Arguments:
        //     n: number of nodes
        //     outedges: list of edges
        // Returns:
        //     Graph: a graph with the given number of nodes and edges

        // Initialize parent and rank vectors
        let parent: Vec<Vertex> = vec![];
        let rank: Vec<usize> = vec![];

        // Create the graph
        let mut g = Graph{n,outedges,parent,rank};

        // Sort the edges by distance  O(n log n)
        g.outedges.sort_by(|a, b| a.2.cmp(&b.2));

        // Initialize parent and rank vectors
        // From a tree perspective, it's just a forest of single node "trees"
        for node in 0..g.n {
            // The parent and rank vectors are indexed in the same order
            // as the outedges vector
            g.parent.push(node);  // set each node to be its own parent
            g.rank.push(0);      // set the rank of each node to 0
        }
        g
    }
    
    fn find(&mut self, i:Vertex) -> Vertex {
        // Recursively find the root of the tree rooted at i.
        // O(log n) -- logarithmic time
        //
        // Arguments:
        //     i: node to find the root of
        // Returns:
        //     Vertex: the root of the tree rooted at i

        if self.parent[i] != i {
            self.parent[i] = self.find(self.parent[i]);
        }
        return self.parent[i];
    }

    fn union(&mut self, i:Vertex, j:Vertex) {
        // Union the trees rooted at i and j by making the root of the
        // smaller ranked tree a child of the root of the larger tree. 
        // O(1) -- constant time
        //
        // Arguments:
        //     i: node to union with
        //     j: node to union with

        if self.rank[i] < self.rank[j] {
            self.parent[i] = j;
        } else if self.rank[i] > self.rank[j] {
            self.parent[j] = i;
        } else {
            self.parent[j] = i;
            self.rank[i] += 1;
        }
    }
}

Let's consider the following graph.

module110_1.png

// Let's create the graph
let n = 9;
let edges: Vec<Edge> = vec![(7,6,1),(8,2,2),(6,5,2),(0,1,4),(2,5,4),(8,6,6),(2,3,7),(7,8,7),(0,7,8),(1,2,8),(3,4,9),(5,4,10),(1,7,11),(3,5,14)];
let mut g = Graph::create_undirected(n, edges);
g
Graph { n: 9, outedges: [(7, 6, 1), (8, 2, 2), (6, 5, 2), (0, 1, 4), (2, 5, 4), (8, 6, 6), (2, 3, 7), (7, 8, 7), (0, 7, 8), (1, 2, 8), (3, 4, 9), (5, 4, 10), (1, 7, 11), (3, 5, 14)], parent: [0, 1, 2, 3, 4, 5, 6, 7, 8], rank: [0, 0, 0, 0, 0, 0, 0, 0, 0] }

Kruskal's MST

impl Graph {
    fn KruskalMST(&mut self) -> Vec<Edge> {
        // Arguments:
        // self: a mutable reference to the graph
        // Returns:
        // Vec<Edge>: a vector of edges that form the minimum spanning tree

        // Initialize the result vector and counters
        let mut result: Vec<Edge> = vec![];
        let mut num_mst_e = 0;
        let mut next_edge = 0;  // start with the smallest weighted edge

        // Loop until we built a tree that has n-1 edges
        // A tree with n nodes has n-1 edges
        while num_mst_e < self.n - 1 {  // O(n)
            let (u,v,w) = self.outedges[next_edge];
            next_edge = next_edge + 1;

            let x = self.find(u);  // find the root of u
            let y = self.find(v);  // find the root of v
            
            // If u and v are in different trees, add the edge to the MST
            if x != y {
                num_mst_e += 1;
                result.push((u,v,w));
                self.union(x,y);  // join the two trees at their roots
            }
        }
        result
    }
}

Complexity Analysis

  • while loop -- times
  • Insde the loop
    • for each of 2 .find()
    • Constant time for the .union()

Which, in order notation would be:




let result = g.KruskalMST();
let mut min_cost = 0;
for (u,v,w) in result {
    min_cost += w;
    println!("{} -- {} == {}", u, v, w)
}
println!("Spanning Tree Cost {}", min_cost);
7 -- 6 == 1
8 -- 2 == 2
6 -- 5 == 2
0 -- 1 == 4
2 -- 5 == 4
2 -- 3 == 7
0 -- 7 == 8
3 -- 4 == 9
Spanning Tree Cost 37

Other interesting graph problems (independent reading if you are interested)

  • Matching: matching conference attendees to available desserts they like
    • Another formulation: maximum size set of independent edges in graph
    • Keep adding edges as long as you can
    • This will give factor 2 approximation

Traveling Salesman Approximation using MST

  • Make a MST
  • Pick an arbitrary vertex to be the root of the tree
  • Run DFS from the root
  • Shortcut where you can to shorten the tour.

For example a DFS of the above tree might yield:

0 - 1 - 0 - 7 - 6 - 5 - 2 - 8 - 2 - 3 - 4 - 3 - 2 - 5 - 6 - 7 - 0

Whenever you go to a new node through an already visited node, check to see if you can get there directly with less cost:

0 - 1 - (0) - 7 - 6 - 5 - 2 - 8 - 2 - 3 - 4 - (3 - 2) - 5 - 6 - 7 - 0

This is still not optimal but guaranteed to be within 2x of optimal (proof beyond our scope)

Greedy Algorithms Recap

  • They often work well in practice
  • They are polynomial on the size of their input (e.g. or better)
  • They don't always find the best solutions (recall the coin problem with non-canonical denominations)
  • They definitely don't prove that P != NP :-)

P vs NP

  • P represents the class of problems that can be solved in polynomial time.

  • NP represents the class of problems where solutions are unknown, but solutions can be verified in polynomial time

  • If a problem is NP, is it also P?

  • Answer this and get $1M

Technical Coding Challenge

Coding Challenge

Coding Challenge Review

Divide and conquer

About This Module

Prework

Prework Reading

Pre-lecture Reflections

Lecture

Learning Objectives

Applies to a class of problem where it might be difficult to solve directly, but the problem can be easily subdivided.

If your problem is too difficult:

  • partition it into subproblems
  • solve the subproblems
  • combine their solutions into a solution to the entire problem

We'll look at 3 algorithms

  1. Simple averaging
  2. Merge Sort
  3. Quick Sort

Warm Up: Taking the average of an array

How can we use a divide and conquer approach to taking the average of an array?

How would you do this?

fn divide_and_conquer_average(arr: &[f64]) -> (f64, usize) {
    // A divide and conquer approach to finding the average of an array using
    // recursion.
    //
    // This function takes a slice of floating point numbers and returns a tuple
    // containing the average and the number of elements in the array.
    //
    // Arguments:
    //     arr: A slice of floating point numbers
    //
    // Returns:
    //     A tuple containing the average and the number of elements in the array

    if arr.len() == 1 {
        return (arr[0], 1);
    } else {
        let mid = arr.len() / 2;
        let (left_sum, left_count) = divide_and_conquer_average(&arr[..mid]);
        let (right_sum, right_count) = divide_and_conquer_average(&arr[mid..]);
        return (left_sum + right_sum, left_count + right_count)
    }
}
let arr = [1.0, 28.0, 32.0, 41.0, 25.0];
let (total, num) = divide_and_conquer_average(&arr);
println!("The average is {}", total/num as f64);

let straightforward_avg = arr.iter().sum::<f64>() / arr.len() as f64;
println!("The straightforward average is {}", straightforward_avg);

The average is 25.4
The straightforward average is 25.4

Is this actually more efficient than the straightforward approach?

No! Why?



Answer:

We are subdividing to levels and at level we are doing additions, so total

The sum of the first terms of a geometric series is:

Plug in and and simplify.

Using properties of logarithms:

Final Answer:

This holds for N a power of 2.

It is still a useful toy example though

Our plan: see two classic divide and conquer sorting algorithms


How would you do this?

Merge Sort

Recursively:

  • sort the first half
  • sort the second half
  • merge the results
Complexity for n elements?
  • Merging two lists of and elements takes time, or
  • levels of recursion: work on each level
  • time overall

BUT

  • Difficult to do in-place (requires extra memory allocations for intermediate results)

Implementing merging

fn merge(v1:Vec<i32>, v2:Vec<i32>) -> Vec<i32> {
    // Take two sorted vectors and merge them into a single sorted vector
    //
    // This function takes two vectors of integers and returns a new vector
    // containing all the elements from both vectors in sorted order.
    //
    // Arguments:
    //     v1: A sorted vector of integers
    //     v2: A sorted vector of integers
    //
    // Returns:
    //     A new vector containing all the elements from both vectors in sorted order

    let (l1,l2) = (v1.len(), v2.len());
    let mut merged = Vec::with_capacity(l1+l2); // preallocate memory
    let (mut i1, mut i2) = (0,0);
    
    while i1 < l1 {
        if (i2 == l2) || (v1[i1] <= v2[i2]) {
            merged.push(v1[i1]);
            i1 += 1;
        } else {
            merged.push(v2[i2]);
            i2 += 1;
        }
    }
    while i2 < l2 {
        merged.push(v2[i2]);
        i2 += 1;
    }
    merged
}
let v1 = vec![3,4,8,11,12];
let v2 = vec![1,2,3,9,22];
merge(v1,v2)
[1, 2, 3, 3, 4, 8, 9, 11, 12, 22]

Implementing Merge Sort

Now implement divide and conquer via recursion.

fn merge_sort(input:&[i32]) -> Vec<i32> {
    // Implement merge sort using divide and conquer
    //
    // This function takes a slice of integers and returns a new vector
    // containing all the elements from the input slice in sorted order.
    //
    // Arguments:
    //     input: A slice of integers
    //
    // Returns:
    //     A new vector containing all the elements from the input slice in sorted order

    if input.len() <= 1 {
        input.to_vec()
    } else {
        let split = input.len() / 2;
        let v1 = merge_sort(&input[..split]);
        let v2 = merge_sort(&input[split..]);
        merge(v1,v2)
    }
}
let v = vec![2,4,21,6,2,32,62,0,-2,8];
merge_sort(&v)
[-2, 0, 2, 2, 4, 6, 8, 21, 32, 62]

See merge_sort Cargo project if you want to debug.

Quick Sort

  • Select an arbitrary (random?) element from the array
  • Partition your vector:
    • Move elements lower than to the left
    • Move elements greater than to the right
    • which will result in 3 partitions:
      1. the left partition
      2. the middle partition (in the correct location(s))
      3. the right partition
  • Repeat again for the left and right partitions
Complexity for n elements?
  • Partitioning elements takes time
  • Intuition: The size of the problem usually decreases by constant factor in a recursive call
  • Expected time: time overall (probabilistic algo analysis)
    • but worst case when the array is already sorted and pivot is always first or last element.
    • recursive calls and each level does about comparisons.

Implementing partitioning

fn partition(input:&mut [i32], pivot: i32) -> (usize,usize) {
    // move numbers lower than pivot to the left
    let mut left = 0;
    for i in 0..input.len() {
        if input[i] < pivot {
            input.swap(i,left);
            left += 1;
        }
    }
    // now input[..left] are all numbers lower than pivot

    // move numbers greater than pivot to the right
    let mut right = input.len();
    for i in (left..input.len()).rev() {
        if input[i] > pivot {
            right -= 1;
            input.swap(i,right);
        }
    }
    // input[right..]: numbers greater than pivot
    
    // left is the index of the pivot and
    // right is the index of the first number greater than pivot
    (left,right)
}

Implementing QuickSort

:dep rand
use rand::Rng;

fn quicksort(input:&mut [i32]) {
    if input.len() >= 2 {    
        // pivot = random element from the input
        let pivot = input[rand::rng().random_range(0..input.len())];

        // partition the input array around the pivot
        let (left,right) = partition(input, pivot);
        println!("\nL {} R {} P {} P {}", left, right, pivot, input[left]);
        
        println!("Left side {:?}", &input[..left]);
        println!("Right side {:?}", &input[right..]);
        
        quicksort(&mut input[..left]);
        quicksort(&mut input[right..]);
    }
}
let mut q = vec![145,12,3,7,83,12,8,64];
quicksort(&mut q);
println!("{:?}", q);
L 7 R 8 P 145 P 145
Left side [12, 3, 7, 83, 12, 8, 64]
Right side []

L 6 R 7 P 83 P 83
Left side [12, 3, 7, 12, 8, 64]
Right side []

L 5 R 6 P 64 P 64
Left side [12, 3, 7, 12, 8]
Right side []

L 1 R 2 P 7 P 7
Left side [3]
Right side [12, 12, 8]

L 1 R 3 P 12 P 12
Left side [8]
Right side []
[3, 7, 8, 12, 12, 64, 83, 145]

See quicksort Cargo project to single step debug.

Recap of Divide and Conquer algorithms

  • They divide the problems into smaller subproblems
  • They are always polynomial in execution time
  • They tell us nothing about P and NP

Note on Recursion in Rust

Recursion with mutable references can be quite hard in Rust due to the borrow checker

A relatively simple example of the problem.

We want to make another mutable reference to the first element and pass the rest of the slice to the recursive call.

fn process_slice(slice: &mut [i32]) {
    if !slice.is_empty() {
        let first = &mut slice[0]; // Mutable borrow of the first element
        process_slice(&mut slice[1..]); // Recursive call borrows the rest of the slice mutably
        *first *= 2; // Attempt to modify the first element after recursive call
    }
}

fn testme() {
    let mut numbers = [1, 2, 3, 4];
    process_slice(&mut numbers);
    println!("{:?}", numbers);
}

testme();
[E0499] Error: cannot borrow `*slice` as mutable more than once at a time

   ╭─[command_2:1:1]

   │

 3 │         let first = &mut slice[0]; // Mutable borrow of the first element

   │                     ──────┬──────  

   │                           ╰──────── first mutable borrow occurs here

 4 │         process_slice(&mut slice[1..]); // Recursive call borrows the rest of the slice mutably

   │                            ──┬──  

   │                              ╰──── second mutable borrow occurs here

 5 │         *first *= 2; // Attempt to modify the first element after recursive call

   │         ─────┬─────  

   │              ╰─────── first borrow later used here

───╯
fn process_slice(slice: *mut i32, len: usize) {
    if len == 0 {
        return;
    }

    unsafe {
        // Access and modify the first element
        let first = &mut *slice;

        // Recursive call with the rest of the slice
        let rest = slice.add(1); // Advance the pointer to the next element
        process_slice(rest, len - 1);

        *first *= 2;
    }
}

fn testme() {
    let mut numbers = [1, 2, 3, 4];
    let len = numbers.len();

    unsafe {
        process_slice(numbers.as_mut_ptr(), len);
    }

    println!("{:?}", numbers); // Output: [2, 4, 6, 8]
}
testme();
[2, 4, 6, 8]

Technical Coding Challenge

Coding Challenge

Coding Challenge Review

NDArray

About This Module

Prework

Prework Reading

Pre-lecture Reflections

Lecture

Learning Objectives

  • An -dimensional (e.g. 1-D, 2-D, 3-D, ...) container for general elements and numerics.

  • Similar to Python's Numpy but with important differences.

  • Rich set of methods for creating views and slices into the array

  • Provides a rich set of methods and mathematical operations

  • Arrays have single element types
  • Arrays can have arbitrary dimensions
  • Arrays can have arbitrary strides
  • Indexing starts at 0
  • Default ordering is row-major (more on that below)
  • Arithmetic operators (+, -, *, /) perform element-wise operations
  • Arrays that are not views are contiguous in memory
  • Many cheap operations that return views into the array instead of copying data

Some important differences from Numpy

  • Numpy arrays and views are all mutable and can all change the contents of an array.
  • NDarrays have:
    • flavors that can change contents,
    • flavors that cannot, and
    • flavors that make copies when things change.
  • Numpy arrays are always dynamic in their number of dimensions. NDarrays can be static or dynamic.
  • Slices with negative steps behave differently (more on that later)

To use it

To add latest ndarray crate.

% cargo add ndarray

or manually add

[dependencies]  
ndarray="0.15.6"  

or later in your Cargo.toml file

Why use NDarray over Vec or array?

It is easier to do a bunch of things like:

  • Data Cleaning and Preprocessing offering functions like slicing and fill
  • Statistics built in (lots of math functions come with it)
  • Machine learning: Used in many of the ML libraries written in Rust
  • Linear Algebra: Built in methods like matrix inversion, multiplication and decomposition.

Let's look at some example usage.

// This is required in a Jupyter notebook.
// For a cargo project, you would add it to your Cargo.toml file.
:dep ndarray = { version = "^0.15.6" }

use ndarray::prelude::*;

fn main() {
    let a = array![         // handy macro for creating arrays
                [1.,2.,3.], 
                [4.,5.,6.],
            ]; 
    assert_eq!(a.ndim(), 2);         // get the number of dimensions of array a
    assert_eq!(a.len(), 6);          // get the number of elements in array a
    assert_eq!(a.shape(), [2, 3]);   // get the shape of array a
    assert_eq!(a.is_empty(), false); // check if the array has zero elements

    println!("Print the array with debug formatting:");
    println!("{:?}", a);

    println!("\nPrint the array with display formatting:");
    println!("{}", a);
}

main();
Print the array with debug formatting:
[[1.0, 2.0, 3.0],
 [4.0, 5.0, 6.0]], shape=[2, 3], strides=[3, 1], layout=Cc (0x5), const ndim=2

Print the array with display formatting:
[[1, 2, 3],

For a side by side comparison NumPy, see https://docs.rs/ndarray/latest/ndarray/doc/ndarray_for_numpy_users/index.html

Array Creation

NumPyndarrayNotes
np.array([[1.,2.,3.], [4.,5.,6.]])array![[1.,2.,3.], [4.,5.,6.]] or arr2(&[[1.,2.,3.], [4.,5.,6.]])2×3 floating-point array literal
np.arange(0., 10., 0.5)Array::range(0., 10., 0.5)create a 1-D array with values 0., 0.5, …, 9.5
np.linspace(0., 10., 11)Array::linspace(0., 10., 11)create a 1-D array with 11 elements with values 0., …, 10.
np.logspace(2.0, 3.0, num=4, base=10.0)Array::logspace(10.0, 2.0, 3.0, 4)create a 1-D array with 4 logarithmically spaced elements with values 100., 215.4, 464.1, 1000.
np.geomspace(1., 1000., num=4)Array::geomspace(1e0, 1e3, 4)create a 1-D array with 4 geometrically spaced elements from 1 to 1,000 inclusive: 1., 10., 100., 1000.
np.ones((3, 4, 5))Array::ones((3, 4, 5))create a 3×4×5 array filled with ones (inferring the element type)
np.zeros((3, 4, 5))Array::zeros((3, 4, 5))create a 3×4×5 array filled with zeros (inferring the element type)
np.zeros((3, 4, 5), order='F')Array::zeros((3, 4, 5).f())create a 3×4×5 array with Fortran (column-major) memory layout filled with zeros (inferring the element type)
np.zeros_like(a, order='C')Array::zeros(a.raw_dim())create an array of zeros of the shape shape as a, with row-major memory layout (unlike NumPy, this infers the element type from context instead of duplicating a’s element type)
np.full((3, 4), 7.)Array::from_elem((3, 4), 7.)create a 3×4 array filled with the value 7.
np.array([1, 2, 3, 4]).reshape((2, 2))Array::from_shape_vec((2, 2), vec![1, 2, 3, 4])? or .into_shape((2,2).unwrap()create a 2×2 array from the elements in the Vec
#![allow(unused)]
fn main() {
let a:Array<f64, Ix1> = Array::linspace(0., 4.5, 10);
println!("{:?}", a);

let b:Array<f64, _> = a.into_shape((2,5)).unwrap();
println!("\n{:?}", b);

let c:Array<f64, _> = Array::zeros((3,4).f());
println!("\n{:?}", c);
}
 [4, 5, 6]]
[0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5], shape=[10], strides=[1], layout=CFcf (0xf), const ndim=1

[[0.0, 0.5, 1.0, 1.5, 2.0],
 [2.5, 3.0, 3.5, 4.0, 4.5]], shape=[2, 5], strides=[5, 1], layout=Cc (0x5), const ndim=2

Good overview at this TDS Post, but subscription required.

#![allow(unused)]
fn main() {
:dep ndarray = { version = "^0.15.6" }

// Not working on Jupyter notebook
//:dep ndarray-rand = { version = "^0.15.0" }

use ndarray::{Array, ShapeBuilder};

// Not working on Jupyter notebook
//use ndarray_rand::RandomExt;
//use ndarray_rand::rand_distr::Uniform;


// Ones

let ones = Array::<f64, _>::ones((1, 4));
println!("{:?}", ones); 

// Output:
// [[1.0, 1.0, 1.0, 1.0]], shape=[1, 4], strides=[4, 1], layout=CFcf (0xf), const ndim=2

// Range

let range = Array::<f64, _>::range(0., 5., 1.);
println!("{:?}", range); 

// Output:
// [0.0, 1.0, 2.0, 3.0, 4.0], shape=[5], strides=[1], layout=CFcf (0xf), const ndim=1

// Linspace

let linspace = Array::<f64, _>::linspace(0., 5., 5);
println!("{:?}", linspace); 

// Output:
// [0.0, 1.25, 2.5, 3.75, 5.0], shape=[5], strides=[1], layout=CFcf (0xf), const ndim=1

// Fill

let mut ones = Array::<f64, _>::ones((1, 4));
ones.fill(0.);
println!("{:?}", ones); 

// Output:
// [[0.0, 0.0, 0.0, 0.0]], shape=[1, 4], strides=[4, 1], layout=CFcf (0xf), const ndim=2

// Eye -- Identity Matrix

let eye = Array::<f64, _>::eye(4);
println!("{:?}", eye); 

// Output:
// [[1.0, 0.0, 0.0, 0.0],
// [0.0, 1.0, 0.0, 0.0],
// [0.0, 0.0, 1.0, 0.0],
// [0.0, 0.0, 0.0, 1.0]], shape=[4, 4], strides=[4, 1], layout=Cc (0x5), const ndim=2

// Random

// Not working on Jupyter notebook

//let random = Array::random((2, 5), Uniform::new(0., 10.));
//println!("{:?}", random);

// Output:
// [[9.375493735188611, 4.088737328406999, 9.778579742815943, 0.5225866490310649, 1.518053969762827],
//  [9.860829919571666, 2.9473768443117, 7.768332993584486, 7.163926861520167, 9.814750664983297]], shape=[2, 5], strides=[5, 1], layout=Cc (0x5), const ndim=2
}
[[0.0, 0.0, 0.0, 0.0],
 [0.0, 0.0, 0.0, 0.0],
 [0.0, 0.0, 0.0, 0.0]], shape=[3, 4], strides=[1, 3], layout=Ff (0xa), const ndim=2
[[1.0, 1.0, 1.0, 1.0]], shape=[1, 4], strides=[4, 1], layout=CFcf (0xf), const ndim=2
[0.0, 1.0, 2.0, 3.0, 4.0], shape=[5], strides=[1], layout=CFcf (0xf), const ndim=1
[0.0, 1.25, 2.5, 3.75, 5.0], shape=[5], strides=[1], layout=CFcf (0xf), const ndim=1

What does the printout mean?

  • The values of the array
  • The shape of the array, most important dimension first
  • The stride (always 1 for arrays in the last dimension, but can be different for views)
  • The layout (storage order, view order)
  • The number of dimensions

Indexing and Slicing

NumPyndarrayNotes
a[-1]a[a.len() - 1]access the last element in 1-D array a
a[1, 4]a[[1, 4]]access the element in row 1, column 4
a[1] or a[1, :, :]a.slice(s![1, .., ..]) or a.index_axis(Axis(0), 1)get a 2-D subview of a 3-D array at index 1 of axis 0
a[0:5] or a[:5] or a[0:5, :]a.slice(s![0..5, ..]) or a.slice(s![..5, ..]) or a.slice_axis(Axis(0), Slice::from(0..5))get the first 5 rows of a 2-D array
a[-5:] or a[-5:, :]a.slice(s![-5.., ..]) or a.slice_axis(Axis(0), Slice::from(-5..))get the last 5 rows of a 2-D array
a[:3, 4:9]a.slice(s![..3, 4..9])columns 4, 5, 6, 7, and 8 of the first 3 rows
a[1:4:2, ::-1]a.slice(s![1..4;2, ..;-1])rows 1 and 3 with the columns in reverse order

The s![] slice macro

  • The s![] macro in Rust's ndarray crate is a convenient way to create slice specifications for array operations.

  • It's used to create a SliceInfo object that describes how to slice or view an array.

Here's how it works:

  1. The s![] macro is used to create slice specifications that are similar to Python's slice notation
  2. It's commonly used with methods like slice(), slice_mut(), and other array view operations
  3. Inside the macro, you can specify ranges and steps for each dimension

For example:

#![allow(unused)]
fn main() {
let mut slice = array.slice_mut(s![1.., 0, ..]);
}

This creates a slice that:

  • Takes all elements from index 1 onwards in the first dimension (1..)
  • Takes only index 0 in the second dimension (0)
  • Takes all elements in the third dimension (..)

The syntax inside s![] supports several patterns:

  • .. - take all elements in that dimension
  • start..end - take elements from start (inclusive) to end (exclusive)
  • start..=end - take elements from start (inclusive) to end (inclusive)
  • start.. - take elements from start (inclusive) to the end
  • ..end - take elements from the beginning to end (exclusive)
  • index - take only that specific index

For example:

#![allow(unused)]
fn main() {
// Take every other element in the first dimension
s![..;2]

// Take elements 1 to 3 in the first dimension, all elements in the second
s![1..4, ..]

// Take elements from index 2 to the end, with step size 2
s![2..;2]

// Take specific indices
s![1, 2, 3]
}

From TDS Post.

#![allow(unused)]
fn main() {
use ndarray::{s};
{
    // Create a 3-dimensional array (2x3x4)
    let mut array = Array::from_shape_fn((2, 3, 4), |(i, j, k)| {
        (i * 100 + j * 10 + k) as f32
    });

    // Print the original 3-dimensional array
    println!("Original 3D array:\n{:?}", array);

    // Create a 2-dimensional slice (taking the first 2D layer)
    let mut slice = array.slice_mut(s![1.., 0, ..]);

    // Print the 2-dimensional slice
    println!("2D slice:\n{:?}", slice);

    // Create a 1-dimensional slice (taking the first 2D and 3D layer)
    let slice2 = array.slice(s![1, 0, ..]);

    // Print the 1-dimensional slice
    println!("1D slice:\n{:?}", slice2);

}
}
[[0.0, 0.0, 0.0, 0.0]], shape=[1, 4], strides=[4, 1], layout=CFcf (0xf), const ndim=2
[[1.0, 0.0, 0.0, 0.0],
 [0.0, 1.0, 0.0, 0.0],
 [0.0, 0.0, 1.0, 0.0],
 [0.0, 0.0, 0.0, 1.0]], shape=[4, 4], strides=[4, 1], layout=Cc (0x5), const ndim=2
Original 3D array:
[[[0.0, 1.0, 2.0, 3.0],
  [10.0, 11.0, 12.0, 13.0],
  [20.0, 21.0, 22.0, 23.0]],

 [[100.0, 101.0, 102.0, 103.0],
  [110.0, 111.0, 112.0, 113.0],
  [120.0, 121.0, 122.0, 123.0]]], shape=[2, 3, 4], strides=[12, 4, 1], layout=Cc (0x5), const ndim=3
2D slice:
[[100.0, 101.0, 102.0, 103.0]], shape=[1, 4], strides=[0, 1], layout=CFcf (0xf), const ndim=2
1D slice:
[100.0, 101.0, 102.0, 103.0], shape=[4], strides=[1], layout=CFcf (0xf), const ndim=1





()

2D and 3D datatypes, again from TDS Post.

#![allow(unused)]
fn main() {
use ndarray::{array, Array, Array2, Array3, ShapeBuilder};

// 1D array
let array_d1 = Array::from_vec(vec![1., 2., 3., 4.]);
println!("{:?}", array_d1);

// Output:
// [1.0, 2.0, 3.0, 4.0], shape=[4], strides=[1], layout=CFcf (0xf), const ndim=1

// or 

let array_d11 = Array::from_shape_vec((1, 4), vec![1., 2., 3., 4.]);
println!("{:?}", array_d11.unwrap());

// Output:
// [[1.0, 2.0, 3.0, 4.0]], shape=[1, 4], strides=[4, 1], layout=CFcf (0xf), const ndim=2

// 2D array

let array_d2 = array![
    [-1.01,  0.86, -4.60,  3.31, -4.81],
    [ 3.98,  0.53, -7.04,  5.29,  3.55],
    [ 3.30,  8.26, -3.89,  8.20, -1.51],
    [ 4.43,  4.96, -7.66, -7.33,  6.18],
    [ 7.31, -6.43, -6.16,  2.47,  5.58],
];

// or

let array_d2 = Array::from_shape_vec((2, 2), vec![1., 2., 3., 4.]);
println!("{:?}", array_d2.unwrap());

// Output:
// [[1.0, 2.0],
// [3.0, 4.0]], shape=[2, 2], strides=[2, 1], layout=Cc (0x5), const ndim=2

// or

let mut data = vec![1., 2., 3., 4.];
let array_d21 = Array2::from_shape_vec((2, 2), data);

// 3D array

let mut data = vec![1., 2., 3., 4.];
let array_d3 = Array3::from_shape_vec((2, 2, 1), data);
println!("{:?}", array_d3);

// Output:
// [[[1.0],
//  [2.0]],
//  [[3.0],
//  [4.0]]], shape=[2, 2, 1], strides=[2, 1, 1], layout=Cc (0x5), const ndim=3
}
[1.0, 2.0, 3.0, 4.0], shape=[4], strides=[1], layout=CFcf (0xf), const ndim=1
[[1.0, 2.0, 3.0, 4.0]], shape=[1, 4], strides=[4, 1], layout=CFcf (0xf), const ndim=2
[[1.0, 2.0],
 [3.0, 4.0]], shape=[2, 2], strides=[2, 1], layout=Cc (0x5), const ndim=2

Reshaping

From TDS Post.

From TDS Post.

Examining the array parameters

NumPyndarrayNotes
np.ndim(a) or a.ndima.ndim()get the number of dimensions of array a
np.size(a) or a.sizea.len()get the number of elements in array a
np.shape(a) or a.shapea.shape() or a.dim()get the shape of array a
a.shape[axis]a.len_of(Axis(axis))get the length of an axis
a.stridesa.strides()get the strides of array a
np.size(a) == 0 or a.size == 0a.is_empty()check if the array has zero elements

Simple Math

NumPyndarrayNotes
a.transpose() or a.Ta.t() or a.reversed_axes()transpose of array a (view for .t() or by-move for .reversed_axes())
mat1.dot(mat2)mat1.dot(&mat2)2-D matrix multiply
mat.dot(vec)mat.dot(&vec)2-D matrix dot 1-D column vector
vec.dot(mat)vec.dot(&mat)1-D row vector dot 2-D matrix
vec1.dot(vec2)vec1.dot(&vec2)vector dot product
a * b, a + b, etc.a * b, a + b, etc.element-wise arithmetic operations
a**3a.mapv(|a| a.powi(3))element-wise power of 3
np.sqrt(a)a.mapv(f64::sqrt)element-wise square root for f64 array
(a>0.5)a.mapv(|a| a > 0.5)array of bools of same shape as a with true where a > 0.5 and false elsewhere
np.sum(a) or a.sum()a.sum()sum the elements in a
np.sum(a, axis=2) or a.sum(axis=2)a.sum_axis(Axis(2))sum the elements in a along axis 2
np.mean(a) or a.mean()a.mean().unwrap()calculate the mean of the elements in f64 array a
np.mean(a, axis=2) or a.mean(axis=2)a.mean_axis(Axis(2))calculate the mean of the elements in a along axis 2
np.allclose(a, b, atol=1e-8)a.abs_diff_eq(&b, 1e-8)check if the arrays’ elementwise differences are within an absolute tolerance (it requires the approx feature-flag)
np.diag(a)a.diag()view the diagonal of a

mapv vs map:

mapv iterates over the values of the array, map iterates over mutable references of the array

Array-n

Array0 to Array6 also defined in addition to Array as special cases with fixed dimensions instead dynamically defined dimensions.

#![allow(unused)]
fn main() {
{
    // Create a 2-dimensional array (3x4)
    let mut a = Array::from_shape_fn((3, 4), |(j, k)| {
        (j * 10 + k) as f32
    });

    // Print the original 2-dimensional array
    println!("Original 2D array:\n{:?}", a);

    let b = a * 2.0;
    println!("\nb:\n{:?}", b);

    let c = b.mapv(|v| v>24.8);
    println!("\nc:\n{:?}", c);
}
}
Ok([[[1.0],
  [2.0]],

 [[3.0],
  [4.0]]], shape=[2, 2, 1], strides=[2, 1, 1], layout=Cc (0x5), const ndim=3)
Original 2D array:
[[0.0, 1.0, 2.0, 3.0],
 [10.0, 11.0, 12.0, 13.0],
 [20.0, 21.0, 22.0, 23.0]], shape=[3, 4], strides=[4, 1], layout=Cc (0x5), const ndim=2

b:
[[0.0, 2.0, 4.0, 6.0],
 [20.0, 22.0, 24.0, 26.0],
 [40.0, 42.0, 44.0, 46.0]], shape=[3, 4], strides=[4, 1], layout=Cc (0x5), const ndim=2

c:
[[false, false, false, false],
 [false, false, false, true],
 [true, true, true, true]], shape=[3, 4], strides=[4, 1], layout=Cc (0x5), const ndim=2





()

Type Conversions

  • std::convert::From ensures lossless, safe conversions at compile-time and is generally recommended.
  • std::convert::TryFrom can be used for potentially unsafe conversions. It will return a Result which can be handled or unwrap()ed to panic if any value at runtime cannot be converted losslessly.
NumPyndarrayNotes
a.astype(np.float32)a.mapv(|x| f32::from(x))convert array to f32. Only use if can't fail
a.astype(np.int32)a.mapv(|x| i32::from(x))convert array to i32. Only use if can't fail
a.astype(np.uint8)a.mapv(|x| u8::try_from(x).unwrap())try to convert to u8 array, panic if any value cannot be converted lossless at runtime (e.g. negative value)
a.astype(np.int32)a.mapv(|x| x as i32)convert to i32 array with “saturating” conversion; care needed because it can be a lossy conversion or result in non-finite values!

Basic Linear Algebra

Provided by NDArray.

Transpose

#![allow(unused)]
fn main() {
let array_d2 = Array::from_shape_vec((2, 2), vec![1., 2., 3., 4.]);
println!("{:?}", array_d2.unwrap());

// Output
// [[1.0, 2.0],
//  [3.0, 4.0]], shape=[2, 2], strides=[2, 1], layout=Cc (0x5), const ndim=2)

let binding = array_d2.expect("Expect 2d matrix");

let array_d2t = binding.t();
println!("{:?}", array_d2t);

// Output
// [[1.0, 3.0],
//  [2.0, 4.0]], shape=[2, 2], strides=[1, 2], layout=Ff (0xa), const ndim=2

}
[E0382] Error: use of moved value: `array_d2`

   ╭─[command_8:1:1]

   │

 1 │ let array_d2 = Array::from_shape_vec((2, 2), vec![1., 2., 3., 4.]);

   │     ────┬───  

   │         ╰───── move occurs because `array_d2` has type `Result<ndarray::ArrayBase<OwnedRepr<f64>, ndarray::Dim<[usize; 2]>>, ShapeError>`, which does not implement the `Copy` trait

 2 │ println!("{:?}", array_d2.unwrap());

   │                  ────┬───│────┬───  

   │                      ╰────────────── help: consider calling `.as_ref()` or `.as_mut()` to borrow the type's contents

   │                          │    │     

   │                          ╰────────── help: you can `clone` the value and consume it, but this might not be your desired behavior: `.clone()`

   │                               │     

   │                               ╰───── `array_d2` moved due to this method call

   │ 

 8 │ let binding = array_d2.expect("Expect 2d matrix");

   │               ────┬───  

   │                   ╰───── value used here after move

   │ 

   │ Note: note: `Result::<T, E>::unwrap` takes ownership of the receiver `self`, which moves `array_d2`

───╯



[E0597] Error: `binding` does not live long enough

    ╭─[command_8:1:1]

    │

  8 │ let binding = array_d2.expect("Expect 2d matrix");

    │     ───┬───  

    │        ╰───── binding `binding` declared here

    │ 

 10 │ let array_d2t = binding.t();

    │                 ───┬─┬─────  

    │                    ╰───────── borrowed value does not live long enough

    │                      │       

    │                      ╰─────── argument requires that `binding` is borrowed for `'static`

────╯

Matrix Multiplication

#![allow(unused)]
fn main() {
use ndarray::{array, Array2};

let a: Array2<f64> = array![[3., 2.], [2., -2.]];
let b: Array2<f64> = array![[3., 2.], [2., -2.]];
let c = a.dot(&b);
print!("{:?}", c);

// Output
// [[13.0, 2.0],
//  [2.0, 8.0]], shape=[2, 2], strides=[2, 1], layout=Cc (0x5), const ndim=2

}
[[13.0, 2.0],

Linear Algebra with ndarray-linalg

  • The crate ndarray-linalg implements more advanced lineary algebra operations that comes with NDArray.

  • It relies on a native linear algebra library like OpenBLAS and can be tricky to configure.

  • We show it here just for reference.

NumPyndarrayNotes
numpy.linalg.inv(a)a.inv()Invert matrix a. Must be square
numpy.linalg.eig(a)a.eig()Compute eigenvalues and eigenvectors of matrix. Must be square
numpy.linalg.svd(a)a.svd(true, true)Compute the Singular Value Decomposition of matrix
numpy.linalg.det(a)a.det()Compute the determinant of a matrix. Must be square
#![allow(unused)]
fn main() {
//```rust
:dep ndarray = { version = "^0.15" }
// This is the MAC version
// See ./README.md for setup instructions
:dep ndarray-linalg = { version = "^0.16", features = ["openblas-system"] }
// This is the linux verison
//:dep ndarray-linalg = { version = "^0.15", features = ["openblas"] }

use ndarray::array;
use ndarray_linalg::*;

{
    // Create a 2D square matrix (3x3)
    let matrix = array![
        [1.0, 2.0, 3.0],
        [0.0, 1.0, 4.0],
        [5.0, 6.0, 0.0]
    ];

    // Compute the eigenvalues and eigenvectors
    match matrix.eig() {
        Ok((eigenvalues, eigenvectors)) => {
            println!("Eigenvalues:\n{:?}", eigenvalues);
            println!("Eigenvectors:\n{:?}", eigenvectors);
        },
        Err(e) => println!("Failed to compute eigenvalues and eigenvectors: {:?}", e),
    }
}
//```
}
 [2.0, 8.0]], shape=[2, 2], strides=[2, 1], layout=Cc (0x5), const ndim=2EVCXR_VARIABLE_CHANGED_TYPE:b


The type of the variable b was redefined, so was lost.
The type of the variable array_d21 was redefined, so was lost.
The type of the variable array_d3 was redefined, so was lost.
The type of the variable a was redefined, so was lost.
The type of the variable range was redefined, so was lost.
The type of the variable eye was redefined, so was lost.
The type of the variable c was redefined, so was lost.
The type of the variable array_d1 was redefined, so was lost.
The type of the variable linspace was redefined, so was lost.
The type of the variable ones was redefined, so was lost.
The type of the variable b was redefined, so was lost.


Eigenvalues:
[Complex { re: 7.256022422687388, im: 0.0 }, Complex { re: -0.026352822204034426, im: 0.0 }, Complex { re: -5.229669600483354, im: 0.0 }], shape=[3], strides=[1], layout=CFcf (0xf), const ndim=1
Eigenvectors:
[[Complex { re: -0.4992701697014973, im: 0.0 }, Complex { re: -0.7576983872742932, im: 0.0 }, Complex { re: -0.22578016277159085, im: 0.0 }],
 [Complex { re: -0.4667420094775666, im: 0.0 }, Complex { re: 0.6321277120502754, im: 0.0 }, Complex { re: -0.526348454767688, im: 0.0 }],
 [Complex { re: -0.7299871192254567, im: 0.0 }, Complex { re: -0.162196515314045, im: 0.0 }, Complex { re: 0.8197442419819131, im: 0.0 }]], shape=[3, 3], strides=[1, 3], layout=Ff (0xa), const ndim=2





()

The same code is provided as a cargo project.

Python Numpy Reference

For reference you can look at mnist_fcn.ipynb which implements and trains the network with only numpy matrices, but does use PyTorch dataset loaders for conciseness.

Technical Coding Challenge

Coding Challenge

Coding Challenge Review

Deriving and Implementing Traits: Debug and Display

About This Module

This module explores the #[derive(Debug)] attribute in detail and introduces manual trait implementations. Understanding how derive macros work and when to implement traits manually is important for creating user-friendly and debuggable Rust programs.

Prework

Prework Readings

Read the following sections from "The Rust Programming Language" book:

Pre-lecture Reflections

Before class, consider these questions:

  1. What is the difference between Debug and Display formatting?
  2. Why might you want to manually implement a trait instead of using derive?
  3. How do derive macros help reduce boilerplate code?
  4. When would custom formatting be important for user experience?
  5. What role do traits play in Rust's type system?

Lecture

Learning Objectives

By the end of this module, you should be able to:

  • Understand what #[derive(Debug)] does under the hood
  • Manually implement the Debug trait for custom types
  • Implement the Display trait for user-friendly output
  • Choose between Debug and Display formatting appropriately
  • Understand when to use derive vs. manual implementation
  • Apply formatting traits to make code more debuggable

Understanding #[derive(Debug)]

  • A simple way to tell Rust to generate code that allows a complex type to be printed
  • Here's the equivalent manual implementation of the Debug trait
  • more on traits and impl later
use std::fmt;

impl fmt::Debug for Direction {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
           match *self {
               Direction::North => write!(f, "North"),
               Direction::East => write!(f, "East"),
               Direction::South => write!(f, "South"),
               Direction::West => write!(f, "West"),               
           }
    }
}
let dir = Direction::North;
dir
North
let dir = Direction::North;
println!("{:?}",dir);
North
// Example of how make a complex datatype printable directly (without deriving from Debug)
use std::fmt;

impl fmt::Display for Direction {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
           match *self {
               Direction::North => write!(f, "North"),
               Direction::East => write!(f, "East"),
               Direction::South => write!(f, "South"),
               Direction::West => write!(f, "West"),               
           }
    }
}
println!("{}", dir);
North

Best Practices

Design Guidelines:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
pub enum UserAction {
    Login(String),
    Logout,
    UpdateProfile { name: String, email: String },
}

impl fmt::Display for UserAction {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            UserAction::Login(username) => write!(f, "User '{}' logged in", username),
            UserAction::Logout => write!(f, "User logged out"),
            UserAction::UpdateProfile { name, .. } => {
                write!(f, "User '{}' updated profile", name)
            }
        }
    }
}

// Usage
let action = UserAction::Login("alice".to_string());
println!("{}", action);     // User-friendly: "User 'alice' logged in"
println!("{:?}", action);   // Debug: Login("alice")
}

When to Implement Manually:

  • Security: Hide sensitive information in debug output
  • Performance: Optimize formatting for frequently-used types
  • User Experience: Create polished output for end users
  • Domain Requirements: Follow domain-specific formatting conventions

Styles of Parallel Programming

About This Module

Prework

Prework Reading

Pre-lecture Reflections

Lecture

Learning Objectives

  • Instruction level parallelism (ILP)
    • Done at the hardware level. CPU determines independent instructions and executes them in parallel! Often assisted by the compiler which arranges instruction so contiguous instructions can be executed concurrently.
  • Single Instruction Multiple Data (SIMD)
    • Done at the hardware level. A processor may be able to do, e.g., 1 Multiply-Accumulate of Int32, but 2 MACs per cycle for Int16 and 4 MACs per cycle of Int8.
  • Thread level parallelism
    • Done at the software level. Programmer or compiler decomposes program onto mostly independent parts and creates separate threads to execute them.
    • Message passing between threads
      • When coordination or data exchange needs arise in #3 the coordination and or data exchange happens by explicitly sending messages between threads.
    • Shared memory between threads
      • Threads share all the memory and use locks/barriers to coordinate between them (more below).
  • Pipeline Parallelism
    • Output of one thread is input to another
  • Data parallelism
    • Each thread works on a different part of the data
  • Model parallelism (new)
    • Arose due to large ML models where each thread/core has a part of the model and updates its parameters

Rust language supports thread level parallelism primitives.

We'll look at three of them (3.1, 3.2, 3.3 ) and mention the other briefly (3.4).

Processor Scaling and Multicore Architectures

In 1965, Gordon Moore, cofounder of Intel, observed that component count of processors was doubling about every 2 years.

He predicted that it would continue for at least the next 10 years.

This became known as "Moore's Law".

In fact it has held up for over 50 years.

The industry did this by shrinking component size, which also allowed to increase the clock rate.

Up to a point...

Became increasingly difficult to continue to grow the transistor count...

And increasingly difficult to improve single core performance.

The alternative was to start putting more cores in the processor.

How much memory in a NVDA A100 GPU?

  • 80 Gbytes of high bandwidth memory

What is the size of GPT-4?

  • 1.7 trillion parameters, at 4 bytes each, 6.8 TBytes
  • 1.7 trillion parameters, at 2 bytes each, 3.4 TBytes
  • So clearly it will not fit in a single GPU memory

2. Limits of Parallel Programming

Let an algorithm A have a serial S and a parallel P part. Then the time to execute G is:

Running on multiple processors only affects

which as becomes

So the maximum possible speedup for a program is

3. Rust Parallel and Concurrent Programming

It helps to look at what constitutes a CPU core and a thread.

Intel processors have typically supported 2 threads/core.

New Mac processors typically support 1 hardware thread/core.

But the OS can support many more SW threads (thousands).

Rust libraries provide constructs for different types of parallel/concurrent programming.

  1. Threads (spawn, join)
  2. Message Passing Via Channels (mpsc)
  3. Shared Memory/State Concurrency using Mutexes (mutex)
  4. Asynchronous Programing with Async, Await, Futures, ... Rust Lang §17

3.1 Using Threads in Rust

Examples from Rust Language Book, §16.1.

Rust let's you manually assign program code to additional threads.

Let's "spawn" a thread and print a statement 10 times with a 1 millisecond sleep between iterations.

use std::thread;
use std::time::Duration;

fn main(){
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}
main();
hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!

Why did we not complete the print loop in the thread?



The main thread completed and the program shut down.

We can wait till the other thread completes using join handles.

use std::thread;
use std::time::Duration;

fn main() {
    // get a handle to the thread
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}

main();
hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 3 from the main thread!
hi number 4 from the spawned thread!
hi number 4 from the main thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

What if we want to use variables from the our main thread in the spawned thread?

#![allow(unused)]
fn main() {
use std::thread;

{
    let v = vec![1, 2, 3];

    let handle = thread::spawn( || {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}
}
[E0373] Error: closure may outlive the current function, but it borrows `v`, which is owned by the current function

   ╭─[command_10:1:1]

   │

 6 │ ╭─▶     let handle = thread::spawn( || {

   │ │                                   ┬┬  

   │ │                                   ╰─── help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword: `move `

   │ │                                    │  

   │ │                                    ╰── may outlive borrowed value `v`

 7 │ │           println!("Here's a vector: {v:?}");

   │ │                                       ┬  

   │ │                                       ╰── `v` is borrowed here

 8 │ ├─▶     });

   │ │             

   │ ╰───────────── note: function requires argument type to outlive `'static`

───╯

The Rust compiler won't let us borrow from a spawned thread.

We have to use the move directive.

#![allow(unused)]
fn main() {
use std::thread;

{
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
    
}
}
Here's a vector: [1, 2, 3]





()

3.2 Using Message Passing

So we spawned threads that can act completely independently.

What if we need communication or coordination between threads?

One popular pattern in many languages is message passing.

Message passing via channels.

Each channel has a Transmitter (tx) and Reciever (Rx).

std::sync::mpsc -- Multiple Producer, Single Consumer

Example with single producer, single consumer.

#![allow(unused)]
fn main() {
use std::sync::mpsc;
use std::thread;

{
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Got: {received}");
}
}
Got: hi





()

Reminder that the unwrap() method works on the Result enum in that if the Result is Ok, it will produce the value inside the Ok. If the Result is the Err variant, unwrap will call the panic! macro.

Example with Multiple Producer, Single Consumer.

#![allow(unused)]
fn main() {
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

{

    let (tx, rx) = mpsc::channel();

    let tx1 = tx.clone();
    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx1.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    thread::spawn(move || {
        let vals = vec![
            String::from("more"),
            String::from("messages"),
            String::from("for"),
            String::from("you"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {received}");
    }

}
}
Got: hi
Got: more
Got: messages
Got: from
Got: for
Got: the
Got: you
Got: thread





()

3.3 Shared Memory/State Concurrency

The other approach for coordinating between threads is to use shared memory with a mutually exclusive (Mutex) lock on the memory.

#![allow(unused)]
fn main() {
use std::sync::Mutex;

{
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();  // to access the data we have to lock it
        *num = 6;
    } // the lock released at the end of the scope

    println!("m = {m:?}");
}
}
m = Mutex { data: 6, poisoned: false, .. }





()

What if we wanted multiple threads to increment the same counter.

We have to use Atomic Reference Counting (Arc).

#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};
use std::thread;

{
    let counter = Arc::new(Mutex::new(0)); // Create an Arc on a Mutex of int32
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter); // Use Arc::clone() method
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();  // lock the counter mutex to increment it

            *num += 1;
        }); // mutex is unlocked at end of scope
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
}
Result: 10





()

3.4 Asynchronous Programming: Async, Await, Futures, etc.

This material is optional.

Rust (and other languages) have language constructs that abstracts away the particulars of managing and coordinating threads.

You can use Rust's asynchronous programming features (see Rust Book, Ch. 17) to describe which parts of your program can execute asynchronously and how to express dependency points between them.

Mixed parallel and concurrent workflow. Fig 17-3 from the Rust Book.

From Futures and the Async Syntax:

  • A future is a value that may not be ready now but will become ready at some point in the future.

  • You can apply the async keyword to blocks and functions to specify that they can be interrupted and resumed.

  • Within an async block or async function, you can use the await keyword to await a future

    • that is, wait for it to become ready.
  • Any point where you await a future within an async block or function is a potential spot for that async block or function to pause and resume.

  • The process of checking with a future to see if its value is available yet is called polling.

See Rust Book, Ch. 17 to dive deeper.

Additional Examples (Optional)

Message passing between threads

  • The code demonstrates thread communication using Rust's mpsc (multiple producer, single consumer) channels to exchange messages between threads.
  • Two worker threads are spawned, each with its own pair of channels for bidirectional communication with the main thread.
  • The main thread coordinates the communication by receiving messages from both worker threads and sending responses back to them.
  • The code uses thread synchronization primitives like join() to ensure proper thread termination and sleep() to manage timing.
use std::thread;
use std::sync::mpsc;
use std::time::Duration;


fn communicate(id:i32, tx: mpsc::Sender<String>, rx:mpsc::Receiver<&str>) {
   let t = format!("Hello from thread {}", id);
   tx.send(t).unwrap();
   let a:&str = rx.recv().unwrap();
   println!("Thread {} received {}", id, a);
}

fn main_thread() {

    let (tx1, rx1) = mpsc::channel();
    let (tx2, rx2) = mpsc::channel();
    // let handle1 = 
    let handle1 = thread::spawn(move || {
       communicate(1, tx1, rx2);
    });

    let (tx3, rx3) = mpsc::channel();
    let (tx4, rx4) = mpsc::channel();

    // let handle2 = 
    let handle2 = thread::spawn(move || {
       communicate(2, tx3, rx4);
    });

    let a:String = rx1.recv().unwrap();
    let b:String = rx3.recv().unwrap();
    println!("Main thread got \n{}\n{}\n\n", a, b);
    tx2.send("Hi from main").unwrap();
    tx4.send("Hi from main").unwrap();
    thread::sleep(Duration::from_millis(5000));
    handle1.join().unwrap();
    handle2.join().unwrap();
}

main_thread();
Main thread got 
Hello from thread 1
Hello from thread 2


Thread 1 received Hi from main
Thread 2 received Hi from main

More example programs

Summarized by ChatGPT.

These are posted to https://github.com/trgardos/ds210-b1-sp25-lectures/tree/main/lecture_24_optional_code.

parallel_write

  • Creates a configurable square matrix (default 64x64) and processes it in parallel using Rust's Rayon library
  • Divides the matrix into chunks based on the number of specified threads, with each thread processing its assigned chunk
  • Uses Rayon's scope and spawn functions for parallel execution, where each thread writes its ID number to its assigned matrix section
  • Includes options to configure matrix size, number of threads, and display the matrix before/after processing

parallel_locks

  • The program demonstrates parallel matrix processing with configurable parameters (matrix size, thread count, and synchronization method) using command-line arguments
  • It implements three different synchronization approaches:
    • Exclusive locking using Mutex
    • Reader-only access using RwLock read locks
    • Writer access using RwLock write locks
  • The program uses Rust's concurrency primitives:
    • Arc for thread-safe shared ownership
    • Mutex and RwLock for synchronization
    • thread::spawn for parallel execution
  • Each thread processes a segment of the matrix, calculates a sum, and the program measures and reports the total execution time

parallel_gaussian

  • Implements parallel Gaussian elimination using Rayon's parallel iterators (par_iter_mut) for both matrix initialization and row elimination operations
  • Uses thread-safe random number generation with ChaCha8Rng, where each thread gets its own seeded RNG instance
  • Allows configuration of matrix size and number of threads via command-line arguments
  • Includes performance timing measurements and optional matrix display functionality

parallel_game_of_life

  • Implements Conway's Game of Life simulation with parallel processing using Rayon's concurrency primitives (scope and spawn)
  • Divides the board into chunks that are processed concurrently by multiple threads, with each thread handling a specific section of the board
  • Uses command-line arguments to control simulation parameters (board size, thread count, display options, iteration count, and delay between iterations)
  • Includes a glider pattern initialization and visual rendering of the board state, with timing measurements for performance analysis

Technical Coding Challenge

Coding Challenge

Coding Challenge Review

Calling Rust from Python

Content regarding how to write performant python modules in Rust.

Prework

We'll be using PyO3 and the maturin python module.

Either follow the maturin tutorial or one of the examples from PyO3 Using Rust from Python.

This TDS blog could be helpful.

Prework Assessment

A short assessment of the prework material to check and reinforce students' understanding and readiness for the lecture.

Lecture

Recap of the prework material, perhaps in a bit more depth and additional examples.

Technical Coding Challenge

Introduce and discuss the technical coding challenge where the solution can be implemented with material and language syntax learned so far.

Coding Challenge

Students work on the coding challenge in groups of 3 and submit their solutions online.

Coding Challenge Review

Review the coding challenge in class:

  1. peer review of solutions with comments... maybe randomly select 3 other students to do the review
  2. instructor randomly select a group to present their solution

File Input/Output in Rust

About This Module

This module covers file input/output operations in Rust, focusing on reading from and writing to files safely and efficiently. Students will learn how to handle files, work with the std::fs and std::io modules, and implement error handling for file operations. The module includes practical examples of reading and writing different data formats, with a focus on processing large datasets commonly used in data science applications.

Prework

Before this lecture, please read:

Pre-lecture Reflections

  1. How does Rust's ownership model affect file handling compared to other languages?
  2. What are the advantages of Rust's Result type for file I/O error handling?
  3. When would you use buffered vs. unbuffered file reading in data processing applications?

Lecture

Learning Objectives

By the end of this lecture, you should be able to:

  • Open, read from, and write to files in Rust using std::fs and std::io
  • Handle file I/O errors properly using Result types and expect()
  • Use buffered readers for efficient file processing
  • Parse and generate file formats commonly used in data science
  • Implement file-based data processing workflows

File I/O

See also file_io.

:dep rand="0.8.5"

use std::fs::File;        // object providing access to an open file on filesystem
use std::io::prelude::*;  // imports common I/O traits
use rand::Rng;


fn generate_file(path: &str, n: usize) {
    // Generate a random file of edges for vertices 0.n

    // Create a file at `path` and return Result<File> or error
    // .expect() unwraps the Result<File> or prints error and panics
    let mut file = File::create(path).expect("Unable to create file");
    
    for i in 0..n {
        // How many neighbors will this node have
        let rng = rand::thread_rng().gen_range(0..20) as usize;
        
        for _j in 0..rng {
            // Randomly select a neighbor (even with duplicates but not to ourselves)
            let neighbor = rand::thread_rng().gen_range(0..n) as usize;
            if neighbor != i {
                let s: String = format!("{} {}\n", i, neighbor);
                file.write_all(s.as_bytes()).expect("Unable to write file");
            }
        }
    }
}

fn read_file(path: &str) -> Vec<(u32, u32)> {
    let mut result: Vec<(u32, u32)> = Vec::new();
    let file = File::open(path).expect("Could not open file");
    let buf_reader = std::io::BufReader::new(file).lines();
    for line in buf_reader {
        let line_str = line.expect("Error reading");
        let v: Vec<&str> = line_str.trim().split(' ').collect();
        let x = v[0].parse::<u32>().unwrap();
        let y = v[1].parse::<u32>().unwrap();
        result.push((x, y));
    }
    return result;
}

println!("Generating file");
generate_file("list_of_edges.txt", 10);
let edges = read_file("list_of_edges.txt");
println!("{:?}", edges);
Generating file
[(0, 5), (0, 9), (0, 1), (0, 8), (0, 2), (0, 2), (0, 1), (0, 6), (0, 8), (0, 9), (1, 6), (1, 6), (1, 4), (2, 7), (2, 7), (2, 1), (2, 1), (2, 8), (2, 6), (3, 0), (3, 1), (3, 2), (3, 8), (3, 2), (3, 8), (3, 0), (3, 4), (3, 8), (3, 7), (3, 8), (3, 0), (3, 8), (3, 2), (3, 6), (3, 8), (3, 4), (4, 7), (4, 2), (4, 0), (4, 5), (4, 0), (4, 9), (4, 2), (4, 2), (4, 9), (4, 7), (4, 3), (4, 9), (4, 9), (5, 8), (5, 0), (5, 3), (5, 4), (5, 2), (5, 4), (5, 9), (6, 3), (6, 7), (6, 9), (6, 3), (6, 7), (6, 7), (6, 4), (6, 0), (6, 3), (6, 8), (6, 0), (6, 9), (6, 5), (7, 4), (7, 9), (7, 3), (7, 6), (7, 3), (7, 9), (7, 1), (7, 8), (7, 1), (7, 2), (7, 2), (8, 2), (8, 0), (8, 1), (8, 3), (8, 7), (8, 5), (8, 4), (9, 2), (9, 3), (9, 4), (9, 5), (9, 4), (9, 2), (9, 5), (9, 2)]

NDArray and Neural networks

About This Module

Prework

Prework Reading

Pre-lecture Reflections

Lecture

Learning Objectives

  • Simple neural networks can be easily represented by matrix arithmentic.

  • Let's see how we can build a relatively simple neural network to recognize handwritten digits.

Biological Neuron

An _artificial neuron_is loosely modeled on a biological neuron.

From Stanford's cs231n

  • The dendrites carry impulses from other neurons of different distances.
  • Once the collective firing rate of the impulses exceed a certain threshold, the neuron fires its own pulse through the axon to other neurons.

Artificial Neuron

The artificial neuron is loosely based on the biological one.

From Stanford's cs231n.

The artifical neuron:

  • collects one or more inputs,
  • each multiplied by a unique weight,
  • sums the weighted inputs,
  • adds a bias,
  • then applies a nonlinear activation function.

Multi-Layer Perceptron (MLP) or Fully Connected Network (FCN)

From Stanford's cs231n.

  • Multiple artificial neurons can act on the same inputs,. This defines a layer. We can have more than one layer until we produce one or more outputs.

  • The example above shows a network with 3 inputs, two layers of neurons, each with 4 neurons, followed by one layer that produces a single value output.

This architecture can be used as a binary classifier.

FCN Detailed View

Here's a more detailed view of a fully connected network making biases explicit and with weight matrix and bias vector dimensions

We can collect all the inputs into a column vector.

Then we can gather the weights for the first layer:

where is a nonlinear activation function, such as ReLU shown below.

The equation for which is.

or in pseudocode simply:

x = max(0, x)

The nonlinear activation functions are crucial, because without them, the neural network can be simplified down to a single linear system of equations, which is severely limited in its representational power.

The subsequent layers can be defined similarly interms of the activations of the previous activation functions.

where the last function is a softmax, which limits the output to between 0 and 1, and all the outputs sum up to 1.

Here's a visualization.

From TDS Post.

Much of neural network training and evaluation comes down to vector operations in which ndarray excels.

Training a Neural Network

  • We won't go into all the details of training a network, but we'll show an example.

  • The steps involve producing the outpur from some input -- Forward Pass

  • Then updating the weights and biases of the network, taking the partial derivatives of the entire model with respect to each parameter and then updating each parameter proportional to its partial derivative -- Backward Pass

Simple weight update example

We'll build our understanding of the gradient by considering derivatives of single variable functions.

Let's start with a quadratic function

The derivative with respect to of our example is

This is the asymptotic slope at any particular value of .

Question: Which way does need to move at to decrease ? Positive or negative direction?

Question: What about at ? Positive or negative direction?



An easy way to update this is:

where is some small number (e.g. 0.01) which we call the learning rate.

Minimizing the Network Loss

To train our network we want to minize some function that computes the difference between our target values and what the network produces for each input .

An appropriate loss function for classification models is the categorical cross-entropy loss.

The following image from the Medium Post illustrates the full pipeline:

except in the figure is the model output and are the targets.

We won't go into details but a nice output is that

Forward propagation

  • Weights would be represented by an Array2<f32> with dimensions: (# neurons # inputs)

    • For example if you have 100 input neurons and 50 2nd layer neurons then you need a matrix of weights
  • Bias vectors would be represented by an Array2<f32> with dimensions (# neurons 1)

Backwards propagation

  • As mentioned, in practice the calculation of partial derivatives must be calculated starting with the end of the pipeline and then working backwards.

  • The chain rule for differentiation in calculus gives a modular, scalable way of doing this calculation, again starting from the end and working backwards.

Just a reminder, we have

We already did

And the process continues.

Example Implementaiton

Let's look at an example implementation.

We'll start with some utility functions.

#![allow(unused)]
fn main() {
:dep ndarray = { version = "^0.15" }
:dep rand = { version = "0.8" }

use ndarray::prelude::*;
use rand::Rng;

// Helper function to populate arrays with random values
// We could use ndarray-rand crate to do this, but it doesn't seem to work in a Jupyter notebook
fn populate_array(arr: &mut Array2<f32>, m: usize, n: usize) {
    let mut rng = rand::thread_rng();
    for i in 0..m {
        for j in 0..n {
            arr[(i, j)] = rng.gen_range(-1.0..1.0);
        }
    }
}

// ReLU activation function
fn relu(x: &Array2<f32>) -> Array2<f32> {
    x.mapv(|x| if x > 0.0 { x } else { 0.0 })
}

// Derivative of ReLU
fn relu_derivative(x: &Array2<f32>) -> Array2<f32> {
    x.mapv(|x| if x > 0.0 { 1.0 } else { 0.0 })
}

// Softmax function
fn softmax(x: &Array2<f32>) -> Array2<f32> {
    let exp_x = x.mapv(|x| x.exp());
    let sum_exp_x = exp_x.sum();
    exp_x / sum_exp_x
}

}

Then we'll define a NeuralNetwork struct and associated methods.

#![allow(unused)]

fn main() {
struct NeuralNetwork {
    input_size: usize,
    output_size: usize,
    weights1: Array2<f32>,
    biases1: Array2<f32>,
    weights2: Array2<f32>,
    biases2: Array2<f32>,
    learning_rate: f32,
}

impl NeuralNetwork {
    /// Create a shallow neural network with one hidden layer
    /// 
    /// # Arguments
    /// 
    /// * `input_size` - The number of input neurons
    /// * `hidden_size` - The number of neurons in the hidden layer
    /// * `output_size` - The number of output neurons
    /// * `learning_rate` - The learning rate for the neural network
    fn new(input_size: usize, hidden_size: usize, output_size: usize, learning_rate: f32) -> Self {
        let mut weights1 = Array2::zeros((hidden_size, input_size));
        let mut weights2 = Array2::zeros((output_size, hidden_size));

        // Initialize weights with random values
        populate_array(&mut weights1, hidden_size, input_size);
        populate_array(&mut weights2, output_size, hidden_size);

        let biases1 = Array2::zeros((hidden_size, 1));
        let biases2 = Array2::zeros((output_size, 1));

        NeuralNetwork {
            input_size,
            output_size,
            weights1,
            biases1,
            weights2,
            biases2,
            learning_rate,
        }
    }

    fn forward(&self, input: &Array2<f32>) -> (Array2<f32>, Array2<f32>, Array2<f32>) {
        // First layer
        let pre_activation1 = self.weights1.dot(input) + &self.biases1;
        let hidden = relu(&pre_activation1);

        // Output layer
        let pre_activation2 = self.weights2.dot(&hidden) + &self.biases2;
        let output = softmax(&pre_activation2);

        (hidden, pre_activation2, output)
    }

    fn backward(
        &mut self,
        input: &Array2<f32>,
        hidden: &Array2<f32>,
        pre_activation2: &Array2<f32>,
        output: &Array2<f32>,
        target: &Array2<f32>,
    ) {
        let batch_size = input.shape()[1] as f32;

        // Calculate gradients for output layer
        let output_error = output - target;
        
        // Gradients for weights2 and biases2
        let d_weights2 = output_error.dot(&hidden.t()) / batch_size;
        let d_biases2 = &output_error.sum_axis(Axis(1)).insert_axis(Axis(1)) / batch_size;

        // Backpropagate error to hidden layer
        let hidden_error = self.weights2.t().dot(&output_error);
        let hidden_gradient = &hidden_error * &relu_derivative(hidden);

        // Gradients for weights1 and biases1
        let d_weights1 = hidden_gradient.dot(&input.t()) / batch_size;
        let d_biases1 = &hidden_gradient.sum_axis(Axis(1)).insert_axis(Axis(1)) / batch_size;

        // Update weights and biases using gradient descent
        self.weights2 = &self.weights2 - &(d_weights2 * self.learning_rate);
        self.biases2 = &self.biases2 - &(d_biases2 * self.learning_rate);
        self.weights1 = &self.weights1 - &(d_weights1 * self.learning_rate);
        self.biases1 = &self.biases1 - &(d_biases1 * self.learning_rate);
    }

    fn train(&mut self, input: &Array2<f32>, target: &Array2<f32>) -> f32 {
        // Forward pass
        let (hidden, pre_activation2, output) = self.forward(input);

        // Calculate loss (cross-entropy)
        let epsilon = 1e-15;
        let loss = -target * &output.mapv(|x| (x + epsilon).ln());
        let loss = loss.sum() / (input.shape()[1] as f32);

        // Backward pass
        self.backward(input, &hidden, &pre_activation2, &output, target);

        loss
    }
}

}

fn main() {
    // Create a neural network
    let mut nn = NeuralNetwork::new(6, 6, 4, 0.01);

    // Create sample input
    let mut input = Array2::zeros((nn.input_size, 1));
    populate_array(&mut input, nn.input_size, 1);

    // Create sample target
    let mut target = Array2::zeros((nn.output_size, 1));
    populate_array(&mut target, nn.output_size, 1);
    
    // Calculate initial cross entropy loss before training
    let (_, _, initial_output) = nn.forward(&input);
    let epsilon = 1e-15;
    let initial_loss = -&target * &initial_output.mapv(|x| (x + epsilon).ln());
    let initial_loss = initial_loss.sum() / (input.shape()[1] as f32);
    println!("Initial loss before training: {}", initial_loss);

    
    // Train for one iteration
    let loss = nn.train(&input, &target);
    //println!("Loss: {}", loss);

    // Calculate loss after training
    let (_, _, final_output) = nn.forward(&input);
    let epsilon = 1e-15;
    let final_loss = -&target * &final_output.mapv(|x| (x + epsilon).ln());
    let final_loss = final_loss.sum() / (input.shape()[1] as f32);
    println!("loss after training: {}", final_loss);

}

We can now run an iteration of a forward pass and backward pass.

You can repeatedly run this cell to see the loss decreasing.

#![allow(unused)]
fn main() {
main()
}
Initial loss before training: 1.5886599
loss after training: 1.5812341





()

Has anyone implemented neural networks in Rust?

See for example candle. Perhaps a pun on "Torch" from "PyTorch".

But the point isn't to use an existing package but learn how to build a simple one from basic linear algebra principles.

All of this and more...

  • Quick start: https://github.com/rust-ndarray/ndarray/blob/master/README-quick-start.md
  • Numpy equivalence: https://docs.rs/ndarray/latest/ndarray/doc/ndarray_for_numpy_users/index.html
  • Linear Algebra: https://github.com/rust-ndarray/ndarray-linalg/blob/master/README.md

Technical Coding Challenge

Coding Challenge

Coding Challenge Review

Plotters -- Rust Drawing Library

About This Module

Prework

Prework Reading

Pre-lecture Reflections

Lecture

Learning Objectives

From the website:

Plotters is a drawing library designed for rendering figures, plots, and charts, in pure Rust.

Full documentation is at https://docs.rs/plotters/latest/plotters/.

Installation and Configuration

Rust Project

In Cargo.toml add the following dependency

[dependencies]  
plotters="0.3.6"

Or inside Jupyter notebook

For 2021 edition:

:dep plotters = { version = "^0.3.6", default_features = false, features = ["evcxr", "all_series"] }

For 2024 edition, default_features became default-features (dash instead of underscore):

:dep plotters = { version = "^0.3.6", default-features = false, features = ["evcxr", "all_series"] }

Plotters Tutorial

We'll go through the interactive tutorial, reproduced here.

Import everything defined in prelude which includes evcxr_figure().

// Rust 2021 edition syntax
//:dep plotters = { version = "^0.3.6", default_features = false, features = ["evcxr", "all_series"] }

// Rust 2024 edition syntax: changed the syntax to 'default-feature'
:dep plotters = { version = "^0.3.6", default-features = false, features = ["evcxr", "all_series"] }

extern crate plotters;

// Import all the plotters prelude functions
use plotters::prelude::*;

// To create a figure that can be displayed in Jupyter notebook, use evcxr_figure function.
// The first param is the resolution of the figure.
// The second param is the closure that performes the drawing.
evcxr_figure((300, 100), |name| {
    // Do the drawings
    name.fill(&BLUE)?;
    // Tell plotters that everything is ok
    Ok(())
})

  • evcxr_figure((xsize, ysize), |name| { your code }) is how you make a figure of a certain size and put things in it.
  • name is the handle for accessing the figure
//:dep plotters = { version = "^0.3.0", default_features = false, features = ["evcxr", "all_series"] }

extern crate plotters;
use plotters::prelude::*;

evcxr_figure((320,50), |root| {
    root.fill(&GREEN)?;
    root.draw(&Text::new("Hello World from Plotters!", (15, 15), ("Arial", 20).into_font()))?;
    Ok(())
})
Hello World from Plotters!

Sub-Drawing Areas

The object created by evcxr_figure is a DrawingArea

DrawingArea is a key concept and represents the handle into which things will be actually drawn. Plotters supports different types of drawing areas depending on context.

  • Inside jupyter notebook the type of drawing area is an SVG (Scalable Vector Graphics) area
  • When used from the termina the most common type is a BitMapBackend

For full documentation on what you can do with DrawingArea see https://docs.rs/plotters/latest/plotters/drawing/struct.DrawingArea.html

Key capabilities:

  • fill: Fill it with a background color
  • draw_mesh: Draw a mesh on it
  • draw_text: Add some text in graphic form
  • present: make it visible (may not be neeed in all backend types)
  • titled: Add a title and return the remaining area
  • split_*: Split it into subareas in a variety of ways

Split Drawing Areas Example

We can make a Sierpiński carpet by splitting the drawing areas and recursion function.

The Sierpiński carpet is a plane fractal first described by Wacław Sierpiński in 1916. The carpet is a generalization of the Cantor set to two dimensions...

//:dep plotters = { version = "^0.3.0", default_features = false, features = ["evcxr", "all_series"] }
extern crate plotters;
use plotters::prelude::*;
use plotters::coord::Shift;

pub fn sierpinski_carpet(
    depth: u32, 
    drawing_area: &DrawingArea<SVGBackend, Shift>) -> Result<(), Box<dyn std::error::Error>> {
    if depth > 0 {
        // Split the drawing area into 9 equal parts
        let sub_areas = drawing_area.split_evenly((3,3));

        // Iterate over the sub-areas
        for (idx, sub_area) in (0..).zip(sub_areas.iter()) {
            if idx == 4 { // idx == 4 is the center sub-area
                // If the sub-area is the center one, fill it with white
                sub_area.fill(&WHITE)?;
            } else {
                sierpinski_carpet(depth - 1, sub_area)?;
            }
        }
    }
    Ok(())
}
evcxr_figure((480,480), |root| {
    root.fill(&BLACK)?;
    sierpinski_carpet(5, &root)
}).style("width: 600px")  /* You can add CSS style to the result */
                          /* Note: doesn't work in VSCode/Cursor */

Charts

Drawing areas are too basic for scientific drawings so the next important concept is a chart

Charts can be used to plot functions, datasets, bargraphs, scatterplots, 3D Objects and other stuff.

Full documentation at https://docs.rs/plotters/latest/plotters/chart/struct.ChartBuilder.html and https://docs.rs/plotters/latest/plotters/chart/struct.ChartContext.html

//:dep plotters = { version = "^0.3.0", default_features = false, features = ["evcxr", "all_series"] }
extern crate plotters;
use plotters::prelude::*;

evcxr_figure((640, 240), |root| {
    // The following code will create a chart context
    let mut chart = ChartBuilder::on(&root)
    // the caption for the chart
        .caption("Hello Plotters Chart Context!", ("Arial", 20).into_font())
   // the X and Y coordinates spaces for the chart
        .build_cartesian_2d(0f32..1f32, 0f32..1f32)?;
    // Then we can draw a series on it!
    chart.draw_series((1..10).map(|x|{
        let x = x as f32/10.0;
        Circle::new((x,x), 5, &RED)
    }))?;
    Ok(())
}).style("width:60%")

Hello Plotters Chart Context!

Common chart components

Adding a mesh, and X and Y labels

//:dep plotters = { version = "^0.3.0", default_features = false, features = ["evcxr", "all_series"] }
extern crate plotters;
use plotters::prelude::*;

evcxr_figure((640, 480), |root| {
    // The following code will create a chart context
    let mut chart = ChartBuilder::on(&root)
        .caption("Chart with Axis Label", ("Arial", 20).into_font())
        .x_label_area_size(80)
        .y_label_area_size(80)
        .build_cartesian_2d(0f32..1f32, 0f32..1f32)?;
    
    chart.configure_mesh()
        .x_desc("Here's the label for X")
        .y_desc("Here's the label for Y")
        .draw()?;

    // Then we can draw a series on it!
    chart.draw_series((1..10).map(|x|{
        let x = x as f32/10.0;
        Circle::new((x,x), 5, &RED)
    }))?;
    
    Ok(())
}).style("width: 60%")

Chart with Axis Label Here's the label for Y Here's the label for X 0.0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0 0.0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0

Then let's disable mesh lines for the X axis

//:dep plotters = { version = "^0.3.0", default_features = false, features = ["evcxr", "all_series"] }
extern crate plotters;
use plotters::prelude::*;

evcxr_figure((640, 480), |root| {
    // The following code will create a chart context
    let mut chart = ChartBuilder::on(&root)
        .caption("Chart Context with Mesh and Axis", ("Arial", 20).into_font())
        .x_label_area_size(40)
        .y_label_area_size(40)
        .build_cartesian_2d(0f32..1f32, 0f32..1f32)?;
    
    chart.configure_mesh()
        .y_labels(10)
        .light_line_style(&TRANSPARENT)
        .disable_x_mesh()
        .draw()?;
    
    // Then we can draw a series on it!
    chart.draw_series((1..10).map(|x|{
        let x = x as f32/10.0;
        Circle::new((x,x), 5, &RED)
    }))?;

    Ok(())
}).style("width: 60%")
Chart Context with Mesh and Axis 0.0 0.2 0.4 0.6 0.8 1.0 0.0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0

Adding subcharts

Simple. Split your drawing area and then add a chart in each of the split portions

//:dep plotters = { version = "^0.3.0", default_features = false, features = ["evcxr", "all_series"] }

extern crate plotters;
use plotters::prelude::*;
evcxr_figure((640, 480), |root| {
    let sub_areas = root.split_evenly((2,2));
    
    for (idx, area) in (1..).zip(sub_areas.iter()) {
        // The following code will create a chart context
        let mut chart = ChartBuilder::on(&area)
            .caption(format!("Subchart #{}", idx), ("Arial", 15).into_font())
            .x_label_area_size(40)
            .y_label_area_size(40)
            .build_cartesian_2d(0f32..1f32, 0f32..1f32)?;

        chart.configure_mesh()
            .y_labels(10)
            .light_line_style(&TRANSPARENT)
            .disable_x_mesh()
            .draw()?;

        // Then we can draw a series on it!
        chart.draw_series((1..10).map(|x|{
            let x = x as f32/10.0;
            Circle::new((x,x), 5, &RED)
        }))?;
    }

    Ok(())
}).style("width: 60%")

Subchart #1 0.0 0.2 0.4 0.6 0.8 1.0 0.0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0 Subchart #2 0.0 0.2 0.4 0.6 0.8 1.0 0.0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0 Subchart #3 0.0 0.2 0.4 0.6 0.8 1.0 0.0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0 Subchart #4 0.0 0.2 0.4 0.6 0.8 1.0 0.0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0

Drawing on Charts with the Series Abstraction

  • Unlike most of the plotting libraries, Plotters doesn't actually define any types of chart.

  • All the charts are abstracted to a concept of series.

    • By doing so, you can put a histgoram series and a line plot series into the same chart context.
  • The series is actually defined as an iterator of elements.

This gives Plotters a huge flexibility on drawing charts. You can implement your own types of series and uses the coordinate translation and chart elements.

There are few types of predefined series, just for convenience:

  • Line Series
  • Histogram
  • Point Series

Scatter Plot

First, generate random numbers

:dep rand = { version = "0.6.5" }
//:dep plotters = { version = "^0.3.0", default_features = false, features = ["evcxr", "all_series"] }

extern crate rand;

use rand::distributions::Normal;
use rand::distributions::Distribution;
use rand::thread_rng;
let sd = 0.13;
let random_points:Vec<(f64,f64)> = {
    let mut norm_dist = Normal::new(0.5, sd);
    let (mut x_rand, mut y_rand) = (thread_rng(), thread_rng());
    let x_iter = norm_dist.sample_iter(&mut x_rand);
    let y_iter = norm_dist.sample_iter(&mut y_rand);
    x_iter.zip(y_iter).take(1000).collect()
};
println!("{}", random_points.len());

1000

To draw the series, we provide an iterator on the elements and then map a closure.


extern crate plotters;
use plotters::prelude::*;

evcxr_figure((480, 480), |root| {
    // The following code will create a chart context
    let mut chart = ChartBuilder::on(&root)
        .caption("Normal Distribution w/ 2 sigma", ("Arial", 20).into_font())
        .x_label_area_size(40)
        .y_label_area_size(40)
        .build_ranged(0f64..1f64, 0f64..1f64)?;
    
    chart.configure_mesh()
        .disable_x_mesh()
        .disable_y_mesh()
        .draw()?;
    
    // Draw little green circles. Remember that closures can capture variables from the enclosing scope
    chart.draw_series(random_points.iter().map(|(x,y)| Circle::new((*x,*y), 3, GREEN.filled())));
    
    // You can always freely draw on the drawing backend.  So we can add background after the fact
    let area = chart.plotting_area();
    let two_sigma = sd * 2.0;
    let chart_width = 480;
    let radius = two_sigma * chart_width as f64;  // circle radius is in pixels not chart coords
    area.draw(&Circle::new((0.5, 0.5), radius, RED.mix(0.3).filled()))?;
    area.draw(&Cross::new((0.5, 0.5), 5, &RED))?;
    
    Ok(())
}).style("width:60%")

Normal Distribution w/ 2 sigma 0.0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0 0.0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0

Histograms

We can also have histograms. For histograms, we can use the predefined histogram series struct to build the histogram easily. The following code demonstrate how to create both histogram for X and Y value of random_points.

// Rust 2021
//:dep plotters = { version = "^0.3.0", default_features = false, features = ["evcxr", "all_series", "all_elements"] }

// Rust 2024
:dep plotters = { version = "^0.3.0", default-features = false, features = ["evcxr", "all_series", "all_elements"] }


extern crate plotters;
use plotters::prelude::*;

evcxr_figure((640, 480), |root| {
    let areas = root.split_evenly((2,1));
    let mut charts = vec![];
    
    // The following code will create a chart context
   for (area, name) in areas.iter().zip(["X", "Y"].into_iter()) {
        let mut chart = ChartBuilder::on(&area)
            .caption(format!("Histogram for {}", name), ("Arial", 20).into_font())
            .x_label_area_size(40)
            .y_label_area_size(40)
            .build_cartesian_2d(0u32..100u32, 0f64..0.5f64)?;
        chart.configure_mesh()
            .disable_x_mesh()
            .disable_y_mesh()
            .y_labels(5)
            .x_label_formatter(&|x| format!("{:.1}", *x as f64 / 100.0))
            .y_label_formatter(&|y| format!("{}%", (*y * 100.0) as u32))
            .draw()?;
        charts.push(chart);
    }
    // Histogram is just another series but a nicely encapsulated one
    let hist_x = Histogram::vertical(&charts[0])
        .style(RED.filled())
        .margin(0)
        .data(random_points.iter().map(|(x,_)| ((x*100.0) as u32, 0.01)));
    
    let hist_y = Histogram::vertical(&charts[0])
        .style(GREEN.filled())
        .margin(0)
        .data(random_points.iter().map(|(_,y)| ((y*100.0) as u32, 0.01)));
    
    charts[0].draw_series(hist_x);
    charts[1].draw_series(hist_y);
    
    Ok(())
}).style("width:60%")

Histogram for X 0% 20% 40% 0.0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0 Histogram for Y 0% 20% 40% 0.0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0

Fancy combination of histogram and scatter

Split the drawing area in 3 parts and draw two histograms and a scatter plot

//:dep plotters = { version = "^0.3.0", default_features = false, features = ["evcxr", "all_series", "all_elements"] }

use plotters::prelude::*;

evcxr_figure((640, 480), |root| {
    let root = root.titled("Scatter with Histogram Example", ("Arial", 20).into_font())?;
    
    // Split the drawing area into a grid with specified X and Y breakpoints
    let areas = root.split_by_breakpoints([560], [80]);

    let mut x_hist_ctx = ChartBuilder::on(&areas[0])
        .y_label_area_size(40)
        .build_cartesian_2d(0u32..100u32, 0f64..0.5f64)?;
    let mut y_hist_ctx = ChartBuilder::on(&areas[3])
        .x_label_area_size(40)
        .build_cartesian_2d(0f64..0.5f64, 0..100u32)?;
    let mut scatter_ctx = ChartBuilder::on(&areas[2])
        .x_label_area_size(40)
        .y_label_area_size(40)
        .build_cartesian_2d(0f64..1f64, 0f64..1f64)?;
    scatter_ctx.configure_mesh()
        .disable_x_mesh()
        .disable_y_mesh()
        .draw()?;
    scatter_ctx.draw_series(random_points.iter().map(|(x,y)| Circle::new((*x,*y), 3, GREEN.filled())))?;
    let x_hist = Histogram::vertical(&x_hist_ctx)
        .style(RED.filled())
        .margin(0)
        .data(random_points.iter().map(|(x,_)| ((x*100.0) as u32, 0.01)));
    let y_hist = Histogram::horizontal(&y_hist_ctx)
        .style(GREEN.filled())
        .margin(0)
        .data(random_points.iter().map(|(_,y)| ((y*100.0) as u32, 0.01)));
    x_hist_ctx.draw_series(x_hist)?;
    y_hist_ctx.draw_series(y_hist)?;
    
    Ok(())
}).style("width:60%")

Scatter with Histogram Example 0.0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0 0.0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0

Drawing Lines

It's stil using the draw_series call with the convenient wrapper of LineSeries.

//:dep plotters = { version = "^0.3.0", default_features = false, features = ["evcxr", "all_series", "all_elements"] }

use plotters::prelude::*;

evcxr_figure((640, 480), |root_area| {
        root_area.fill(&WHITE)?;

    let root_area = root_area.titled("Line Graph", ("sans-serif", 60))?;

    let x_axis = (-3.4f32..3.4).step(0.1);

    let mut cc = ChartBuilder::on(&root_area)
        .margin(5)
        .set_all_label_area_size(50)
        .caption("Sine and Cosine", ("sans-serif", 40))
        .build_cartesian_2d(-3.4f32..3.4, -1.2f32..1.2f32)?;

    cc.configure_mesh()
        .x_labels(20)
        .y_labels(10)
        .disable_mesh()
        .x_label_formatter(&|v| format!("{:.1}", v))
        .y_label_formatter(&|v| format!("{:.1}", v))
        .draw()?;

    cc.draw_series(LineSeries::new(x_axis.values().map(|x| (x, x.sin())), &RED))?
        .label("Sine")
        .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], RED));

    cc.draw_series(LineSeries::new(x_axis.values().map(|x| (x, x.cos())), &BLUE,))?
    .label("Cosine")
    .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], BLUE));

    cc.configure_series_labels().border_style(BLACK).draw()?;

    Ok(())
}).style("width:60%")
Line Graph Sine and Cosine -3.0 -2.5 -2.0 -1.5 -1.0 -0.5 0.0 0.5 1.0 1.5 2.0 2.5 3.0 -1.0 -0.5 0.0 0.5 1.0 -3.0 -2.5 -2.0 -1.5 -1.0 -0.5 0.0 0.5 1.0 1.5 2.0 2.5 3.0 -1.0 -0.5 0.0 0.5 1.0 Sine Cosine

3D Plotting

Big difference is in the ChartBuilder call. Instead of build_cartesian_2d we use build_cartesian_3d.

Unlike the 2D plots, 3D plots use the function configure_axes to configure the chart components.

//:dep plotters = { version = "^0.3.0", default_features = false, features = ["evcxr", "all_series", "all_elements"] }

use plotters::prelude::*;

evcxr_figure((640, 480), |root| {
    let root = root.titled("3D Plotting", ("Arial", 20).into_font())?;
    
    let mut chart = ChartBuilder::on(&root)
        .build_cartesian_3d(-10.0..10.0, -10.0..10.0, -10.0..10.0)?;
    
    chart.configure_axes().draw()?;
    
    // Draw a red circle parallel to XOZ panel
    chart.draw_series(LineSeries::new(
        (-314..314).map(|a| a as f64 / 100.0).map(|a| (8.0 * a.cos(), 0.0, 8.0 *a.sin())),
        &RED,
    ))?;
    // Draw a green circle parallel to YOZ panel
    chart.draw_series(LineSeries::new(
        (-314..314).map(|a| a as f64 / 100.0).map(|a| (0.0, 8.0 * a.cos(), 8.0 *a.sin())),
        &GREEN,
    ))?;
    
    Ok(())
})

3D Plotting -10.0 -5.0 0.0 5.0 10.0 -10.0 -5.0 0.0 5.0 10.0 -10.0 -5.0 0.0 5.0 10.0

For more examples check

https://plotters-rs.github.io/plotters-doc-data/evcxr-jupyter-integration.html

What about using it from the terminal?

The key difference is in how you define your drawing area.

  • Inside Jupyter notebook we create a drawing area using evcxr_figure

  • In the terminal context we create a drawing area using

#![allow(unused)]
fn main() {
let root = BitMapBackend::new("0.png", (640, 480)).into_drawing_area();

// or

let root = SVGBackend::new("0.svg", (1024, 768)).into_drawing_area();

// or

let root = BitMapBackend::gif("0.gif", (600, 400), 100)?.into_drawing_area();
}

Let's take a look on the terminal example (demo).

What if you don't want output to a file or a browser but standalone application?

Things get very messy and machine specific there. You need to integrate with the underlying OS graphics terminal libraries. For MacOS and Linux this is the the CairoBackend library but I don't know what it is for Windows

Here's an example from the terminal using GTK.

On MacOS, install these dependencies first:

brew install gtk4
brew install pkg-config

Then cargo run in (plotters-gtk-demo).

Technical Coding Challenge

Coding Challenge

Coding Challenge Review

CSV Files and Basic Data Engineering

About This Module

Prework

Prework Reading

Pre-lecture Reflections

Lecture

Learning Objectives

  • Data Engineering in Rust
    1. Reading CSV Files
    2. Deserializing CSV Files
    3. Cleaning CSV Files
    4. Converting CSV Data to NDArray representation
  1. Reading CSV Files
  2. Deserializing CSV Files
  3. Cleaning CSV Files
  4. Converting CSV Data to NDArray representation
  • By default CSV will generate StringRecords which are structs containing an array of strings

  • Missing fields will be represented as empty strings

:dep csv = { version = "^1.3" }

let mut rdr = csv::Reader::from_path("uspop.csv").unwrap();
let mut count = 0;
// Loop over each record.
for result in rdr.records() {
    // An error may occur, so abort the program in an unfriendly way.
    // We will make this more friendly later!
    let record = result.expect("a CSV record");
    // Print a debug version of the record.
    if count < 5 {
        println!("{:?}", record);
    }
    count += 1;
}

StringRecord(["Davidsons Landing", "AK", "", "65.2419444", "-165.2716667"])
StringRecord(["Kenai", "AK", "7610", "60.5544444", "-151.2583333"])
StringRecord(["Oakman", "AL", "", "33.7133333", "-87.3886111"])
StringRecord(["Richards Crossroads", "AL", "", "31.7369444", "-85.2644444"])
StringRecord(["Sandfort", "AL", "", "32.3380556", "-85.2233333"])





()

What if there malformed records with mismatched fields?

:dep csv = { version = "^1.3" }

let mut rdr = csv::Reader::from_path("usbad.csv").unwrap();
let mut count = 0;
// Loop over each record.
for result in rdr.records() {
    // An error may occur, so abort the program in an unfriendly way.
    // We will make this more friendly later!
    let record = result.expect("a CSV record");
    // Print a debug version of the record.
    if count < 5 {
        println!("{:?}", record);
    }
    count += 1;
}

StringRecord(["Davidsons Landing", "AK", "", "65.2419444", "-165.2716667"])
StringRecord(["Kenai", "AK", "7610", "60.5544444", "-151.2583333"])



thread '<unnamed>' panicked at src/lib.rs:164:25:
a CSV record: Error(UnequalLengths { pos: Some(Position { byte: 125, line: 4, record: 3 }), expected_len: 5, len: 8 })
stack backtrace:
   0: _rust_begin_unwind
   1: core::panicking::panic_fmt
   2: core::result::unwrap_failed
   3: std::panic::catch_unwind
   4: _run_user_code_16
   5: evcxr::runtime::Runtime::run_loop
   6: evcxr::runtime::runtime_hook
   7: evcxr_jupyter::main
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

Let's make this safe for malformed records. Match statements to the rescue

:dep csv = { version = "^1.3" }

let mut rdr = csv::Reader::from_path("usbad.csv").unwrap();
let mut count = 0;
// Loop over each record.
for result in rdr.records() {
    // An error may occur, so abort the program in an unfriendly way.
    // We will make this more friendly later!
    match result {
        Ok(record) => { 
          if count < 5 {
              println!("{:?}", record);
          }
          count += 1; 
        },
        Err(err) => {
            println!("error reading CSV record {}", err);
        }  
    }
}
StringRecord(["Davidsons Landing", "AK", "", "65.2419444", "-165.2716667"])
StringRecord(["Kenai", "AK", "7610", "60.5544444", "-151.2583333"])
error reading CSV record CSV error: record 3 (line: 4, byte: 125): found record with 8 fields, but the previous record has 5 fields
StringRecord(["Kenai", "AK", "7610", "60.5544444", "-151.2583333"])





()

If your csv file has headers and you want to access them then you can use the headers function

By default, the first row is treated as a special header row.

:dep csv = { version = "^1.3" }
{
let mut rdr = csv::Reader::from_path("usbad.csv").unwrap();
let mut count = 0;
// Loop over each record.
let headers = rdr.headers()?;
println!("Headers: {:?}", headers);

for result in rdr.records() {
    // An error may occur, so abort the program in an unfriendly way.
    // We will make this more friendly later!
    match result {
        Ok(record) => { 
          if count < 5 {
              println!("{:?}", record);
          }
          count += 1; 
        },
        Err(err) => {
            println!("error reading CSV record {}", err);
        }  
    }
}
}
Headers: StringRecord(["City", "State", "Population", "Latitude", "Longitude"])
StringRecord(["Davidsons Landing", "AK", "", "65.2419444", "-165.2716667"])
StringRecord(["Kenai", "AK", "7610", "60.5544444", "-151.2583333"])
error reading CSV record CSV error: record 3 (line: 4, byte: 125): found record with 8 fields, but the previous record has 5 fields
StringRecord(["Kenai", "AK", "7610", "60.5544444", "-151.2583333"])





()

You can customize your reader in many ways:

#![allow(unused)]
fn main() {
let mut rdr = csv::ReaderBuilder::new()
        .has_headers(false)
        .delimiter(b';')
        .double_quote(false)
        .escape(Some(b'\\'))
        .flexible(true)
        .comment(Some(b'#'))
        .from_path("Some path");
}

What is the difference between a ReaderBuilder and a Reader? One is customizable and one is not.

2. Deserializing CSV Files

StringRecords are not particularly useful in computation. They typically have to be converted to floats or integers before we can work with them.

You can deserialize your CSV data either into a:

  • Record with types you define, or

  • a hashmap of key value pairs

Custom Record

:dep csv = { version = "^1.3" }
use std::collections::HashMap;

type StrRecord = (String, String, Option<u64>, f64, f64);

let mut rdr = csv::Reader::from_path("uspop.csv").unwrap();
let mut count = 0;
// Loop over each record.
for result in rdr.deserialize() {
    // An error may occur, so abort the program in an unfriendly way.
    // We will make this more friendly later!
    let record:StrRecord = result.expect("a CSV record");
    // Print a debug version of the record.
    if count < 5 {
        println!("{:?}", record);
    }
    count += 1;
}

("Davidsons Landing", "AK", None, 65.2419444, -165.2716667)
("Kenai", "AK", Some(7610), 60.5544444, -151.2583333)
("Oakman", "AL", None, 33.7133333, -87.3886111)
("Richards Crossroads", "AL", None, 31.7369444, -85.2644444)
("Sandfort", "AL", None, 32.3380556, -85.2233333)





()

Note that we use Option<T> on one of the types that we know has some empty values.

HashMap

Note the order of the outputs in print.

:dep csv = { version = "^1.3" }
use std::collections::HashMap;

type Record = HashMap<String, String>;

let mut rdr = csv::Reader::from_path("uspop.csv").unwrap();
let mut count = 0;
// Loop over each record.
for result in rdr.deserialize() {
    // An error may occur, so abort the program in an unfriendly way.
    // We will make this more friendly later!
    let record:Record = result.expect("a CSV record");
    // Print a debug version of the record.
    if count < 5 {
        println!("{:?}", record);
    }
    count += 1;
}
{"State": "AK", "Latitude": "65.2419444", "Population": "", "City": "Davidsons Landing", "Longitude": "-165.2716667"}
{"Population": "7610", "City": "Kenai", "State": "AK", "Longitude": "-151.2583333", "Latitude": "60.5544444"}
{"Latitude": "33.7133333", "City": "Oakman", "Population": "", "Longitude": "-87.3886111", "State": "AL"}
{"Population": "", "Longitude": "-85.2644444", "City": "Richards Crossroads", "State": "AL", "Latitude": "31.7369444"}
{"City": "Sandfort", "Latitude": "32.3380556", "Longitude": "-85.2233333", "State": "AL", "Population": ""}





()

This will work well but makes it hard to read and know what type is associated with which CSV field

You can do better by using serde and structs

:dep csv = { version = "^1.3" }
:dep serde = { version = "^1", features = ["derive"] }

// This lets us write `#[derive(Deserialize)]`.
use serde::Deserialize;

// We don't need to derive `Debug` (which doesn't require Serde), but it's a
// good habit to do it for all your types.
//
// Notice that the field names in this struct are NOT in the same order as
// the fields in the CSV data!
#[derive(Debug, Deserialize)]  // derive the Deserialize trait
#[serde(rename_all = "PascalCase")]
struct SerRecord {
    latitude: f64,
    longitude: f64,
    population: Option<u64>,  // account for the fact that some records have no population
    city: String,
    state: String,
}

let mut rdr = csv::Reader::from_path("uspop.csv").unwrap();
let mut count = 0;

// Loop over each record.
for result in rdr.deserialize() {
    // An error may occur, so abort the program in an unfriendly way.
    // We will make this more friendly later!
    let record:SerRecord = result.expect("a CSV record");
    // Print a debug version of the record.
    if count < 5 {
        println!("{:?}", record);
    }
    count += 1;
}

The type of the variable rdr was redefined, so was lost.


SerRecord { latitude: 65.2419444, longitude: -165.2716667, population: None, city: "Davidsons Landing", state: "AK" }
SerRecord { latitude: 60.5544444, longitude: -151.2583333, population: Some(7610), city: "Kenai", state: "AK" }
SerRecord { latitude: 33.7133333, longitude: -87.3886111, population: None, city: "Oakman", state: "AL" }
SerRecord { latitude: 31.7369444, longitude: -85.2644444, population: None, city: "Richards Crossroads", state: "AL" }
SerRecord { latitude: 32.3380556, longitude: -85.2233333, population: None, city: "Sandfort", state: "AL" }





()

What about deserializing with invalid data?

:dep csv = { version = "^1.3" }
:dep serde = { version = "^1", features = ["derive"] }

// This lets us write `#[derive(Deserialize)]`.
use serde::Deserialize;

// We don't need to derive `Debug` (which doesn't require Serde), but it's a
// good habit to do it for all your types.
//
// Notice that the field names in this struct are NOT in the same order as
// the fields in the CSV data!
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct FSerRecord {
    latitude: f64,
    longitude: f64,
    population: Option<u64>,
    city: String,
    state: String,
}

let mut rdr = csv::Reader::from_path("usbad.csv").unwrap();
let mut count = 0;
// Loop over each record.
for result in rdr.deserialize() {
    // An error may occur, so abort the program in an unfriendly way.
    // We will make this more friendly later!
    let record:FSerRecord = result.expect("a CSV record");
    // Print a debug version of the record.
    if count < 5 {
        println!("{:?}", record);
    }
    count += 1;
}

FSerRecord { latitude: 65.2419444, longitude: -165.2716667, population: None, city: "Davidsons Landing", state: "AK" }
FSerRecord { latitude: 60.5544444, longitude: -151.2583333, population: Some(7610), city: "Kenai", state: "AK" }



thread '<unnamed>' panicked at src/lib.rs:335:36:
a CSV record: Error(UnequalLengths { pos: Some(Position { byte: 125, line: 4, record: 3 }), expected_len: 5, len: 8 })
stack backtrace:
   0: _rust_begin_unwind
   1: core::panicking::panic_fmt
   2: core::result::unwrap_failed
   3: <unknown>
   4: <unknown>
   5: evcxr::runtime::Runtime::run_loop
   6: evcxr::runtime::runtime_hook
   7: evcxr_jupyter::main
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

Deserialization failed so we need to deal with bad records just like before. Match statement to the rescue

:dep csv = { version = "^1.3" }
:dep serde = { version = "^1", features = ["derive"] }

// This lets us write `#[derive(Deserialize)]`.
use serde::Deserialize;

// We don't need to derive `Debug` (which doesn't require Serde), but it's a
// good habit to do it for all your types.
//
// Notice that the field names in this struct are NOT in the same order as
// the fields in the CSV data!
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct GSerRecord {
    latitude: f64,
    longitude: f64,
    population: Option<u64>,
    city: String,
    state: String,
}

let mut rdr = csv::Reader::from_path("usbad.csv").unwrap();
let mut count = 0;

// Loop over each record.
// We need to specify the type we are deserializing to because compiler
// cannot infer the type from the match statement
for result in rdr.deserialize::<GSerRecord>() {
    // An error may occur, so abort the program in an unfriendly way.
    // We will make this more friendly later!
    match result {
        Ok(record) => {
            // Print a debug version of the record.
            if count < 5 {
                println!("{:?}", record);
            }
            count += 1;
        },
        Err(err) => {
            println!("{}", err);
        }
    }
}

GSerRecord { latitude: 65.2419444, longitude: -165.2716667, population: None, city: "Davidsons Landing", state: "AK" }
GSerRecord { latitude: 60.5544444, longitude: -151.2583333, population: Some(7610), city: "Kenai", state: "AK" }
CSV error: record 3 (line: 4, byte: 125): found record with 8 fields, but the previous record has 5 fields
GSerRecord { latitude: 60.5544444, longitude: -151.2583333, population: Some(7610), city: "Kenai", state: "AK" }





()

Some more complex work. Let's filter cities over a population threshold

:dep csv = { version = "^1.3" }
:dep serde = { version = "^1", features = ["derive"] }

// This lets us write `#[derive(Deserialize)]`.
use serde::Deserialize;

// We don't need to derive `Debug` (which doesn't require Serde), but it's a
// good habit to do it for all your types.
//
// Notice that the field names in this struct are NOT in the same order as
// the fields in the CSV data!
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct FilterRecord {
    latitude: f64,
    longitude: f64,
    population: Option<u64>,
    city: String,
    state: String,
}

let mut rdr = csv::Reader::from_path("uspop.csv").unwrap();
let minimum_pop: u64 = 50_000;
// Loop over each record.
for result in rdr.deserialize::<FilterRecord>() {
    // An error may occur, so abort the program in an unfriendly way.
    // We will make this more friendly later!
    match result {
        Ok(record) => {
            // `map_or` is a combinator on `Option`. It take two parameters:
            // a value to use when the `Option` is `None` (i.e., the record has
            // no population count) and a closure that returns another value of
            // the same type when the `Option` is `Some`. In this case, we test it
            // against our minimum population count that we got from the command
            // line.
            if record.population.map_or(false, |pop| pop >= minimum_pop) {
                println!("{:?}", record);
            }
        },
        Err(err) => {
            println!("{}", err);
        }
    }
}

FilterRecord { latitude: 34.0738889, longitude: -117.3127778, population: Some(52335), city: "Colton", state: "CA" }
FilterRecord { latitude: 34.0922222, longitude: -117.4341667, population: Some(169160), city: "Fontana", state: "CA" }
FilterRecord { latitude: 33.7091667, longitude: -117.9527778, population: Some(56133), city: "Fountain Valley", state: "CA" }
FilterRecord { latitude: 37.4283333, longitude: -121.9055556, population: Some(62636), city: "Milpitas", state: "CA" }
FilterRecord { latitude: 33.4269444, longitude: -117.6111111, population: Some(62272), city: "San Clemente", state: "CA" }
FilterRecord { latitude: 41.1669444, longitude: -73.2052778, population: Some(139090), city: "Bridgeport", state: "CT" }
FilterRecord { latitude: 34.0230556, longitude: -84.3616667, population: Some(77218), city: "Roswell", state: "GA" }
FilterRecord { latitude: 39.7683333, longitude: -86.1580556, population: Some(773283), city: "Indianapolis", state: "IN" }
FilterRecord { latitude: 45.12, longitude: -93.2875, population: Some(62528), city: "Coon Rapids", state: "MN" }
FilterRecord { latitude: 40.6686111, longitude: -74.1147222, population: Some(59878), city: "Bayonne", state: "NJ" }
FilterRecord { latitude: 45.4983333, longitude: -122.4302778, population: Some(98851), city: "Gresham", state: "OR" }
FilterRecord { latitude: 34.9247222, longitude: -81.0252778, population: Some(59766), city: "Rock Hill", state: "SC" }
FilterRecord { latitude: 26.3013889, longitude: -98.1630556, population: Some(60509), city: "Edinburg", state: "TX" }
FilterRecord { latitude: 32.8369444, longitude: -97.0816667, population: Some(53221), city: "Euless", state: "TX" }
FilterRecord { latitude: 26.1944444, longitude: -98.1833333, population: Some(60687), city: "Pharr", state: "TX" }





()

Cleaning CSV Files

Once you have a Record you can push it to a vector and then iterate over the vector to fix it. Deserialization doesn't quite work all that well when the fields themselves are malformed

:dep csv = { version = "^1.3" }
:dep serde = { version = "^1", features = ["derive"] }

// This lets us write `#[derive(Deserialize)]`.
use serde::Deserialize;

// We don't need to derive `Debug` (which doesn't require Serde), but it's a
// good habit to do it for all your types.
//
// Notice that the field names in this struct are NOT in the same order as
// the fields in the CSV data!
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct DirtyRecord {
    CustomerNumber: Option<u32>,
    CustomerName: String,
    S2016: Option<f64>,
    S2017: Option<f64>,
    PercentGrowth: Option<f64>,
    JanUnits:Option<u64>,
    Month: Option<u8>,
    Day: Option<u8>,
    Year: Option<u16>,
    Active: String,
}

let mut rdr = csv::Reader::from_path("sales_data_types.csv").unwrap();
let mut count = 0;
// Loop over each record.
for result in rdr.deserialize::<DirtyRecord>() {
    // An error may occur, so abort the program in an unfriendly way.
    // We will make this more friendly later!
    match result {
        Ok(record) => {
            // Print a debug version of the record.
            if count < 5 {
                println!("{:?}", record);
            }
            count += 1;
        },
        Err(err) => {
            println!("{}", err);
        }
    }
}

CSV deserialize error: record 1 (line: 2, byte: 85): field 0: invalid digit found in string
CSV deserialize error: record 2 (line: 3, byte: 161): field 2: invalid float literal
CSV deserialize error: record 3 (line: 4, byte: 236): field 2: invalid float literal
CSV deserialize error: record 4 (line: 5, byte: 305): field 2: invalid float literal
CSV deserialize error: record 5 (line: 6, byte: 370): field 2: invalid float literal





()

An alternative is to read everything as Strings and clean them up using String methods.

:dep csv = { version = "^1.3" }
:dep serde = { version = "^1", features = ["derive"] }

// This lets us write `#[derive(Deserialize)]`.
use serde::Deserialize;

// We don't need to derive `Debug` (which doesn't require Serde), but it's a
// good habit to do it for all your types.
//
// Notice that the field names in this struct are NOT in the same order as
// the fields in the CSV data!
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct DirtyRecord {
    CustomerNumber: String,
    CustomerName: String,
    S2016: String,
    S2017: String,
    PercentGrowth: String,
    JanUnits:String,
    Month: String,
    Day: String,
    Year: String,
    Active: String,
}

#[derive(Debug, Default)]
struct CleanRecord {
    CustomerNumber: u64,
    CustomerName: String,
    S2016: f64,
    S2017: f64,
    PercentGrowth: f32,
    JanUnits:u64,
    Month: u8,
    Day: u8,
    Year: u16,
    Active: bool,

}

fn cleanRecord(r: DirtyRecord) -> CleanRecord {
    let mut c = CleanRecord::default();
    c.CustomerNumber = r.CustomerNumber.trim_matches('"').parse::<f64>().unwrap() as u64;
    c.CustomerName = r.CustomerName.clone();
    c.S2016 = r.S2016.replace('$',"").replace(',',"").parse::<f64>().unwrap();
    c.S2017 = r.S2017.replace('$',"").replace(',',"").parse::<f64>().unwrap();
    c.PercentGrowth = r.PercentGrowth.replace('%',"").parse::<f32>().unwrap() / 100.0;
    let JanUnits = r.JanUnits.parse::<u64>();
    if JanUnits.is_ok() {
        c.JanUnits = JanUnits.unwrap();
    } else {
        c.JanUnits = 0;
    }
    c.Month = r.Month.parse::<u8>().unwrap();
    c.Day = r.Day.parse::<u8>().unwrap();
    c.Year = r.Year.parse::<u16>().unwrap();
    c.Active = if r.Active == "Y" { true } else {false};
    return c;
}

fn process_csv_file() -> Vec<CleanRecord> {
    let mut rdr = csv::Reader::from_path("sales_data_types.csv").unwrap();
    let mut v:Vec<DirtyRecord> = Vec::new();
    // Loop over each record.
    for result in rdr.deserialize::<DirtyRecord>() {
        // An error may occur, so abort the program in an unfriendly way.
        // We will make this more friendly later!
        match result {
            Ok(record) => {
                // Print a debug version of the record.
                println!("{:?}", record);
                v.push(record);
            },
            Err(err) => {
                println!("{}", err);
            }
        }
    }

    println!("");

    let mut cleanv: Vec<CleanRecord> = Vec::new();
    for r in v {
        let cleanrec = cleanRecord(r);
        println!("{:?}", cleanrec);
        cleanv.push(cleanrec);
    }
    return cleanv;
}

process_csv_file();
DirtyRecord { CustomerNumber: "10002.0", CustomerName: "QuestIndustries", S2016: "<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord">125</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">000.00&quot;</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal" style="margin-right:0.05764em;">S</span><span class="mord">2017</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord">&quot;</span></span></span></span>162500.00", PercentGrowth: "30.00%", JanUnits: "500", Month: "1", Day: "10", Year: "2015", Active: "Y" }
DirtyRecord { CustomerNumber: "552278", CustomerName: "SmithPlumbing", S2016: "<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord">920</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">000.00&quot;</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal" style="margin-right:0.05764em;">S</span><span class="mord">2017</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord">&quot;</span></span></span></span>101,2000.00", PercentGrowth: "10.00%", JanUnits: "700", Month: "6", Day: "15", Year: "2014", Active: "Y" }
DirtyRecord { CustomerNumber: "23477", CustomerName: "ACMEIndustrial", S2016: "<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord">50</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">000.00&quot;</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal" style="margin-right:0.05764em;">S</span><span class="mord">2017</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord">&quot;</span></span></span></span>62500.00", PercentGrowth: "25.00%", JanUnits: "125", Month: "3", Day: "29", Year: "2016", Active: "Y" }
DirtyRecord { CustomerNumber: "24900", CustomerName: "BrekkeLTD", S2016: "<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord">350</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">000.00&quot;</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal" style="margin-right:0.05764em;">S</span><span class="mord">2017</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord">&quot;</span></span></span></span>490000.00", PercentGrowth: "4.00%", JanUnits: "75", Month: "10", Day: "27", Year: "2015", Active: "Y" }
DirtyRecord { CustomerNumber: "651029", CustomerName: "HarborCo", S2016: "<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord">15</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">000.00&quot;</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal" style="margin-right:0.05764em;">S</span><span class="mord">2017</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord">&quot;</span></span></span></span>12750.00", PercentGrowth: "-15.00%", JanUnits: "Closed", Month: "2", Day: "2", Year: "2014", Active: "N" }

CleanRecord { CustomerNumber: 10002, CustomerName: "QuestIndustries", S2016: 125000.0, S2017: 162500.0, PercentGrowth: 0.3, JanUnits: 500, Month: 1, Day: 10, Year: 2015, Active: true }
CleanRecord { CustomerNumber: 552278, CustomerName: "SmithPlumbing", S2016: 920000.0, S2017: 1012000.0, PercentGrowth: 0.1, JanUnits: 700, Month: 6, Day: 15, Year: 2014, Active: true }
CleanRecord { CustomerNumber: 23477, CustomerName: "ACMEIndustrial", S2016: 50000.0, S2017: 62500.0, PercentGrowth: 0.25, JanUnits: 125, Month: 3, Day: 29, Year: 2016, Active: true }
CleanRecord { CustomerNumber: 24900, CustomerName: "BrekkeLTD", S2016: 350000.0, S2017: 490000.0, PercentGrowth: 0.04, JanUnits: 75, Month: 10, Day: 27, Year: 2015, Active: true }
CleanRecord { CustomerNumber: 651029, CustomerName: "HarborCo", S2016: 15000.0, S2017: 12750.0, PercentGrowth: -0.15, JanUnits: 0, Month: 2, Day: 2, Year: 2014, Active: false }

4. Let's convert the Vector of structs to an ndarray that can be fed into other libraries

Remember that ndarrays have to contain uniform data, so make sure the "columns" you pick are of the same type or you convert them appropriately.

:dep ndarray = { version = "^0.15.6" }
use ndarray::Array2;

let mut cleanv = process_csv_file();
let mut flat_values: Vec<f64> = Vec::new();
for s in &cleanv {
    flat_values.push(s.S2016);
    flat_values.push(s.S2017);
    flat_values.push(s.PercentGrowth as f64);
}
let array = Array2::from_shape_vec((cleanv.len(), 3), flat_values).expect("Error creating ndarray");
println!("{:?}", array);

CleanRecord { CustomerNumber: 10002, CustomerName: "QuestIndustries", S2016: 125000.0, S2017: 162500.0, PercentGrowth: 0.3, JanUnits: 500, Month: 1, Day: 10, Year: 2015, Active: true }
CleanRecord { CustomerNumber: 552278, CustomerName: "SmithPlumbing", S2016: 920000.0, S2017: 1012000.0, PercentGrowth: 0.1, JanUnits: 700, Month: 6, Day: 15, Year: 2014, Active: true }
CleanRecord { CustomerNumber: 23477, CustomerName: "ACMEIndustrial", S2016: 50000.0, S2017: 62500.0, PercentGrowth: 0.25, JanUnits: 125, Month: 3, Day: 29, Year: 2016, Active: true }
CleanRecord { CustomerNumber: 24900, CustomerName: "BrekkeLTD", S2016: 350000.0, S2017: 490000.0, PercentGrowth: 0.04, JanUnits: 75, Month: 10, Day: 27, Year: 2015, Active: true }
CleanRecord { CustomerNumber: 651029, CustomerName: "HarborCo", S2016: 15000.0, S2017: 12750.0, PercentGrowth: -0.15, JanUnits: 0, Month: 2, Day: 2, Year: 2014, Active: false }
DirtyRecord { CustomerNumber: "10002.0", CustomerName: "QuestIndustries", S2016: "<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord">125</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">000.00&quot;</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal" style="margin-right:0.05764em;">S</span><span class="mord">2017</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord">&quot;</span></span></span></span>162500.00", PercentGrowth: "30.00%", JanUnits: "500", Month: "1", Day: "10", Year: "2015", Active: "Y" }
DirtyRecord { CustomerNumber: "552278", CustomerName: "SmithPlumbing", S2016: "<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord">920</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">000.00&quot;</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal" style="margin-right:0.05764em;">S</span><span class="mord">2017</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord">&quot;</span></span></span></span>101,2000.00", PercentGrowth: "10.00%", JanUnits: "700", Month: "6", Day: "15", Year: "2014", Active: "Y" }
DirtyRecord { CustomerNumber: "23477", CustomerName: "ACMEIndustrial", S2016: "<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord">50</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">000.00&quot;</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal" style="margin-right:0.05764em;">S</span><span class="mord">2017</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord">&quot;</span></span></span></span>62500.00", PercentGrowth: "25.00%", JanUnits: "125", Month: "3", Day: "29", Year: "2016", Active: "Y" }
DirtyRecord { CustomerNumber: "24900", CustomerName: "BrekkeLTD", S2016: "<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord">350</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">000.00&quot;</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal" style="margin-right:0.05764em;">S</span><span class="mord">2017</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord">&quot;</span></span></span></span>490000.00", PercentGrowth: "4.00%", JanUnits: "75", Month: "10", Day: "27", Year: "2015", Active: "Y" }
DirtyRecord { CustomerNumber: "651029", CustomerName: "HarborCo", S2016: "<span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord">15</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">000.00&quot;</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal" style="margin-right:0.05764em;">S</span><span class="mord">2017</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord">&quot;</span></span></span></span>12750.00", PercentGrowth: "-15.00%", JanUnits: "Closed", Month: "2", Day: "2", Year: "2014", Active: "N" }

If your data does not need cleaning

This is not likely, but sometimes data preprocessing happens in other environments and you are given a clean file to work with. Or you clean the data once and use it to train many different models. There is a crate that lets you go directly from csv to ndarray!

https://docs.rs/ndarray-csv/latest/ndarray_csv/

:dep csv = { version = "^1.3.1" }
:dep ndarray = { version = "^0.15.6" }
:dep ndarray-csv = { version = "^0.5.3" }

extern crate ndarray;
extern crate ndarray_csv;

use csv::{ReaderBuilder, WriterBuilder};
use ndarray::{array, Array2};
use ndarray_csv::{Array2Reader, Array2Writer};
use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    // Our 2x3 test array
    let array = array![[1, 2, 3], [4, 5, 6]];

    // Write the array into the file.
    {
        let file = File::create("test.csv")?;
        let mut writer = WriterBuilder::new().has_headers(false).from_writer(file);
        writer.serialize_array2(&array)?;
    }

    // Read an array back from the file
    let file = File::open("test2.csv")?;
    let mut reader = ReaderBuilder::new().has_headers(true).from_reader(file);
    let array_read: Array2<u64> = reader.deserialize_array2((2, 3))?;

    // Ensure that we got the original array back
    assert_eq!(array_read, array);
    println!("{:?}", array_read);
    Ok(())
}

main();
[E0308] Error: mismatched types

    ╭─[command_37:1:1]

    │

 22 │         writer.serialize_array2(&array)?;

    │                ────────┬─────── ───┬──  

    │                        ╰──────────────── arguments to this method are incorrect

    │                                    │    

    │                                    ╰──── expected `&ArrayBase<OwnedRepr<_>, Dim<...>>`, found `&ArrayBase<OwnedRepr<...>, ...>`

    │ 

    │ Note: note: method defined here

────╯



[E0308] Error: `?` operator has incompatible types

    ╭─[command_37:1:1]

    │

 28 │     let array_read: Array2<u64> = reader.deserialize_array2((2, 3))?;

    │                                   ─────────────────┬────────────────  

    │                                                    ╰────────────────── expected `ArrayBase<OwnedRepr<u64>, Dim<...>>`, found `ArrayBase<OwnedRepr<_>, Dim<...>>`

────╯

Technical Coding Challenge

Coding Challenge

Coding Challenge Review

Decision trees.

About This Module

Prework

Prework Reading

Pre-lecture Reflections

Lecture

Learning Objectives

Machine learning: supervised vs. unsupervised

Supervised

  • Labeled data
    • Example 1: images labeled with the objects: cat, dog, monkey, elephant, etc.
    • Example 2: medical data labeled with likelihood of cancer
  • Goal: discover a relationship between attributes to predict unknown labels

Unsupervised

  • Unlabeled data
  • Want to discover a relationship between data points
  • Examples:
    • clustering: partition your data into groups of similar objects
    • dimension reduction: for high dimensional data discover important attributes

Machine learning: Predictive vs descriptive vs prescriptive analytics

Descriptive

Use our data to explain what has happened in the past (i.e. find patterns in data that has already been observed)

Predictive

Use our data to predict what may happen in the future (i.e. apply the observed patterns to new observations and predict outcomes)

Prescriptive

Use our data and model to inform decisions we can make to achieve certain outcomes. This assumes a certain level of control over the data inputs to whatever process is being modeled. If no such control exists then prescriptive is not possible.

Supervised learning: Decision trees

Popular machine learning tool for predictive data analysis:

  • rooted tree
  • start at the root and keep going down
  • every internal node labeled with a condition
    • if satisfied, go left
    • if not satisfied, go right
  • leafs labeled with predicted labels

Does a player like bluegrass?

Drawing
Big challenge: finding a decision tree that matches data!
// First lets read in the sample data

:dep csv = { version = "^1.3" }
:dep serde = { version = "^1", features = ["derive"] }
:dep ndarray = { version = "^0.15.6" }
use ndarray::Array2;

// This lets us write `#[derive(Deserialize)]`.
use serde::Deserialize;

// We don't need to derive `Debug` (which doesn't require Serde), but it's a
// good habit to do it for all your types.
//
// Notice that the field names in this struct are NOT in the same order as
// the fields in the CSV data!
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct SerRecord {
    name: String,
    number: usize,
    year_born: usize,
    total_points: usize,
    PPG: f64,
}

let mut rdr = csv::Reader::from_path("players.csv").unwrap();
let mut v:Vec<SerRecord> = Vec::new();
// Loop over each record.
for result in rdr.deserialize() {
    // An error may occur, so abort the program in an unfriendly way.
    // We will make this more friendly later!
    let record:SerRecord = result.expect("a CSV record");
    v.push(record);
}
println!("{:#?}", v);
[
    SerRecord {
        name: "Kareem",
        number: 33,
        year_born: 1947,
        total_points: 38387,
        PPG: 24.6,
    },
    SerRecord {
        name: "Karl",
        number: 32,
        year_born: 1963,
        total_points: 36928,
        PPG: 25.0,
    },
    SerRecord {
        name: "LeBron",
        number: 23,
        year_born: 1984,
        total_points: 36381,
        PPG: 27.0,
    },
    SerRecord {
        name: "Kobe",
        number: 24,
        year_born: 1978,
        total_points: 33643,
        PPG: 25.0,
    },
    SerRecord {
        name: "Michael",
        number: 23,
        year_born: 1963,
        total_points: 32292,
        PPG: 30.1,
    },
]

Heuristics for constructing decision trees -- I

  • Start from a single node with all samples
  • Iterate:
    • select a node
    • use the samples in the node to split it into children using some splitting criteria
    • pass each sample to respective child
  • Label leafs

Let's try to predict what a player's favorite color is?

[Decision tree]

Heuristics for constructing decision trees -- II

  • Start from a single node with all samples
  • Iterate:
    • select a node
    • use the samples in the node to split it into children using some splitting criteria
    • pass each sample to respective child
  • Label leafs

We'll split on PPG.

The goal is to have each leaf be a single class.

Favorite color?

[Decision tree]

Heuristics for constructing decision trees -- III

  • Start from a single node with all samples
  • Iterate:
    • select a node
    • use the samples in the node to split it into children using some splitting criteria
    • pass each sample to respective child
  • Label leafs
Favorite color? [Decision tree]

Heuristics for constructing decision trees -- IV

  • Start from a single node with all samples
  • Iterate:
    • select a node
    • use the samples in the node to split it into children
    • pass each sample to respective child
  • Label leafs

Favorite color?

[Decision tree]

Split selection

  • Typical heuristic: select a split that improves classification most
  • Various measures of "goodness" or "badness":
    • Information gain / Entropy
    • Ginni impurity
    • Variance
  • ID3
  • C4.5
  • C5.0
  • CART (used by linfa-trees, rustlearn, and scikit-learn)

You can read more about those algorithms at https://scikit-learn.org/stable/modules/tree.html#tree-algorithms-id3-c4-5-c5-0-and-cart

and see the mathematical formulation for CART here: https://scikit-learn.org/stable/modules/tree.html#mathematical-formulation

The Gini coefficient and Entropy (Impurity Measures)

  • Let's assume we have k classes that we are trying to decide.

  • We can estimate the probability by:

  • A node M containing N samples has a Gini coefficient defined as follows:

  • Or entropy defined by:

Advantages and disadvantages of decision trees

Advantages:

  • easy to interpret
  • not much data preparation needed
  • categorical and numerical data
  • relatively fast

Disadvantages:

  • can be very sensitive to data changes
  • can create an overcomplicated tree that matches the sample, but not the underlying problem
  • hard to find an optimal tree

Decision tree construction using linfa-tree

https://rust-ml.github.io/linfa/, https://crates.io/crates/linfa

https://docs.rs/linfa-trees/latest/linfa_trees/

Note: ignore machine learning context for now

First, we read our sample data and add information who likes pizza

Visualized

// Rust 2021
//:dep plotters={version = "^0.3.0", default_features = false, features = ["evcxr", "all_series"]}

// Rust 2024
:dep plotters={version = "^0.3.0", default-features = false, features = ["evcxr", "all_series"]}

:dep csv = { version = "^1.3" }
:dep serde = { version = "^1", features = ["derive"] }
:dep ndarray = { version = "^0.15.6" }
use ndarray::Array2;

// This lets us write `#[derive(Deserialize)]`.
use serde::Deserialize;

// We don't need to derive `Debug` (which doesn't require Serde), but it's a
// good habit to do it for all your types.
//
// Notice that the field names in this struct are NOT in the same order as
// the fields in the CSV data!
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct SerRecord {
    name: String,
    number: usize,
    year_born: usize,
    total_points: usize,
    PPG: f64,
}

let mut rdr = csv::Reader::from_path("players.csv").unwrap();
let mut v:Vec<SerRecord> = Vec::new();
// Loop over each record.
for result in rdr.deserialize() {
    // An error may occur, so abort the program in an unfriendly way.
    // We will make this more friendly later!
    let record:SerRecord = result.expect("a CSV record");
    v.push(record);
}

let mut flat_values: Vec<f64> = Vec::new();
for s in &v {
    flat_values.push(s.total_points as f64);
    flat_values.push(s.PPG);
    flat_values.push(s.year_born as f64);
    flat_values.push(s.number as f64);
}
let array = Array2::from_shape_vec((v.len(), 4), flat_values).expect("Error creating ndarray");
println!("{:?}", array);

let likes_pizza = [1,0,0,1,0];

extern crate plotters;
use plotters::prelude::*;
{
let x_values = array.column(0);
let y_values = array.column(1);

evcxr_figure((800, 800), |root| {
    let mut chart = ChartBuilder::on(&root)
    // the caption for the chart
        .caption("Scatter Plot", ("Arial", 20).into_font())
        .x_label_area_size(40)
        .y_label_area_size(40)
        .build_cartesian_2d(32000f64..39000f64, 24f64..31f64)?;
   // the X and Y coordinates spaces for the chart
    chart.configure_mesh()
        .x_desc("Total Points")
        .y_desc("PPG")
        .draw()?;

    chart.draw_series(
            x_values.iter()
                .zip(y_values.iter())
                .zip(likes_pizza.iter())
                .map(|((total, ppg), likes)| {
                    let point = (*total, *ppg);
                    let size = 20;
                    let color = Palette99::pick(*likes as usize % 10); // Choose color based on 'LikesPizza'
                    Circle::new(point, size as i32, color.filled())
                })
        )?;

    Ok(())
})}
The type of the variable v was redefined, so was lost.


[[38387.0, 24.6, 1947.0, 33.0],
 [36928.0, 25.0, 1963.0, 32.0],
 [36381.0, 27.0, 1984.0, 23.0],
 [33643.0, 25.0, 1978.0, 24.0],
 [32292.0, 30.1, 1963.0, 23.0]], shape=[5, 4], strides=[4, 1], layout=Cc (0x5), const ndim=2
Scatter Plot PPG Total Points 24.0 25.0 26.0 27.0 28.0 29.0 30.0 31.0 32000.0 33000.0 34000.0 35000.0 36000.0 37000.0 38000.0 39000.0

Question:

If we were to try to split RED and GREEN based on PPG, where would we split?

Data selection

  • set of inputs: X
  • set of desired outputs:y

Decision tree construction

  • How to decide which feature should be located at the root node,
  • Most accurate feature to serve as internal nodes or leaf nodes,
  • How to divide tree,
  • How to measure the accuracy of splitting tree and many more.

First the preamble with all the dependencies and the code to read the CSV file

//:dep plotters={version = "^0.3.0", default_features = false, features = ["evcxr", "all_series"]}
:dep csv = { version = "^1.3" }
:dep serde = { version = "^1", features = ["derive"] }
:dep ndarray = { version = "^0.15.6" }
:dep linfa = { git = "https://github.com/rust-ml/linfa" }
:dep linfa-trees = { git = "https://github.com/rust-ml/linfa" }


use ndarray::Array2;
use ndarray::array;
use linfa_trees::DecisionTree;
use linfa::prelude::*;
// This lets us write `#[derive(Deserialize)]`.
use serde::Deserialize;
use std::fs::File;
use std::io::Write;

// We don't need to derive `Debug` (which doesn't require Serde), but it's a
// good habit to do it for all your types.
//
// Notice that the field names in this struct are NOT in the same order as
// the fields in the CSV data!
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct SerRecord {
    name: String,
    number: usize,
    year_born: usize,
    total_points: usize,
    PPG: f64,
}

fn process_csv_file() -> Vec<SerRecord> {
  let mut rdr = csv::Reader::from_path("players.csv").unwrap();
  let mut v:Vec<SerRecord> = Vec::new();
  // Loop over each record.
  for result in rdr.deserialize() {
    // An error may occur, so abort the program in an unfriendly way.
    // We will make this more friendly later!
    let record:SerRecord = result.expect("a CSV record");
    v.push(record);
  }
  return v;
}


And the code to construct, train and measure a decision tree

We use the linfa Dataset:

  • The most commonly used typed of dataset.
  • It contains a number of records stored as an Array2 and
  • each record may correspond to multiple targets.
  • The targets are stored as an Array1 or Array2.

And construct a DecisionTree structure.

Then export to a TeX file and render the tree.

fn main() {
  let mut v = process_csv_file();
  let mut flat_values: Vec<f64> = Vec::new();
  for s in &v {
    flat_values.push(s.total_points as f64);
    flat_values.push(s.PPG);
    flat_values.push(s.year_born as f64);
  }
  let array = Array2::from_shape_vec((v.len(), 3), flat_values).expect("Error creating ndarray");

  let likes_pizza = array![1,0,0,1,0];

  let dataset = Dataset::new(array, likes_pizza).with_feature_names(vec!["total points", "PPG", "year born"]);
  let decision_tree = DecisionTree::params()
        .max_depth(Some(2))
        .fit(&dataset)
        .unwrap();

  let accuracy = decision_tree.predict(&dataset).confusion_matrix(&dataset).unwrap().accuracy();
    
  println!("The accuracy is: {:?}", accuracy);

  let mut tikz = File::create("decision_tree_example.tex").unwrap();
    tikz.write_all(
        decision_tree
            .export_to_tikz()
            .with_legend()
            .to_string()
            .as_bytes(),
    )
    .unwrap();
    println!(" => generate tree description with `latex decision_tree_example.tex`!");
}

main();
The type of the variable rdr was redefined, so was lost.
The type of the variable array was redefined, so was lost.


The accuracy is: 0.8
 => generate tree description with `latex decision_tree_example.tex`!
use std::process::Command;
let output = Command::new("pwd").output().expect("Failed to execute");
let output = Command::new("pdflatex").arg("decision_tree_example.tex").output().expect("Failed to execute");
let output = Command::new("sips").args(["-s", "format", "png", "decision_tree_example.pdf", "--out", "decision_tree_example.png"])
.output().expect("Failed to execute");
Updated Image

"Impurity" is another term for Gini index.

Note that Impurity decreases as we do more splits.

Also note that we can overfit the dataset. Will do worse on new data.

Techniques to avoid overfitting include setting a maximum tree depth.

Technical Coding Challenge

Coding Challenge

Coding Challenge Review

Linear Regression, Loss, and Bias

About This Module

Prework

Prework Reading

Pre-lecture Reflections

Lecture

Learning Objectives

Simplest setting

Input: set of points in

  • What function explains the relationship of 's with 's?

  • What linear function describes it best?

Multivariate version

Input: set of points in

Find linear function that describes 's in terms of 's?

Why linear regression?

  • Have to assume something!
  • Models hidden linear relationship + noise

Typical objective: minimize square error

  • Points rarely can be described exactly using a linear relationship

  • How to decide between several non-ideal options?

  • Typically want to find that minimizes total square error:

What about Categorical Data?

  • Convert to numerical first
  • One way is to simply convert to unique integers
  • A better way is to create N new columns (one for each category) and make them boolean (is_x, is_y, is_z etc) -- one hot encoding

For example with 3 categories:

1-D Example

We'll start with an example from the Rust Machine Learning Book.

// Use a crate built from source on github
:dep linfa-book = { git = "https://github.com/rust-ml/book" }
:dep ndarray = { version = "^0.15.6" }

// create_curve implemented at https://github.com/rust-ml/book/blob/main/src/lib.rs#L52
use linfa_book::create_curve;
use ndarray::Array2;
use ndarray::s;

fn generate_data(output: bool) -> Array2<f64> {

    /*
     * Generate a dataset of x and y values
     * 
     * - Randomly generate 50 points between 0 and 7
     * - Calculate y = m * x^power + b +noise
     *   where noise is uniformly random between -0.5 and 0.5
     * 
     * m = 1.0  (slope)
     * power = 1.0  (straight line)
     * b = 0.0 (y-intercept)
     * num_points = 50
     * x_range = [0.0, 7.0]
     * 
     * This produces a 50x2 Array2<f64> with the first column being x and the
     * second being y
     */
    let array: Array2<f64> = linfa_book::create_curve(1.0, 1.0, 0.0, 50, [0.0, 7.0]);

    // Converting from an array to a Linfa Dataset can be the trickiest part of this process
    // The first part has to be an array of arrays even if they have a single entry for the 1-D case
    // The underlying type is kind of ugly but thankfully the compiler figures it out for us
    // let data: ArrayBase<OwnedRepr<f64>, Ix2> = ....  is the actual type
    // The second part has to be an array of values.
    // let targets: Array1<f64> is the actual type


    let data = array.slice(s![.., 0..1]).to_owned();
    let targets = array.column(1).to_owned();
    if output {
        println!("The original array is:");
        println!("{:.2?}", array);

        println!("The data is:");
        println!("{:.2?}", data);

        println!("The targets are:");
        println!("{:.2?}", targets);

    }
    return array;
}

generate_data(true);
The original array is:
[[6.59, 6.85],
 [2.49, 2.39],
 [0.77, 0.32],
 [1.85, 2.25],
 [2.13, 2.46],
 [0.93, 0.60],
 [6.17, 6.51],
 [1.91, 2.02],
 [2.82, 3.20],
 [0.55, 0.23],
 [4.31, 4.06],
 [4.04, 3.54],
 [6.15, 6.51],
 [0.10, -0.33],
 [0.68, 0.77],
 [1.03, 1.18],
 [0.51, 0.68],
 [3.75, 3.38],
 [1.59, 1.57],
 [5.77, 6.26],
 [1.18, 1.15],
 [2.99, 2.99],
 [4.33, 4.69],
 [1.36, 1.52],
 [5.20, 5.67],
 [6.18, 6.41],
 [6.10, 6.46],
 [6.82, 6.68],
 [4.27, 3.88],
 [1.67, 1.71],
 [5.42, 5.62],
 [2.47, 2.02],
 [4.48, 4.95],
 [1.82, 2.27],
 [5.53, 5.27],
 [0.52, 0.53],
 [5.63, 5.35],
 [0.13, 0.43],
 [4.47, 4.83],
 [0.58, 1.00],
 [3.13, 3.28],
 [6.84, 6.69],
 [6.46, 6.15],
 [6.04, 6.27],
 [5.63, 6.12],
 [1.60, 1.86],
 [0.18, 0.08],
 [6.24, 6.70],
 [0.20, 0.06],
 [4.44, 4.10]], shape=[50, 2], strides=[2, 1], layout=Cc (0x5), const ndim=2
The data is:
[[6.59],
 [2.49],
 [0.77],
 [1.85],
 [2.13],
 [0.93],
 [6.17],
 [1.91],
 [2.82],
 [0.55],
 [4.31],
 [4.04],
 [6.15],
 [0.10],
 [0.68],
 [1.03],
 [0.51],
 [3.75],
 [1.59],
 [5.77],
 [1.18],
 [2.99],
 [4.33],
 [1.36],
 [5.20],
 [6.18],
 [6.10],
 [6.82],
 [4.27],
 [1.67],
 [5.42],
 [2.47],
 [4.48],
 [1.82],
 [5.53],
 [0.52],
 [5.63],
 [0.13],
 [4.47],
 [0.58],
 [3.13],
 [6.84],
 [6.46],
 [6.04],
 [5.63],
 [1.60],
 [0.18],
 [6.24],
 [0.20],
 [4.44]], shape=[50, 1], strides=[1, 1], layout=CFcf (0xf), const ndim=2
The targets are:
[6.85, 2.39, 0.32, 2.25, 2.46, 0.60, 6.51, 2.02, 3.20, 0.23, 4.06, 3.54, 6.51, -0.33, 0.77, 1.18, 0.68, 3.38, 1.57, 6.26, 1.15, 2.99, 4.69, 1.52, 5.67, 6.41, 6.46, 6.68, 3.88, 1.71, 5.62, 2.02, 4.95, 2.27, 5.27, 0.53, 5.35, 0.43, 4.83, 1.00, 3.28, 6.69, 6.15, 6.27, 6.12, 1.86, 0.08, 6.70, 0.06, 4.10], shape=[50], strides=[1], layout=CFcf (0xf), const ndim=1

Let's plot these points to see what they look like

// 2021
//:dep plotters={version = "^0.3.0", default_features = false, features = ["evcxr", "all_series"]}

// 2024
:dep plotters={version = "^0.3.0", default-features = false, features = ["evcxr", "all_series"]}

:dep ndarray = { version = "^0.15.6" }

extern crate plotters;
use plotters::prelude::*;

{
let array: Array2<f64> = generate_data(false);

let x_values = array.column(0);
let y_values = array.column(1);

evcxr_figure((640, 480), |root| {
    let mut chart = ChartBuilder::on(&root)
    // the caption for the chart
        .caption("2-D Plot", ("Arial", 20).into_font())
        .x_label_area_size(40)
        .y_label_area_size(40)
   // the X and Y coordinates spaces for the chart
        .build_cartesian_2d(0f64..8f64, 0f64..8f64)?;
    chart.configure_mesh()
        .x_desc("X Values")
        .y_desc("Y Values")
        .draw()?;

    chart.draw_series(
        x_values.iter().zip(y_values.iter()).map(|(&x, &y)| {
            Circle::new((x, y), 3, RED.filled())
        }),
    )?;
    Ok(())
})
}

2-D Plot Y Values X Values 0.0 1.0 2.0 3.0 4.0 5.0 6.0 7.0 8.0 0.0 1.0 2.0 3.0 4.0 5.0 6.0 7.0 8.0

Let's fit a linear regression to it

// There are multiple packages in this git repository so we have to declare
// all the ones we care about
:dep linfa = { git = "https://github.com/rust-ml/linfa" }
:dep linfa-linear = { git = "https://github.com/rust-ml/linfa" }
:dep ndarray = { version = "^0.15.6" }

// Now we need to declare which function we are going to use
use linfa::Dataset;
use linfa_linear::LinearRegression;
use linfa_linear::FittedLinearRegression;
use linfa::prelude::*;

fn fit_model(array: &Array2<f64>) -> FittedLinearRegression<f64> {
    // Let's regenarate and split our data
    let data = array.slice(s![.., 0..1]).to_owned();
    let targets = array.column(1).to_owned();

    // And finally let's fit a linear regression to it
    println!("Data: {:?} \n Targets: {:?}", data, targets);
    let dataset = Dataset::new(data, targets);
    let lin_reg: LinearRegression = LinearRegression::new();
    let model: FittedLinearRegression<f64> = lin_reg.fit(&dataset).unwrap();
    
    let ypred = model.predict(&dataset);
    let loss = (dataset.targets() - ypred)
        .mapv(|x| x.abs())
        .mean();

    println!("{:?}", loss);
    return model;
}

let array: Array2<f64> = generate_data(false);
let model = fit_model(&array);
println!("{:?}", model);

Data: [[6.381616680690672],
 [4.396361662331629],
 [4.3324365575773305],
 [0.8289740051702756],
 [6.319103537997485],
 [0.3335321841860137],
 [5.095836624614474],
 [3.6492182454480435],
 [1.3364352957815282],
 [0.09639507860613405],
 [5.303183512825285],
 [4.2951694397460285],
 [5.4057438081138915],
 [0.10960286365816452],
 [4.603044369250634],
 [4.024864366257263],
 [4.281935499419392],
 [4.06157301198745],
 [5.755333890324657],
 [1.3359866701598488],
 [2.3904258537567324],
 [4.029842664366427],
 [2.2848232403062685],
 [0.12639520987084052],
 [2.9318196797275835],
 [3.4846905959349144],
 [2.1132653187529598],
 [2.409170009823532],
 [1.294104964714346],
 [3.068189601192284],
 [1.5698399649061598],
 [4.585070111032277],
 [0.9080582927284744],
 [2.3500918290692145],
 [5.412834219057823],
 [0.5732492542928671],
 [3.1955234173749907],
 [0.6011335403952247],
 [0.06756207483950094],
 [0.4973826225929374],
 [3.5708462015304976],
 [5.016449358044835],
 [6.275456045849482],
 [0.24134187530345663],
 [0.19382724586379352],
 [3.900026524938423],
 [1.977100227563272],
 [4.934898600624889],
 [3.970926362145382],
 [1.5789373737594155]], shape=[50, 1], strides=[1, 1], layout=CFcf (0xf), const ndim=2 
 Targets: [6.74548949826222, 4.178753589022698, 3.9296433463393803, 0.9101170752817256, 6.422143609882557, -0.115321608937899, 5.126622670184417, 4.001921017212379, 1.2316076794171344, 0.3251034025687174, 5.291327977564027, 4.740353143641885, 5.005473769290836, -0.3802551719385241, 4.723355959069144, 3.740167334494597, 3.869317273792087, 3.583182714685261, 5.960145733182379, 1.4154622808579058, 2.395483563478454, 3.780181818550531, 2.195179330427658, -0.0780379205920585, 3.3559033104968132, 3.595446926448168, 1.9151631312465982, 2.5817866309609263, 1.1115468223706244, 3.2978733650316716, 1.7303235376317656, 4.577920815363182, 0.7328128405941892, 1.8555370714021548, 5.259719826144993, 0.46486256898256517, 3.661623687684834, 0.24442021204114717, 0.26510910851412994, 0.8471086226984335, 3.373961998065203, 4.696005829425596, 5.854185825360939, 0.3863210554131675, 0.29817841644956, 3.7047013726196827, 2.027075695584828, 5.171080561424032, 3.949876872423843, 2.0560366719493564], shape=[50], strides=[1], layout=CFcf (0xf), const ndim=1
Some(0.23628401752752)
FittedLinearRegression { intercept: -0.016055504752757018, params: [0.9953569138666288], shape=[1], strides=[1], layout=CFcf (0xf), const ndim=1 }

Finally let's put everything together and plot the data points and model line

extern crate plotters;
use plotters::prelude::*;

{    
let array: Array2<f64> = generate_data(false);
let model = fit_model(&array);
println!("{:?}", model);

let x_values = array.column(0);
let y_values = array.column(1);

evcxr_figure((640, 480), |root| {
    let mut chart = ChartBuilder::on(&root)
    // the caption for the chart
        .caption("Linear Regression", ("Arial", 20).into_font())
        .x_label_area_size(40)
        .y_label_area_size(40)
    // the X and Y coordinates spaces for the chart
        .build_cartesian_2d(0f64..8f64, 0f64..8f64)?;
    chart.configure_mesh()
        .x_desc("X Values")
        .y_desc("Y Values")
        .draw()?;

    chart.draw_series(
        x_values.iter().zip(y_values.iter()).map(|(&x, &y)| {
            Circle::new((x, y), 3, RED.filled())
        }),
    )?;
    let mut line_points = Vec::with_capacity(2);
    for i in (0..8i32).step_by(1) {
        line_points.push((i as f64, (i as f64 * model.params()[0]) + model.intercept()));
    }
    // We can configure the rounded precision of our result here
    let precision = 2;
    let label = format!(
        "y = {:.2$}x + {:.2}",
        model.params()[0],
        model.intercept(),
        precision
    );
    chart.draw_series(LineSeries::new(line_points, &BLACK))
        .unwrap()
        .label(&label);

    Ok(())
})
    
}
Data: [[2.04750415763924],
 [6.367873757109327],
 [6.398058085002853],
 [2.768792455803591],
 [0.1718068124456904],
 [5.410322501484772],
 [1.16759081573306],
 [2.7536979999018953],
 [4.950802437871839],
 [0.6025334876065953],
 [3.2764831065673183],
 [1.721556072918826],
 [2.944603890742717],
 [1.1679420030984646],
 [1.4583480833464897],
 [5.603000967206449],
 [0.7792958771030705],
 [0.9903895595193106],
 [5.849216682555504],
 [5.039598826115972],
 [6.79519856325383],
 [5.9811481574554595],
 [5.848135244005015],
 [3.2303889196020474],
 [5.180333900515606],
 [6.441584502170909],
 [2.1595411034915752],
 [0.7565248539761933],
 [6.575497606063534],
 [4.5995576454693055],
 [4.3608367164107875],
 [5.548520783489524],
 [6.525459030458023],
 [0.6937753120972134],
 [1.9522048348466015],
 [2.2341967770269227],
 [3.637038438411855],
 [2.2190157648753184],
 [2.681992233899634],
 [5.566572415187922],
 [0.9357604528754049],
 [0.8490168837359822],
 [6.448593818753438],
 [2.6758983352983177],
 [1.049091617939221],
 [1.5851990026054852],
 [6.098462409705635],
 [2.585742190451155],
 [0.17671676593123875],
 [0.5246679878163918]], shape=[50, 1], strides=[1, 1], layout=CFcf (0xf), const ndim=2 
 Targets: [2.408804027014255, 6.469323578131448, 6.193104604093066, 2.418915373474102, 0.029226368788591195, 4.9972110392473255, 1.1150716816343442, 2.926060993136029, 4.490694341852246, 0.9502150297756105, 3.4740768014974783, 1.251263397177235, 3.4148633041576995, 1.4681943013079881, 0.9890758429931192, 5.391821032945574, 1.211034580394566, 1.3854290271879017, 5.431409730804429, 4.699669709042999, 6.998217208006695, 6.109111375789134, 5.75094664694154, 3.658429812887407, 5.289801849698538, 6.105335877939171, 1.8358314904060669, 0.36311224569624856, 6.342541141255715, 4.691208846854487, 4.250255794021059, 5.675447984854635, 6.793126113768862, 0.5931705314387281, 2.111835341368409, 2.144381384764876, 3.8510929693234646, 1.7549171320182217, 2.563236265429576, 5.214375657851614, 0.8020824408564826, 0.7138795774936122, 6.183001994443131, 2.633962608410515, 1.4663428201689852, 1.6261732010691712, 6.5133103394465035, 2.1115834556736774, 0.5083760324119229, 0.7979776364877389], shape=[50], strides=[1], layout=CFcf (0xf), const ndim=1
Some(0.25864023660929314)
FittedLinearRegression { intercept: 0.05790152043058407, params: [0.9754302443444729], shape=[1], strides=[1], layout=CFcf (0xf), const ndim=1 }
Linear Regression Y Values X Values 0.0 1.0 2.0 3.0 4.0 5.0 6.0 7.0 8.0 0.0 1.0 2.0 3.0 4.0 5.0 6.0 7.0 8.0
  • params
  • intercept in

Coefficient of determination (or )

  • How good is my function ?

  • Input: points

  • Idea: Compare variance of 's to the deviation of 's from 's

  • Formally:

where

  • Range:     (should be in for linear regression)

Let's compute for our data

:dep linfa = { git = "https://github.com/rust-ml/linfa" }
:dep linfa-linear = { git = "https://github.com/rust-ml/linfa" }
:dep ndarray = { version = "^0.15.6" }
:dep ndarray-stats = { version = "^0.5.1" }

let array: Array2<f64> = generate_data(false);
let data = array.slice(s![.., 0..1]).to_owned();
let targets = array.column(1).to_owned();

// And finally let's fit a linear regression to it
let dataset = Dataset::new(data, targets).with_feature_names(vec!["x"]);
let lin_reg: LinearRegression = LinearRegression::new();
let model: FittedLinearRegression<f64> = lin_reg.fit(&dataset).unwrap();    
let ypred = model.predict(&dataset);
//
let variance:f64 = dataset.targets().var(0.0);
let mse:f64 = (dataset.targets() - ypred)
    .mapv(|x| x.powi(2))
        .mean().unwrap();
let r2 = 1.0 - mse/variance;
println!("variance = {:.3}, mse = {:.3}, R2 = {:.3}", variance, mse, r2);
variance = 2.971, mse = 0.080, R2 = 0.973

Multivariable linear regression

What if you have multiple input variables and one output variable?

It's actually quite simple. The same code we used above but make sure your X side contains multiple values for each of the variables in your function. The code will compute as many coefficients as the variables it sees.

For 3-D input:

:dep linfa = { git = "https://github.com/rust-ml/linfa" }
:dep linfa-linear = { git = "https://github.com/rust-ml/linfa" }
:dep ndarray = { version = "^0.15.6" }

use linfa::Dataset;
use linfa::traits::Fit;
use ndarray::{Array1, Array2, array};
use linfa_linear::LinearRegression;
use linfa_linear::FittedLinearRegression;
use linfa::prelude::*;


fn main() {
    // Example data: 4 samples with 3 features each
    let x: Array2<f64> = array![[1.0, 2.0, 3.0],
                                //[2.0, 3.0, 1.0],
                                [2.0, 3.0, 4.0],
                                [3.0, 4.0, 5.0],
                                [4.0, 5.0, 6.0],
                                //[6.0, 11.0, 12.0]
                                ];
    // Target values
    let y: Array1<f64> = array![6.0, 
                                //6.0, 
                                9.0, 
                                12.0, 
                                15.0, 
                                //29.0,
        ];

    // Create dataset
    let dataset = Dataset::new(x.clone(), y.clone());

    // Fit linear regression model
    let lin_reg = LinearRegression::new();
    let model = lin_reg.fit(&dataset).unwrap();

    // Print coefficients
    println!("Coefficients: {:.3?}", model.params());
    println!("Intercept: {:.3?}", model.intercept());

    // Predict using the fitted model
    let predictions = model.predict(&x);
    println!("Predictions: {:.3?}", predictions);
}

main();

Coefficients: [22.667, -8.333, -11.333], shape=[3], strides=[1], layout=CFcf (0xf), const ndim=1
Intercept: 34.000
Predictions: [6.000, 9.000, 12.000, 15.000], shape=[4], strides=[1], layout=CFcf (0xf), const ndim=1

General Least Squares Fit

We can generalize the ordinary least squares regression problem, by trying, for example, to find the in an equation like

to minize the differences between some targets and the .

Note that these are still linear in the parameters.

We can rewrite this in matrix form:

where the matrix of 's is sometimes called the Design Matrix.

  • We are going to use a different library familar to us from the ndarray lecture.

  • ndarray-linalg can compute the parameters to fit an arbitrary function as long as you have some idea of what the function might be.

    • e.g. express it as a design matrix
  • Then solve a system of linear equations with that matrix as the left hand side and our observed values as the right hand side.

  • The result is the missing parameters of our assumed function

:dep ndarray = { version = "^0.15.6" }

// See ./README.md for ndarray-linalg prereqs

// This is the version for MAC 
:dep ndarray-linalg = { version = "^0.16", features = ["openblas-system"] }

// Alternative for Mac if you installed netlib
//:dep ndarray-linalg = { version = "^0.14" , features = ["netlib"]}

// This works for linux
// :dep ndarray-linalg = { version = "^0.14" , features = ["openblas"]}


use ndarray::{Array1, Array2, ArrayView1};
use ndarray_linalg::Solve;
use ndarray::array;
use ndarray::Axis;
use ndarray_linalg::LeastSquaresSvd;

// Define an arbitrary function (e.g., a quadratic function)
fn arbitrary_function(x: f64, params: &ArrayView1<f64>) -> f64 {
    params[0] + params[1] * x + params[2] * x * x
}

// Compute the design matrix for the arbitrary function
fn design_matrix(x: &Array1<f64>) -> Array2<f64> {
    let mut dm = Array2::<f64>::zeros((x.len(), 3));
    for (i, &xi) in x.iter().enumerate() {
        dm[(i, 0)] = 1.0;
        dm[(i, 1)] = xi;
        dm[(i, 2)] = xi * xi;
    }
    dm
}

// Perform least squares fit
fn least_squares_fit(x: &Array1<f64>, y: &Array1<f64>) -> Array1<f64> {
    let dm = design_matrix(x);
    let y_col = y.to_owned().insert_axis(Axis(1)); // Convert y to a column vector
    let params = dm.least_squares(&y_col).unwrap().solution; // Use least squares solver
    params.column(0).to_owned() // Convert params back to 1D array
}


fn main() {
    // Example data
    let x: Array1<f64> = array![1.0, 2.0, 3.0, 4.0];
    let y: Array1<f64> = array![1.0, 4.0, 9.0, 16.0];  // y = x^2 for this example

    // Perform least squares fit
    let params = least_squares_fit(&x, &y);
    println!("Fitted parameters: {:.3}", params);

    // Predict using the fitted parameters
    let predictions: Array1<f64> = x.mapv(|xi| arbitrary_function(xi, &params.view()));
    println!("Predictions: {:.3?}", predictions);
}

main();
The type of the variable dataset was redefined, so was lost.
The type of the variable array was redefined, so was lost.
The type of the variable model was redefined, so was lost.
The type of the variable lin_reg was redefined, so was lost.


Fitted parameters: [0.000, -0.000, 1.000]
Predictions: [1.000, 4.000, 9.000, 16.000], shape=[4], strides=[1], layout=CFcf (0xf), const ndim=1

Example General Function

Imagine a case where you have a function like :

Then setup your functions like this:

// Define an arbitrary function (e.g., a quadratic function)
fn arbitrary_function(x: f64, params: &ArrayView1<f64>) -> f64 {
    params[0] + params[1] * x + params[2] * x * x + params[3] * x.ln()

}

// Compute the design matrix for the arbitrary function
fn design_matrix(x: &Array1<f64>) -> Array2<f64> {
    let mut dm = Array2::<f64>::zeros((x.len(), 4));
    for (i, &xi) in x.iter().enumerate() {
        dm[(i, 0)] = 1.0;
        dm[(i, 1)] = xi;
        dm[(i, 2)] = xi * xi;
        dm[(i, 3)] = xi.ln();
    }
    dm
}

It gets messier if x appears in exponents and outside the scope of this lecture but it is possible to do non-linear least squares fit for completely arbitrary functions!!!

Train/Test Splits

To test the generality of your models, it is recommended to split your data into

  • training dataset (e.g. 80% of the data)
  • testing dataset (e.g. 20% of the data)

Then train with the training dataset and evaluate using the test dataset.

More on this below.

It is a bit more cumbersome in Rust than in scikit-learn in python but in the end not that hard

The smartcore crate implements this function for you and you can use it as follows

:dep linfa = { git = "https://github.com/rust-ml/linfa" }
:dep linfa-linear = { git = "https://github.com/rust-ml/linfa" }
:dep ndarray = {version = "0.15"}
:dep smartcore = {version = "0.2", features=["ndarray-bindings"]}

use linfa::Dataset;
use linfa::traits::Fit;
use linfa_linear::LinearRegression;
use ndarray::{Array1, Array2, array};
use linfa::DatasetBase;
use smartcore::model_selection::train_test_split;  // This is the function we need
use smartcore::linalg::naive::dense_matrix::DenseMatrix;
use smartcore::linalg::BaseMatrix;


fn main() {
    // Example data: 4 samples with 3 features each
    let x: Array2<f64> = array![[1.0, 2.0, 3.0],
                                [2.0, 3.0, 4.0],
                                [3.0, 4.0, 5.0],
                                [4.0, 5.0, 6.0]];
    // Target values
    let y: Array1<f64> = array![6.0, 9.0, 12.0, 15.0];

    // Split the data into training and testing sets
    let (x_train, x_test, y_train, y_test) = train_test_split(&x, &y, 0.5, true);

    let train_dataset = Dataset::new(x_train.clone(), y_train.clone());
    let test_dataset = Dataset::new(x_test.clone(), y_test.clone());

    // Fit linear regression model to the training data
    let lin_reg = LinearRegression::new();
    let model = lin_reg.fit(&train_dataset).unwrap();

    // Print coefficients
    println!("Coefficients: {:.3?}", model.params());
    println!("Intercept: {:.3?}", model.intercept());

    // Predict from the test set
    let predictions = model.predict(&x_test);
    println!("X_test: {:.3?}, Predictions: {:.3?}", x_test, predictions);
}

main();
Coefficients: [1.000, 1.000, 1.000], shape=[3], strides=[1], layout=CFcf (0xf), const ndim=1
Intercept: -0.000
X_test: [[4.000, 5.000, 6.000],
 [3.000, 4.000, 5.000]], shape=[2, 3], strides=[3, 1], layout=Cc (0x5), const ndim=2, Predictions: [15.000, 12.000], shape=[2], strides=[1], layout=CFcf (0xf), const ndim=1

Loss Functions, Bias & Cross-Validation

Reminders

Typical predictive data analysis pipeline:

  • Very important: split your data into a training and test part
  • Train your model on the training part
  • Use the testing part to evaluate accuracy

Measuring errors for regression

  • Usually, the predictor is not perfect.
  • How do I evaluate different options and choose the best one?

Mean Squared Error (or loss):

Mean Absolute Error (or loss):

Plots of MSE and MAE

// 2021
//:dep plotters={version = "^0.3.0", default_features = false, features = ["evcxr", "all_series","full_palette"]}

// 2024
:dep plotters={version = "^0.3.0", default-features = false, features = ["evcxr", "all_series","full_palette"]}

:dep ndarray = { version = "^0.16.0" }
:dep ndarray-rand = { version = "0.15.0" }
:dep linfa = { version = "^0.7.0" }
:dep linfa-linear = { version = "^0.7.0" }
:dep linfa-datasets = { version = "^0.7.0" }
:dep linfa-linear = { version = "^0.7.0" }
:dep ndarray-stats = { version = "^0.5.1" }
[E0277] Error: the trait bound `ArrayBase<OwnedRepr<f64>, Dim<[usize; 2]>>: Matrix<_>` is not satisfied



[E0277] Error: the trait bound `ArrayBase<OwnedRepr<f64>, Dim<[usize; 2]>>: BaseMatrix<_>` is not satisfied



[E0308] Error: mismatched types



[E0308] Error: mismatched types



[E0277] Error: the trait bound `FittedLinearRegression<_>: linfa::prelude::Predict<&ArrayBase<OwnedRepr<f64>, Dim<[usize; 2]>>, _>` is not satisfied



[E0277] Error: the trait bound `ArrayBase<OwnedRepr<f64>, Dim<[usize; 2]>>: Records` is not satisfied



[E0599] Error: no method named `least_squares` found for struct `ArrayBase` in the current scope



[E0308] Error: arguments to this function are incorrect



[E0308] Error: mismatched types
// Helper code for plotting
extern crate plotters;
use plotters::prelude::*;
use ndarray::Array1;
use plotters::evcxr::SVGWrapper;
use plotters::style::colors::full_palette;

fn plotter_scatter(sizes: (u32, u32), x_range: (f64, f64), y_range: (f64, f64), scatters: &[(&Array1<f64>, &Array1<f64>, &RGBColor, &str, &str)], lines: &[(&Array1<f64>, &Array1<f64>, &str, &RGBColor)]) -> SVGWrapper {
    evcxr_figure((sizes.0, sizes.1), |root| {
    let mut chart = ChartBuilder::on(&root)
    // the caption for the chart
        .caption("2-D Plot", ("Arial", 20).into_font())
        .x_label_area_size(40)
        .y_label_area_size(40)
   // the X and Y coordinates spaces for the chart
        .build_cartesian_2d(x_range.0..x_range.1, y_range.0..y_range.1)?;
    chart.configure_mesh()
        .x_desc("X Values")
        .y_desc("Y Values")
        .draw()?;
        
        for scatter in scatters {
            if scatter.3 == "x" {
              chart.draw_series(
                scatter.0.iter().zip(scatter.1.iter()).map(|(&a, &b)| {
                  Cross::new((a, b), 3, scatter.2.filled())   
                }),
              )?
              .label(scatter.4)
              .legend(|(x, y)| Cross::new((x, y), 3, scatter.2.filled()));
            } else {
              chart.draw_series(
                scatter.0.iter().zip(scatter.1.iter()).map(|(&a, &b)| {
                  Circle::new((a, b), 3, scatter.2.filled())   
                }),
              )?
              .label(scatter.4)
              .legend(|(x, y)| Circle::new((x, y), 3, scatter.2.filled()));
            }
        }
        
        if lines.len() > 0 {
            for line in lines {
              chart
                .draw_series(LineSeries::new(line.0.iter().cloned().zip(line.1.iter().cloned()), line.3))?
                .label(line.2)
                .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y )], line.3));
            }
            // Configure and draw the legend
        
        }
        chart
              .configure_series_labels()
              .background_style(&WHITE.mix(0.8))
              .border_style(&BLACK)
              .draw()?;
    Ok(())
  })
}
use ndarray::Array;
    
let xs = Array::linspace(-1.3, 1.3, 300);
let abs_xs = xs.mapv(f64::abs);
let xs_squared = xs.mapv(|a| a.powi(2));

plotter_scatter(
  (500, 400), (-1.5,1.5), (0.,1.75), //plot size and ranges
  &[], //any scatters
  &[(&xs,&abs_xs, "MAE", &full_palette::RED), (&xs,&xs_squared, "MSE", &full_palette::BLUE)] //any lines
)
2-D Plot Y Values X Values 0.0 0.2 0.4 0.6 0.8 1.0 1.2 1.4 1.6 -1.5 -1.0 -0.5 0.0 0.5 1.0 1.5 MAE MSE

Observe that MAE will be more sensitive to small differences, while

MSE penalized big differences more.

Definition of an outlier





A point or small set of points that are "different"





Important difference between error measures: different attention to outliers

Linear Regression: higher powers of absolute error ( loss)

In the limit...

  • This converges to minimizing the maximum difference between ( and )

  • This is called: loss

  • Another way to express it: minimize such that

In the limit...

  • Another way to express it: minimize such that

  • Linear programming formulation: minimize such that

and

1D insight into the outlier sensitivity

  • Input: set of points in

  • What point minimizes MSE, MAE, ... as a representative of these points?

  • MSE aka : mean

  • MAE aka : median

  • : the mean of the maximum and minimum

Something in between MSE and MAE?

  • loss for ?

  • Huber loss:

    • quadratic for small distances
    • linear for large distances

Underfitting

  • Model not expressive enough to capture the problem
  • Or a solution found does not match the problem

Possible solutions:

  • Try harder to find a better solution
  • Add more parameters
  • Try a different model that captures the solution

Overfitting

  • Predictions adjusted too well to training data
  • Error on test data error on training data

Possible solutions:

  • Don't optimize the model on the training data too much
  • Remove features that are too noisy
  • Add more training data
  • Reduce model complexity

Bias and variance

Bias:

The simplifying assumptions made by the model to make the target function easier to approximate.

Mathematically it is the difference of the average value of predictions from the true function:

Variance:

The amount that the output of the model changes given different training data.

Mathematically it is the mean of the delta of the squared deviation of the prediction function from its expected value:

Bias: error due to model unable to match the complexity of the problem

Variance: how much the prediction will vary in response to data points

Overfitting: high variance, low bias

Underfitting: high bias, low variance

Important in practice:

  • detecting the source of problems: variance vs. bias, overfitting vs. underfitting

  • navigating the trade-off and finding the sweet spot

Some examples

| Algorithm | Bias| Variance| |:-:|:-:|:-:| |Linear Regression|High|Low| |Decision Tree|Low|High| |Random Forest|Low|High (less than tree)|

Terminology

Parameters

  • Variables fixed in a specific instantiation of a model
  • Examples:
    • coefficients in linear regression
    • decision tree structure and thresholds
    • weights and thresholds in a neural network

Hyperparameters

  • Also parameters, but higher level

  • Examples:

    • number of leafs in a decision tree
    • number of layers and their structure in a neural network
    • degree of a polynomial

Hyperparameter tuning

  • Adjusting hyperparameters before training the final model

Model selection

  • Deciding on the type of model to be used (linear regression? decision trees? ...)

Decision Tree discussion

  • Tree structure and thresholds for splits are parameters (learned by the algorithm)
  • Many of the others are hyperparameters
  • split_quality: Sets the metric used to decide the feature on which to split a node
  • min_impurity_decrease: How much reduction in gini or other metric should we see before we allow a split
  • min_weight_split: Sets the minimum weight of samples required to split a node.
  • min_weight_leaf: Sets the minimum weight of samples that a split has to place in each leaf
  • max_depth: Affects the structure of the tree and how elements can be assigned to nodes

All documented at great length in (https://docs.rs/linfa-trees/latest/linfa_trees/struct.DecisionTreeParams.html)

Challenges of training and cross-validation

Big goal: train a model that can be used for predicting

Intermediate goal: select the right model and hyperparameters

How about trying various options and seeing how they perform on the test set?
[image]

Information leak danger!

  • If we do it adaptively, information from the test set could affect the model selection

Tune your parameters by using portions of the training set and preserve the test set for only a final evaluation

Holdout method

  • Partition the training data again: training set and validation set

  • Use the validation part to estimate accuracy whenever needed

[image]

Pros:

  • Very efficient
  • Fine with lots of data when losing a fraction is not a problem

Cons:

  • Yet another part of data not used for training
  • Problematic when the data set is small
  • Testing part could contain important information

–fold cross–validation

  • Partition the training set into folds at random
  • Repeat times:
    • train on folds
    • estimate the accuracy on the -th fold
  • Return the mean
[image]

Pros:

  • Every data point used for training most of the time
  • Less variance in the estimate

Cons:

  • times slower

LOOCV: Leave–one–out cross–validation

  • Extreme case of the previous approach: separate fold for each data point

  • For each data point :

    • train on data without
    • estimate the accuracy on
  • Return the mean of accuracies

Cons:

  • Even more expensive

Many other options

  • Generalization: leave––out cross–validation enumerates over subsets

  • Sampling instead of trying all options

  • A variation that ensures that all classes evenly distributed in folds

  • ...

Training and Cross Validation with Iris Dataset

Classic dataset

  • 3 Iris species
  • 50 examples in each class
  • 4 features (sepal length/width, petal length/width) for each sample
:dep linfa = {version = "0.7.0"}
:dep linfa-datasets = { version = "0.7.0", features = ["iris"] }
:dep linfa-trees = { version = "0.7.0" }
:dep ndarray = { version = "0.16.1" }
:dep ndarray-rand = {version = "0.15.0" }
:dep rand = { version = "0.8.5", features = ["small_rng"] }
:dep smartcore = { version = "0.3.2", features = ["datasets", "ndarray-bindings"] }

use linfa::prelude::*;
use linfa_trees::DecisionTree;
use ndarray_rand::rand::SeedableRng;
use rand::rngs::SmallRng;
use linfa_trees::DecisionTreeParams;

fn crossvalidate() -> Result<(), Box<dyn std::error::Error>> {

    // Load the Iris dataset
    let mut iris = linfa_datasets::iris();

    let mut rng = SmallRng::seed_from_u64(42);

    // Split the data into training and testing sets
    let (train, test) = iris.clone()
        .shuffle(&mut rng)
        .split_with_ratio(0.8);

    // Extract the features (X) and target (y) for training and testing sets
    let X_train = train.records();
    let y_train = train.targets();
    let X_test = test.records();
    let y_test = test.targets();

    // Print the shape of the training and testing sets
    println!("X_train shape: ({}, {})", X_train.nrows(), X_train.ncols());
    println!("y_train shape: ({})", y_train.len());
    println!("X_test shape: ({}, {})", X_test.nrows(), X_test.ncols());
    println!("y_test shape: ({})", y_test.len());

    // Train the model on the training data
    let model = DecisionTree::params()
        .max_depth(Some(3))
        .fit(&train)?;

    // Evaluate the model's accuracy on the training set
    let train_accuracy = model.predict(&train)
        .confusion_matrix(&train)?
        .accuracy();
    println!("Training accuracy: {:.2}%", train_accuracy * 100.0);

    // Evaluate the model's accuracy on the test set
    let test_accuracy = model.predict(&test)
        .confusion_matrix(&test)?
        .accuracy();
    println!("Test accuracy: {:.2}%", test_accuracy * 100.0);
    
    // Define two models with depths 3 and 2
    let dt_params1 = DecisionTree::params().max_depth(Some(3));
    let dt_params2 = DecisionTree::params().max_depth(Some(2));

    // Create a vector of models
    let models = vec![dt_params1, dt_params2];

    // Train and cross-validation using the models
    let scores = iris.cross_validate_single(
        5, 
        &models, 
        |prediction, truth|
            Ok(prediction.confusion_matrix(truth.to_owned())?.accuracy()))?;
    println!("Cross-validation scores: {:?}", scores);

    // Perform cross-validation using fold
    let scores: Vec<_> = iris.fold(5).into_iter().map(|(train, valid)| {
       let model = DecisionTree::params()
          .max_depth(Some(3))
          .fit(&train).unwrap();
       let accuracy = model.predict(&valid).confusion_matrix(&valid).unwrap().accuracy();
       accuracy
    }).collect();
    
    println!("Cross-validation scores general: {:?} {}", scores, scores.iter().sum::<f32>()/scores.len() as f32);

    Ok(())
}

crossvalidate();
[E0433] Error: failed to resolve: could not find `naive` in `linalg`



[E0432] Error: unresolved import `smartcore::linalg::BaseMatrix`



[E0277] Error: the trait bound `ArrayBase<OwnedRepr<f64>, Dim<[usize; 2]>>: Array2<_>` is not satisfied



[E0277] Error: the trait bound `ArrayBase<OwnedRepr<f64>, Dim<[usize; 1]>>: Array1<_>` is not satisfied



[E0061] Error: this function takes 5 arguments but 4 arguments were supplied



[E0308] Error: arguments to this function are incorrect



[E0308] Error: arguments to this function are incorrect



[E0599] Error: the method `fit` exists for struct `LinearRegression`, but its trait bounds were not satisfied



[E0599] Error: no method named `least_squares` found for struct `ArrayBase` in the current scope



[E0308] Error: arguments to this function are incorrect



[E0599] Error: the method `fit` exists for struct `LinearRegression`, but its trait bounds were not satisfied



[E0308] Error: mismatched types

Let's take a closer look at

#![allow(unused)]
fn main() {
    // Evaluate the model's accuracy on the training set
    let train_accuracy = model.predict(&train)
        .confusion_matrix(&train)?
        .accuracy();
    println!("Training accuracy: {:.2}%", train_accuracy * 100.0);
}

We're creating the confusion matrix, for example

                Predicted
                Class1    Class2    Class3
Actual  Class1    TP1       FP12      FP13
        Class2    FP21      TP2       FP23
        Class3    FP31      FP32      TP3

Where:

  • TP1, TP2, TP3 are True Positives for each class (correct predictions)
  • FPxy are False Positives (predicting class y when it's actually class x)

And then calculating accuracy as:

Accuracy = (TP1 + TP2 + TP3) / Total Predictions

For the second training experiment:

#![allow(unused)]
fn main() {
    // Define two models with depths 3 and 2
    let dt_params1 = DecisionTree::params().max_depth(Some(3));
    let dt_params2 = DecisionTree::params().max_depth(Some(2));

    // Create a vector of models
    let models = vec![dt_params1, dt_params2];

    // Train and cross-validation using the models
    let scores = iris.cross_validate_single(5, &models, |prediction, truth|
        Ok(prediction.confusion_matrix(truth.to_owned())?.accuracy()))?;
    println!("Cross-validation scores: {:?}", scores);
}

dt_params1 and dt_params2 are just parameter configurations, not yet trained models.

We'll split the training set into 5 folds:

Initial Split: [Fold1, Fold2, Fold3, Fold4, Fold5]

Then train on 4 of the folds and validate on the 5th:

For Model 1 (max_depth=3):
Iteration 1: Train on [Fold2, Fold3, Fold4, Fold5], Validate on Fold1
Iteration 2: Train on [Fold1, Fold3, Fold4, Fold5], Validate on Fold2
Iteration 3: Train on [Fold1, Fold2, Fold4, Fold5], Validate on Fold3
Iteration 4: Train on [Fold1, Fold2, Fold3, Fold5], Validate on Fold4
Iteration 5: Train on [Fold1, Fold2, Fold3, Fold4], Validate on Fold5

For Model 2 (max_depth=2): Same process repeated with the same folds

And the last training experiment:

#![allow(unused)]
fn main() {
// Perform cross-validation using fold
let scores: Vec<_> = iris.fold(5).into_iter().map(|(train, valid)| {
   let model = DecisionTree::params()
      .max_depth(Some(3))
      .fit(&train).unwrap();
   let accuracy = model.predict(&valid).confusion_matrix(&valid).unwrap().accuracy();
   accuracy
}).collect();
}

This manually implements 5-fold cross-validation.

Step-by-step description courtesy of Cursor Agent.

  1. Creating the Folds:
#![allow(unused)]
fn main() {
iris.fold(5)
}
  • This splits the Iris dataset into 5 folds
  • Returns an iterator over tuples of (train, valid) where:
    • train contains 4/5 of the data
    • valid contains 1/5 of the data
  • Each iteration will use a different fold as the validation set
  1. The Main Loop:
#![allow(unused)]
fn main() {
.into_iter().map(|(train, valid)| {
}
  • Converts the folds into an iterator
  • For each iteration, we get a training set and validation set
  1. Model Training:
#![allow(unused)]
fn main() {
let model = DecisionTree::params()
    .max_depth(Some(3))
    .fit(&train).unwrap();
}
  • Creates a decision tree model with max depth of 3
  • Trains the model on the current training fold
  • Uses unwrap() to handle potential errors (in production code, you'd want better error handling)
  1. Model Evaluation:
#![allow(unused)]
fn main() {
let accuracy = model.predict(&valid)
    .confusion_matrix(&valid)
    .unwrap()
    .accuracy();
}
  • Makes predictions on the validation set
  • Creates a confusion matrix comparing predictions to true labels
  • Calculates the accuracy from the confusion matrix
  1. Collecting Results:
#![allow(unused)]
fn main() {
}).collect();
}
  • Collects all 5 accuracy scores into a vector
  • Each score represents the model's performance on a different validation fold

The key differences between this and the previous cross_validate_single method are:

  • This is a more manual implementation
  • It only evaluates one model configuration (max_depth=3)
  • It gives you direct control over the training and evaluation process
  • The results are collected into a simple vector of accuracy scores

This approach is useful when you want more control over the cross-validation process or when you need to implement custom evaluation metrics.

Technical Coding Challenge

Coding Challenge

Coding Challenge Review