Lab 12: Files and Command Line Arguments, plus separate compilation

Feb 15, 2024  │  m. Feb 11, 2024 by Charlotte Curtis

Setup

There is no starter code for this lab, but you may include it in your labs repo (just make sure to add and commit as usual or you won’t be able to pull changes in the future). You can also keep it separate if you prefer.

Create a new directory for your lab, then cd into it.

Objectives

Part 1: Command line arguments

  1. Create a new file named main.cpp

  2. Define the usual main function and necessary #includes, but instead of int main(), define your main function as follows:

    int main(int argc, char *argv[]) {
        // argc is the number of arguments
        // argv is an array of c-strings containing the arguments
    }
    

    This is the standard way to define main when you want to use command line arguments.

  3. Add a cout statement to print out the number of arguments, then a for loop to print out each argument. Compile and run your program with a few different arguments to see how it works. For example:

    $ g++ main.cpp
    $ ./a.out hello world
    

    should print:

    3
    ./a.out
    hello
    world
    

Part 2: Reading and writing files

  1. Create a new file named input.txt and add some text to it. This can be anything you like, but it should be at least a few lines long. For example:

     This is a file.
     It has multiple lines.
     It's not very interesting.
    
  2. Back in main.cpp, #include <fstream> to be able to declare and use ifstream and ofstream objects. Then, declare an ifstream object and pass the name of your first argument to its constructor. For example:

    ifstream in(argv[1]);
    
  3. Add a while loop to read in each line of the file using the getline function and print it out to cout. At this point, your program should display the contents of whatever file you pass as the first argument. For example:

    $ g++ main.cpp
    $ ./a.out input.txt
    This is a file.
    It has multiple lines.
    It's not very interesting.
    
  4. Add a second argument to your program, then declare an ofstream object and pass the name of your second argument to its constructor as in step 2. Modify your while loop to write each line to the second file instead of cout. At this point, your program should copy the contents of the first file to the second file. For example:

    $ g++ main.cpp
    $ ./a.out input.txt output.txt
    $ cat output.txt
    This is a file.
    It has multiple lines.
    It's not very interesting.
    

Part 3: A multi-file program with manual compilation

  1. Create a new file named util.cpp and add the following content:

    #include <iostream>
    using namespace std;
    
    void util() {
        cout << "Hello from util!" << endl;
    }
    

    This file contains a simple function that prints a message to the console.

  2. Try calling the util function from main, then compile the usual way (g++ main.cpp).

  3. The previous step shouldn’t have worked, because the compiler doesn’t know that util exists. Add a forward declaration to main by adding just the function prototype for util, not the whole inplementation, i.e.:

    #include <iostream>
    using namespace std;
    
    void util(); // function prototype
    
    int main() {...}
    

    Try compiling with g++ main.cpp again.

  4. Okay, so that still didn’t work, but now it’s saying there’s a linker error. We need to compile both main.cpp and util.cpp into objects, then link them together. Use the following commands (note the -c after g++!):

    $ g++ -c util.cpp # creates util.o
    $ g++ -c main.cpp # creates main.o
    $ g++ main.o util.o # creates a.out
    

    The last step does the linking and creates the final program, so here’s where you can define a name for your program with the -o option as usual.

  5. Looking at your two files, there’s a fair bit of redundancy. It’s normal to keep the #include <iostream> and using namespace std; in each file that needs it, but the function prototype should be moved to a header file. Create a new file named util.h and add the following content:

    #ifndef UTIL_H
    #define UTIL_H
    
    void util();
    
    #endif // UTIL_H
    

    Then, in util.cpp and main.cpp, delete the function prototype and add #include "util.h". It feels like we just did a lot more work, but doing things this way allows you to write your prototypes in just one place instead of two, reducing room for error.

    Recompile the program using the same commands as above. Note that the .h file is not compiled directly, it is just #includeed from the .cpp files.

  6. Define a new function named copy in util.cpp with the following declaration in util.h:

    void copy(istream &in, ostream &out);
    

    This function should read from in and write to out. Move your file reading and writing code from main to copy, then call copy from main. Recompile your program and verify that it still works.

    Note: istream and ostream are the base classes for ifstream and ofstream, respectively. This means that copy can accept either an ifstream or cin as its first argument, and either an ofstream or cout as its second argument.

Part 4: Automating the build process with make

The process to compile and link multiple modules is annoying and error-prone, so most projects automate this in some way. A popular tool is called make, which processes a user-defined makefile (or Makefile if you prefer).

  1. In your project directory, create a new file named makefile and open it in Emacs.

  2. Add the following contents (notice that Emacs is syntax-aware):

    # This is a makefile. Comments start with #
    
    # The first instruction needs to be the main executable
    testProg: main.o util.o
        g++ main.o util.o –o testProg
        # Be very careful to use tab instead of spaces for indentation!
        # Emacs should help you out here as well.
    
    main.o: main.cpp
        g++ -c main.cpp
    
    util.o: util.cpp
        g++ -c util.cpp
    

    The rules of a makefile are defined as:

    target: dependency-list
        command
        command
        ...
        command
    

    where the target is the thing to build and the dependency list is the other targets necessary to build the current target.

  3. Test out your new file! Type make and press Enter. This will likely do nothing other than inform you that testProg is up to date. To make a change, open main.cpp in Emacs and add a space somewhere, then save the file (or just touch main.cpp to update the timestamp). Run make again, and this time it should list the commands it runs. Notice that it does not make the target util.o, as util.cpp hasn’t been changed.

  4. Add compiler flags: The make utility runs in its own shell, so your alias to g++ (to include the various warning options) doesn’t work. Don’t believe me? Try doing the following:

    1. Declare (but don’t initialize) a variable, e.g. int x;
    2. cout your uninitialized variable
    3. Compile with g++ and verify that you get a warning.
    4. Delete main.o, then run make and verify that you don’t get a warning.

    Compiler flags tell the compiler how to compile your code, and warnings can be very useful for identifying problems! Furthermore, the -ansi flag constrains your code to the C++98 specification - if you don’t test with -ansi, you run the risk of your code not working when you submit an assignment and I compile it.

    To add compiler flags to your makefile, modify your g++ -c lines as follows:

    testProg: main.o util.o
        g++ main.o util.o –o testProg
        # Be very careful to use tab instead of spaces for indentation!
        # Emacs should help you out here as well.
    
    main.o: main.cpp
        g++ -ansi -pedantic-errors -Wall -Wconversion -c main.cpp
    
    util.o: util.cpp
        g++ -ansi -pedantic-errors -Wall -Wconversion  -c util.cpp
    

    As demonstrated in class yesterday, you may need to type this manually rather than copy pasting, as the - character may be rendered differently. Additionally, makefiles need TAB as indentation.

Getting fancy with make: Optional extras

There’s a whole lot more that can be done with make, and makefiles “in the wild” can get pretty intense. For future projects, you’ll probably want to copy a makefile from a previous project and modify it as needed. Here’s a few more features that you might find useful.

  1. Variables: You can define variables in a makefile and use them in your commands. For example, you could define a variable CXXFLAGS to hold your compiler flags, then use it in your commands as follows:

    TARGET = testProg
    OBJECTS = main.o util.o
    CXXFLAGS = -ansi -pedantic-errors -Wall -Wconversion
    
    $(TARGET): $(OBJECTS)
        g++ $(OBJECTS) –o $(TARGET)
    
    main.o: main.cpp util.h
        g++ $(CXXFLAGS) -c main.cpp
    
    util.o: util.cpp util.h
        g++ $(CXXFLAGS) -c util.cpp
    

    At first glance, this seems more complicated, but it’s actually more maintainable, as you can change your compiler flags, program name, or objects in just one place instead of multiple.

    We’ve also added util.h as a dependency for main.o and util.o. This file still isn’t part of the compile command (and never should be), but putting it in the dependency list here makes sure that main.o and util.o are recompiled if util.h changes.

    Note: variables are defined in the format VARIABLE_NAME = value, and are referenced in commands as $(VARIABLE_NAME). They don’t need to be in all caps, but it’s a convention.

  2. Add a clean target: If you really want to force everything to be recompiled (a good idea before submitting an assignment, for example), you need to delete all the .o files and the final program. You can do this manually, but while we’re automating, why not add a clean target to your makefile?

    1. Add the following to the end of your makefile:
      clean:
          rm -f *.o $(TARGET)
      
      This creates a new target named clean that deletes all object files and the final program. The -f flag tells rm to ignore errors, so it won’t complain if the files don’t exist.
    2. Run make clean and verify that it deletes the files.
  3. Clean and rebuild: If you want to force everything to be recompiled, you can run make clean followed by make. This is a bit annoying, so let’s add an all target to our makefile:

    all: clean $(TARGET)
    

    This tells make that the all target depends on clean, then$(TARGET) being completed. There’s no additional commands here.

    To run your new automagic rebuild, type make all and verify that it deletes the files and rebuilds the program.

If you’d like to delve more into what make can do, check out the GNU Make Manual .



Previous: Lab 11: Structures
Next: Lab 13: Pointers tutorial