Lecture 2
Compiling
- We started the course with Scratch, and then learned C.
- Recall that we write our source code in C, but needed to compile it to machine code, in binary, before our computers could run it.
clang
is the compiler we learned to use, andmake
is a utility that helps us runclang
without having to indicate all the options manually.- If we wanted to use CS50’s library, via
#include <cs50.h>
, and useclang
instead ofmake
, we also have to add a flag:clang hello.c -lcs50
. The-l
flag links thecs50
file, which was installed into the CS50 Sandbox.
- “Compiling” source code into machine code is actually made up of smaller steps:
- preprocessing
- compiling
- assembling
- linking
- Preprocessing involves looking at lines that start with a
#
, like#include
, before everything else. For example,#include <cs50.h>
will tellclang
to look for that header file first, since it contains content that we want to include in our program. Then,clang
will essentially replace the contents of those header files into our program:... string get_string(string prompt); int printf(const char *format, ...); ... int main(void) { string name = get_string("Name: "); printf("hello, %s\n", name); }
- Compiling takes our source code, in C, and converts it to assembly code, which looks like this:
... main: # @main .cfi_startproc # BB#0: pushq %rbp .Ltmp0: .cfi_def_cfa_offset 16 .Ltmp1: .cfi_offset %rbp, -16 movq %rsp, %rbp .Ltmp2: .cfi_def_cfa_register %rbp subq $16, %rsp xorl %eax, %eax movl %eax, %edi movabsq $.L.str, %rsi movb $0, %al callq get_string movabsq $.L.str.1, %rdi movq %rax, -8(%rbp) movq -8(%rbp), %rsi movb $0, %al callq printf ...
- These instructions are lower-level and can be understood by the CPU more directly, and generally operate on bytes themselves, as opposed to abstractions like variable names.
- The next step is to take the assembly code and translate it to instructions in binary by assembling it.
- Now, the final step is linking, where the contents of linked libraries, like
cs50.c
, are actually included in our program as binary.
Debugging
- Let’s say we wrote this program,
buggy0
:int main(void) { printf("hello, world\n") }
- We see an error, when we try to
make
this program, that we didn’t include a missing header file. - We can also run
help50 make buggy0
, which will tell us, at the end, that we should#include <stdio.h>
, which containsprintf
. - We do that, and see another error, and realize we’re missing a semicolon at the end of our line.
- We see an error, when we try to
- Let’s look at another program:
#include <stdio.h> int main(void) { for (int i = 0; i <= 10; i++) { printf("#\n"); } }
- Hmm, we intended to only see 10
#
s, but there are 11. If we didn’t know what the problem is (since our program is working as we wrote it), we could add another print line to help us:#include <stdio.h> int main(void) { for (int i = 0; i <= 10; i++) { printf("i is %i\n", i); printf("#\n"); } }
- Now, we see that
i
started at 0 and continued until it was 10, but we should have it stop once it’s at 10.
- Hmm, we intended to only see 10
- If we wrote our program without any whitespace, like the below, it would still be correct:
#include <stdio.h> int main(void) { for (int i = 0; i < 10; i++) { printf("i is %i\n", i); printf("#\n"); } }
- But, our program is much harder to read, and so it’s poorly styled. With indentation for our loops, it’ll be easier to see the nesting of our lines of code.
- We can run
style50 buggy2.c
, and see suggestions for what we should change.
- So to recap, we have three tools to help us improve our code:
help50
printf
style50
Memory
- Inside our computers, we have chips called RAM, random-access memory, that stores data for short-term use. We might save a file to our hard drive (or SSD) for long-term storage, but when we open it and start making changes, it gets copied to RAM. Though RAM is much smaller, and temporary (until the power is turned off), it is much faster.
- We can think of bytes, stored in RAM, as though they were in a grid:
- In reality, there are millions or billions of bytes per chip.
- In C, when we create a variable of type
char
, which will be sized one byte, it will physically be stored in one of those boxes in RAM. An integer, with 4 bytes, will take up four of those boxes.
Arrays
- In memory, we can store variables one after another, back-to-back. And in C, a list of variables stored, one after another in a contiguous chunk of memory, is called an array.
- It turns out, we can do interesting things with just array.
- Let’s look at
scores0.c
:#include <cs50.h> #include <stdio.h> int main(void) { // Get scores from user int score1 = get_int("Score 1: "); int score2 = get_int("Score 2: "); int score3 = get_int("Score 3: "); // Generate first bar printf("Score 1: "); for (int i = 0; i < score1; i++) { printf("#"); } printf("\n"); // Generate second bar printf("Score 2: "); for (int i = 0; i < score2; i++) { printf("#"); } printf("\n"); // Generate third bar printf("Score 3: "); for (int i = 0; i < score3; i++) { printf("#"); } printf("\n"); }
- We get 3 scores from the user, and print bars for each score.
- Our 3 integers,
score1
,score2
, andscore3
will be stored somewhere in memory.
- We can use a loop, but we can start factoring out pieces:
#include <cs50.h> #include <stdio.h> void chart(int score); int main(void) { // Get scores from user int score1 = get_int("Score 1: "); int score2 = get_int("Score 2: "); int score3 = get_int("Score 3: "); // Chart first score printf("Score 1: "); chart(score1); // Chart second score printf("Score 2: "); chart(score2); // Chart third score printf("Score 3: "); chart(score3); } // Generate bar void chart(int score) { // Output one hash per point for (int i = 0; i < score; i++) { printf("#"); } printf("\n"); }
- Now, we have a
chart
function that can print each score. - Remember that we need our prototype,
void chart(int score);
, to be at the top. We could also have the entirechart
function at the top, before we use it, but eventually ourmain
function would be pushed down too far, and be harder and harder to find.
- Now, we have a
- With an array, we can collect our scores in a loop, and access them later in a loop, too:
// Generates a bar chart of three scores using an array #include <cs50.h> #include <stdio.h> void chart(int score); int main(void) { // Get scores from user int scores[3]; for (int i = 0; i < 3; i++) { scores[i] = get_int("Score %i: ", i + 1); } // Chart scores for (int i = 0; i < 3; i++) { printf("Score %i: ", i + 1); chart(scores[i]); } } // Generate bar void chart(int score) { // Output one hash per point for (int i = 0; i < score; i++) { printf("#"); } printf("\n"); }
- Notice that we use
int scores[3]
to initialize an array for 3 integers. Then, we usescores[i] = ...
to store values into that array, using some indexi
that goes from0
to2
(since there are 3 elements). - Then, we use
scores[i]
to access the values stored, at each index.
- Notice that we use
- We repeat the value
3
in a few times, so we can factor that out to a constant, or a number we can specify and use globally:#include <cs50.h> #include <stdio.h> const int COUNT = 3; void chart(int score); int main(void) { // Get scores from user int scores[COUNT]; for (int i = 0; i < COUNT; i++) { scores[i] = get_int("Score %i: ", i + 1); } // Chart scores for (int i = 0; i < COUNT; i++) { printf("Score %i: ", i + 1); chart(scores[i]); } } // Generate bar void chart(int score) { // Output one hash per point for (int i = 0; i < score; i++) { printf("#"); } printf("\n"); }
- At the top, we use the
const
keyword to indicate that this value shouldn’t change. And we can use this throughout our code, so if we wanted this value to change, we only need to change it once. Finally,COUNT
is in all capital letters, to indicate that it’s a constant (by convention).
- At the top, we use the
- We can have our
chart
function print the entire chart, not just one bar at a time:#include <cs50.h> #include <math.h> #include <stdio.h> const int COUNT = 3; void chart(int count, int scores[]); int main(void) { // Get scores from user int scores[COUNT]; for (int i = 0; i < COUNT; i++) { scores[i] = get_int("Score %i: ", i + 1); } // Chart scores chart(COUNT, scores); } // Generate bars void chart(int count, int scores[]) { // Output one hash per point for (int i = 0; i < count; i++) { for (int j = 0; j < scores[i]; j++) { printf("#"); } printf("\n"); } }
- By passing in the entire
scores
array, as well as thecount
of scores we want to print, we can have thechart
function iterate overscores
. In fact,chart
doesn’t know how big thescores
array actually is, so we necessarily have to pass in acount
.
- By passing in the entire
Strings
- Strings are actually just arrays of characters. We can see this with
string0.c
:#include <cs50.h> #include <stdio.h> #include <string.h> int main(void) { string s = get_string("Input: "); printf("Output: "); for (int i = 0; i < strlen(s); i++) { printf("%c\n", s[i]); } }
- First, we need a new library,
string.h
, forstrlen
, which tells us the length of a string. Then, we use the same syntax to access elements in arrays,s[i]
, to print each individual character of the strings
.
- First, we need a new library,
- We can improve the design of our program.
string0
was a bit inefficient, since we check the length of the string, after each character is printed, in our condition. But since the length of the string doesn’t change, we can check the length of the string once:#include <cs50.h> #include <stdio.h> #include <string.h> int main(void) { string s = get_string("Input: "); printf("Output:\n"); for (int i = 0, n = strlen(s); i < n; i++) { printf("%c\n", s[i]); } }
- Now, at the start of our loop, we initialize both an
i
andn
variable, and remember the length of our string inn
. Then, we can check the values each time, without having to actually calculate the length of the string. n
will only be accessible in the scope of thefor
loop, though we could initialize it outside of the loop, if we wanted to reuse it later.
- Now, at the start of our loop, we initialize both an
- When a string is stored in memory, each character is placed into one byte into the grid of bytes. Somewhere, for example,
Zamyla
is stored in 6 bytes. But one more byte is needed, to indicate the end of the string:- The byte in memory where the first character of the string,
Z
, is stored, is labeleds
, since we called our strings
in the code above. Then, after the last character,a
, we have one byte with all0
s, to indicate the end of the string. And the byte of all0
s is called a null character, which we can also write as\0
.
- The byte in memory where the first character of the string,
- If we wanted to write our own version of
strlen
, for example, we would need to know this:#include <cs50.h> #include <stdio.h> int main(void) { // Prompt for user's name string s = get_string("Name: "); // Count number of characters up until '\0' (aka NUL) int n = 0; while (s[n] != '\0') { n++; } printf("%i\n", n); }
- Here, we iterate over each character of the string
s
with the syntax we use to access elements in arrays, and we increment a counter,n
, as long as the character isn’t the null character,\0
. If it is, we’re at the end of the string, and can print out the value ofn
.
- Here, we iterate over each character of the string
- And, since we know that each character has a numeric, ASCII value, we can even print that:
#include <cs50.h> #include <stdio.h> #include <string.h> int main(void) { string s = get_string("String: "); for (int i = 0; i < strlen(s); i++) { int c = (int) s[i]; printf("%c %i\n", s[i], c); } }
- With
(int) s[i]
, we take the value ofs[i]
and convert that character type to an integer type. Then, we can print out both the character and its numeric value. - Technically, we can even do
printf("%c %i\n", s[i], s[i]);
, andprintf
will interpret the value ofs[i]
as an integer.
- With
- We can now combine what we’ve seen, to write a program that can capitalize letters:
#include <cs50.h> #include <stdio.h> #include <string.h> int main(void) { string s = get_string("Before: "); printf("After: "); for (int i = 0, n = strlen(s); i < n; i++) { if (s[i] >= 'a' && s[i] <= 'z') { printf("%c", s[i] - ('a' - 'A')); } else { printf("%c", s[i]); } } printf("\n"); }
- First, we get a string
s
. Then, for each character in the string, if it’s lowercase (its value is between that ofa
andz
), we convert it to uppercase. Otherwise, we just print it. - We can convert a lowercase letter to its uppercase equivalent, by subtracting the difference between a lowercase
a
and an uppercaseA
. (We know that lowercase letters have a higher value than uppercase letters, and so we can subtract that difference to get an uppercase letter from a lowercase letter.)
- First, we get a string
- But there are library functions that we can use, to accomplish the same thing:
#include <cs50.h> #include <ctype.h> #include <stdio.h> #include <string.h> int main(void) { string s = get_string("Before: "); printf("After: "); for (int i = 0, n = strlen(s); i < n; i++) { if (islower(s[i])) { printf("%c", toupper(s[i])); } else { printf("%c", s[i]); } } printf("\n"); }
islower()
andtoupper()
are two functions, among others, from a library calledctype
, that we can use. (And we would only know this from reading the documentation, for that library, that other people wrote.)- We can use a command-line program,
man
, to read manual information for other programs, if it exists. For example, we can runman toupper
to see some documentation about that function. Then, we’ll see thattoupper
will return the character as-is, if it’s not a lowercase letter, and so we can simply have:for (int i = 0, n = strlen(s); i < n; i++) { printf("%c", toupper(s[i])); }
Command-line arguments
- We’ve used programs like
make
andclang
, which take in extra words after their name in the command line. It turns out that programs of our own, can also take in command-line arguments. - In
argv0.c
, we change what ourmain
function looks like:#include <cs50.h> #include <stdio.h> int main(int argc, string argv[]) { if (argc == 2) { printf("hello, %s\n", argv[1]); } else { printf("hello, world\n"); } }
argc
andargv
are two variables that ourmain
function will now get, when our program is run from the command line.argc
is the argument count, or number of arguments, andargv
is an array of strings that are the arguments. And the first argument,argv[0]
, will be the name of our program (the first word typed, like./hello
). In this example, we’ll check if we have two arguments, and print out the second one if so.
- We can print every argument, one at a time:
#include <cs50.h> #include <stdio.h> int main(int argc, string argv[]) { for (int i = 0; i < argc; i++) { printf("%s\n", argv[i]); } }
- We can print out each character of each argument, too:
#include <cs50.h> #include <stdio.h> #include <string.h> int main(int argc, string argv[]) { for (int i = 0; i < argc; i++) { for (int j = 0, n = strlen(argv[i]); j < n; j++) { printf("%c\n", argv[i][j]); } printf("\n"); } }
- With
argv[i]
, we get the current argument from the array of arguments, and withargv[i][j]
, we get a character from that string.
- With
Encryption
- If we wanted to send a message to someone, we might want to encrypt, or somehow scramble that message so that it would be hard for others to read. The original message is called plaintext, and the encrypted message is called ciphertext.
- A message like
HI!
could be converted to ASCII,72 73 33
. But anyone would be able to convert that back to letters. - We look at examples, from World War I, to a poem about Paul Revere’s ride, of historical codes.
- Encryption generally requires another input, in addition to the plaintext. A key is needed, and sometimes it is simply a number, that is kept secret. With the key, plaintext can be converted, via some algorith, to ciphertext, and vice versa.
- For example, if we wanted to send a message like
I L O V E Y O U
, we can first convert it to ASCII:73 76 79 86 69 89 79 85
. Then, we can encrypt it with a key of just1
and a simple algorithm, where we just add the key to each value:74 77 80 87 70 90 80 86
. Then, someone converting that ASCII back to text will seeJ M P W F Z P V
. To decrypt this, someone might have to guess the value of each letter, through trial-and-error, but they wouldn’t be sure, without knowing the key. In fact, this algorithm is known as a Caesar cipher.
Exit codes
- It turns out that we can indicate errors in our program, by returning a value from our
main
function:#include <cs50.h> #include <stdio.h> int main(int argc, string argv[]) { if (argc != 2) { printf("missing command-line argument\n"); return 1; } printf("hello, %s\n", argv[1]); return 0; }
- The return value of
main
in our program is called an exit code, and we can actually see this in our command line. If we ran this program with./exit
, we can then typeecho $?
, which will print the last program’s return value. - As we write more complex programs, error codes like this will help us determine what went wrong, even if it’s not visible or meaningful to the user.
- The return value of
Sorting
- With arrays, we can solve more interesting problems than before. We can think of arrays like lockers, where, behind the doors of each locker, is some value, like an integer or character. Indeed, computers can only look at one locker, or value at a time.
- If we had a list of numbers, and we wanted to find a number in that list, the best we could do is look through it, one at a time, or randomly.
- But if we knew the list was sorted, we could look in the middle first, and move left or right accordingly.
- With some volunteers, we demonstrate how we might sort a list.
- Our volunteers start in the following random order:
6 5 1 3 7 8 4 2
- We look at the first two numbers, and swap them so they are in order:
5 6 1 3 7 8 4 2
- Then we look at the next pair,
6
and1
, and swap them:5 1 6 3 7 8 4 2
- We repeat this, until, after our first pass, the largest number ended up furthest on the right:
5 1 6 3 7 4 2 8
- (In the lecture, the 1 accidentally moved a spot too far!)
- We repeat this, and every time we make a pass, the next-largest number ends up next-furthest to the right:
1 5 3 6 4 2 7 8
- Eventually, our list becomes fully sorted. The first time, we compared 7 pairs of numbers. The second time, we compared 6 pairs.
- We shuffle our numbers again:
2 4 8 5 7 1 3 6
- And this time, we look for the smallest number each time, as we go down the list, and put that to the far left:
1 4 8 5 7 2 3 6 1 2 8 5 7 4 3 6 1 2 3 5 7 4 8 6
- Each time, we select the smallest number and swap it with the number that’s in the furthest left part of the unsorted part of the list.
- With this algorithm, we still pass through the list n - 1 times, since there are n people, and we do have to compare each number with the smallest number we’ve seen thus far.
- Let’s try to figure this out a little more formally. The first algorithm, bubble sort, involved comparing pairs of numbers next to each other, until the largest bubbled up to the right. We might write that in pseudocode as:
repeat until no swaps for i from 0 to n-2 if i'th and i+1'th elements out of order swap them
- And selection sort might be as follows:
for i from 0 to n-1 find smallest element between i'th and n-1'th swap smallest with i'th element
- For the first pass, we needed to make
n - 1
comparisons, to find the smallest number. Then, in each of the following passes, we made one less comparison, since we had already moved some numbers to the left:(n – 1) + (n – 2) + ... + 1 n(n – 1)/2 (n^2 – n)/2 n^2 / 2 – n/2
- Each line simplifies to the next, and eventually, we get
n^2 / 2 – n/2
as the number of comparisons we need to make. In computer science, we can use O, big O notation, to simplify that further, and say that our algorithm takes O(n^2) steps, “on the order of n squared”. This is because, as n gets bigger and bigger, only the n^2 term matters. - For example, if n were 1,000,000, we would get:
n^2 / 2 – n/2 1,000,000^2 / 2 – 1,000,000/2 500,000,000,000 – 500,000 499,999,500,000
- which is on the same order of magnitude as n2.
- Each line simplifies to the next, and eventually, we get
- It turns out, there are other common orders of magnitude:
- O(n2)
- O(n log n)
- O(n)
- O(log n)
- O(1)
- Searching through a phone book, one page at a time, has O(n) running time, since we need one step for every page. Using binary search would have O(log n) running time, since we divided the problem in half each time.
- Let’s take another array of numbers, but this time, use an empty array of the same size as our working space:
4 2 7 5 6 8 3 1 _ _ _ _ _ _ _ _
- Since we have 8 numbers, let’s look at the first half, the first 4. We’ll sort that recursively, and look at just the left half of that. With 2 numbers,
4 2
, we look at the left half of that (sorted), and the right half of that (sorted), and combine them by sorting them,2 4
. We’ll move them to our second array:_ _ 7 5 6 8 3 1 2 4 _ _ _ _ _ _
- We repeat this, for the right half of the original half:
_ _ | _ _ 6 8 3 1 2 4 | 5 7 _ _ _ _
- Then, we merge those halves, to get a sorted left half:
_ _ _ _ 6 8 3 1 _ _ _ _ _ _ _ _ 2 4 5 7 _ _ _ _
- We repeat, for the right half:
_ _ _ _ | _ _ _ _ _ _ _ _ | _ _ _ _ 2 4 5 7 | 1 3 6 8
- And now, we can merge both halves:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 1 2 3 4 5 6 7 8
- Each number had to move 3 times, since we divided 8 by 2 three times, or log n times. So this algorithm takes O(n log n) to sort a list.
- We look at demos like Sorting Algorithms Animations and What different sorting algorithms sound like to conclude.