Lab 3: Pythonic lists in C
- Due before 23:59 Wednesday, September 14
- Submit using the submit program
as
301 lab 03
.
1 Background
Speed vs ease of use in programming languages
Python was designed to be easy to use, at the expense of speed; the theory was that often, it's OK if the program runs a couple seconds slower if it took the programmer several hours less to write it. On the other side of this tradeoff is the other language you know, C, which is extremely fast once written, but more difficult to program.
Much of the difference comes from the amount of work each language does for you. For example, in C, you have to allocate and free memory yourself for objects and arrays. In Python, the language does it for you. This is easier on the programmer, but it takes additional overhead to do so, making the program slower.
So this must mean that there's a program which is running your Python program, so that it can figure out what needs to be done for you. And there is! And to minimize overhead, it'd be good if it was fast, and written in C. And it is! This means that every line of Python you write is interpreted, and implemented with equivalent lines of C before those lines are then run.
Review: structs in C
Unlike Python, C is not an object-oriented language, so there are no
classes to work with with fields and methods. The best C has to offer is
a struct
, which is a way of combining multiple pieces of data
into one. Think of it as a class with no methods.
For example, here's a very simple C program that declares a struct to
store football scores and has a simple function to deal with it. Notice that
structs can be allocated and de-allocated with malloc
and free
(which you should already be familiar with) and you can use the ->
operator to access member fields from a pointer to a struct.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | #include <stdio.h> #include <stdlib.h> // here is the struct declaration. // The name of the struct is "struct score" // and the three fields are defined within. struct score { int touchdowns; int PATs; int fieldgoals; }; // This is a function to add up the points in a score void print_total(struct score* thescore) { int tdpts = thescore->touchdowns * 6; int PATpts = thescore->PATs; int fgpts = thescore->fieldgoals * 3; int total = tdpts + PATpts + fgpts; printf("The total score is %d.\n", total); printf("The kicker scored %d of them.\n", fgpts + PATpts); } int main() { // allocate a new score struct struct score* thescore = malloc(sizeof(struct score)); // assign the fields thescore->touchdowns = 7; thescore->PATs = 7; thescore->fieldgoals = 1; // call the function print_total(thescore); // de-allocate the memory with free free(thescore); return 0; } |
Make sure you review how this works and ask questions if you don't understand anything!
Pythonic Lists and C Arrays
In SY201, you learned about lists in Python, which can do lots of useful things, including change size when items are added or removed. When you learned about C, you learned about arrays, which can't do any of those things. Surprisingly, Python's lists are just implemented with arrays. So when we do, for example, an append to a Python list, how does that work? How long does it take? And here we get to the point: In order to understand the runtime of list operations, we have to understand what operations are being performed that Python is hiding from us.
Here is a pretty simple Python program. Hopefully you can see what it does.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | import sys import time if __name__ == "__main__": # use the first command-line argument as the list size n = int(sys.argv[1]) # the second command-line argument (if present) means to print stuff out debug = len(sys.argv) >= 3 lst = [] start = time.process_time() # start timer for i in range(n): num = 2*i + 7 lst.append(num) if debug: print(lst) end = time.process_time() # end timer elapsed = end - start print(elapsed) |
If we were to write a program which performs the same task in C, how would we do it?
Approach 1: Well, appending is pretty fast with a Linked List. Could we do that with C? Sure, using structs and pointers, we could do that. But not at first
Approach 2: The other main option is arrays. What if every time we tried to perform an append on an array, we made a new array large enough to contain one more element, and then move everything over? That would work.
Approach 3: For this, let's target what might be bad about Approach 2. In Approach 2, every single append requires making a new array, and re-filling it. Ouch. What if we changed it so that rather than allocating one more element's worth of space, when we ran out of space we added five empty spaces? Now, the next several appends will be fast, because there are empty, already allocated spaces to put that data in.
Approach 4: What if we changed it so that rather than allocating five more elements' amount of space when we run out of room, we made the array twice as big? Like Approach 3, there'd be lots of empty spaces, just an increasing number as \(n\) gets larger.
2 Implementing options 2-4 in C
Take the above python program, and place it in a file called
pylist.py
. You'll now write three C programs,
array1.c
, array5.c
,
and array_double.c
.
Increasing size by one every time
array1.c will be an implementation of Approach 2 above. I've
started it for you here; you just need to fill in the function append
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | #include <stdio.h> #include <stdlib.h> #include <time.h> // struct to hold an array and save its length struct list { int* array; int length; }; // helper function to print out a list void print_list(const struct list* lst) { int i; printf("["); if (lst->length >= 1) printf("%d", lst->array[0]); for (i = 1; i < lst->length; ++i) printf(", %d", lst->array[i]); printf("]\n"); } /* append() takes in a pointer to a list struct and an element to be added * to the array. For array1, you want to increase the array size by 1 every * time. This means allocating a new array that is one larger, copying * everything over, adding the new element, and then calling free() to * de-allocate the old array. Finally, you want to re-assign the two fields * "array" and "length" in the list struct to their new values. */ void append(struct list* lst, int num) { // delete this comment - you have to write this method! } int main(int argc, char *argv[]) { int n; sscanf(argv[1], "%d", &n); //store the first argument as n // if there is a second argument (no matter what it is), make "debug" equal 1 int debug = (argc >= 3); struct list* lst = malloc(sizeof(struct list)); // allocate space for the list struct lst->length = 0; // array starts off size 0 lst->array = malloc(lst->length * sizeof(int)); //allocate that much space // start a timer clock_t start = clock(); int i; for(i = 0; i < n; ++i) { int num = 2*i + 7; // call append() to add num to the array append(lst, num); // if we had a second argument, print out the current contents of the array if (debug) print_list(lst); } // end the timer clock_t end = clock(); double elapsed = difftime(end, start) / CLOCKS_PER_SEC; printf("%lf\n", elapsed); free(lst->array); // we're done with the array, so de-allocate it free(lst); // now free the struct itself return 0; } |
Notice that when it runs, it uses the int argc
and the
string array argv
to deal with command-line arguments.
The first argument tells how many times to append something, and
the existence of a second argument
causes it to print the elements of the array after each step. If I run my
solution, with a second argument, I get this:
$ ./array1 5 anything [7] [7, 9] [7, 9, 11] [7, 9, 11, 13] [7, 9, 11, 13, 15] 0.000022
The number on the very last line is the time it took (in seconds).
If you run it without the second argument (./array1 5
for example)
it does exactly the same amount of work, it just doesn't print anything
out except for the time.
Compile with the following command:
gcc -Wall -Wextra -g -O2 -o array1 array1.c
(The -Wall
and -Wextra
flags print out extra
warning messages that can be really useful to find bugs in your code.
The -g
flag will help if you need to do debugging/backtraces,
and -O2
tells the compiler to do some optimizations to make your
program faster.)
As a reminder, malloc
allocates space, and free
deallocates it. Everything which is allocated should be deallocated.
If you want, you can use the valgrind
program to help you find
any memory errors or leaks. For example, with my sample solution, I get:
$ valgrind --leak-check=full ./array1 100 ==12762== Memcheck, a memory error detector ==12762== Copyright (C) 2002-2013, and GNU GPL'd, by Julian Seward et al. ==12762== Using Valgrind-3.10.1 and LibVEX; rerun with -h for copyright info ==12762== Command: ./array1 100 ==12762== 0.003101 ==12762== ==12762== HEAP SUMMARY: ==12762== in use at exit: 0 bytes in 0 blocks ==12762== total heap usage: 102 allocs, 102 frees, 20,216 bytes allocated ==12762== ==12762== All heap blocks were freed -- no leaks are possible ==12762== ==12762== For counts of detected and suppressed errors, rerun with: -v ==12762== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
If my program had memory leaks, they would show up with additional information about what was causing the memory error or leak.
Five at a time
array5.c will be an implementation of Approach 3. Alter your
array1.c program so that it performs append() in that way, only building a new
array when the old one is already full. You'll have to add another field to
the struct list
definition,
as the size of the array and the number of items which have been
stored in the array are now not always the same. You'll also need to modify
the part in main
to initialize this new field.
Compile with the following command:
gcc -Wall -Wextra -g -O2 -o array5 array5.c
Doubling the size
array_double.c will be an implementation of Approach 4. A correct implementation of array5.c should make this modification fairly straightforward.
Compile with the following command:
gcc -Wall -Wextra -g -O2 -o array_double array_double.c
3 Run-time analysis
Once you're confident they are working correctly for small values of \(n\), we need to think about the running times of these three approaches. The bash script timer.sh will run one of your three programs (depending on which line is uncommented), for increasing values of \(n\), and print out a table, in TSV (tab-separated values) format. When saved to a file, such a file is openable either by a text editor or a spreadsheet program like Excel. You can simultaneously see the program running and save its output to a file by doing:
$ ./timer.sh | tee savedfile.tsv
You should run this (editing the script each time accordingly) to
create the files
pylist.tsv
,
array1.tsv
,
array5.tsv
, and
array_double.tsv
, and include all these in your submission
for the lab.
After running this script for all four programs, have a look at (or graph) the values within and answer the following questions in a file called README.txt.
- What is the total runtime of calling append \(n\) times in
array1
? Explain your answer. - Do the same for
array5
. - Do the same for
array_double
. - Which approach do you think is most similar to the implementation of append in Python? Why?
- The timing script runs your programs with up to 1 million insertions.
Even 1 million is small potatoes in large-scale data collection and analysis
these days.
Without actually running anything, estimate (for all four programs) how long they would take with 1 billion insertions (which would be 4GB of data - still pretty small really!).
4 Going farther
The rest of this lab is optional for your enrichment and enjoyment. Be sure to work on this only after getting everything else perfect. You should still submit this work, but make sure it doesn't break any of the parts you already had working correctly!
Linked lists in C
We skipped approach 1 (linked lists) above, mostly because you already did that in last week's lab. Well, it's a whole different challenge to do linked lists in C!
Create a new program linked.c
which is similar to array1.c
and
the others, except that it uses linked lists instead of arrays.
Note, to implement linked lists you will want an additional struct for a linked list node. Your node and list structures definitions might look something like this:
1 2 3 4 5 6 7 8 9 | struct node { int data; struct node* next; }; struct list { struct node* head; struct node* tail; }; |
Note, everything is done with pointers! So every time you want to create a node, you will have
to call malloc(sizeof(struct node))
, and you'll call free
to de-allocate
each node in the list at the end of your program.
Once you get linked.c
working, add it to the analysis parts (do the timing and say what
the runtime is for append), comparing to the various array versions you already completed.
Inserting at the beginning
Everything so far has been assuming we are inserting at the end of the list.
What if we wanted to add something to the front (index 0) of a list? What would
change about the running times? After copying everything to a new directory
so as not to overwrite the work you've already done, try modifying all the programs
to insert at the beginning rather than the end of the list. Then run the timing script
again. Add a paragraph to your README.txt
explaining what you found in
this experiment, and what you think it means about the running time for adding to the
front rather than the end of a list.