CS 112 Lab 1: Test-Driven Development and Data Structures

... but test everything; hold fast to what is good...
                          -- 1 Thessalonians 5:21

Objectives:

In this exercise, you will use test-driven development to:
  1. Build a Song class to store song data.
  2. Build a PlayList data structure that stores Song objects.

Introduction

You've been hired by a new startup company that hopes to revolutionize the music streaming business. You have been assigned the task of writing the software the company will use for its song playlists.

The songs in a playlist will be stored in a text file whose name is the name of the playlist (e.g., MyFavorites.txt). When a given user chooses a playlist, your software needs to open the file and read the song information from the file into memory. Once the song information is in memory, your software needs to let the user:

Design

To begin solving this problem, we will

  1. Create a class named Song, capable of storing the information about one song; and
  2. Create a class named PlayList that stores the Songs for a given playlist and provides methods to perform the operations above.

We will also write test classes and a testing application to try to ensure that each of these classes works as expected.

Getting Started

Create a new project named lab01 in which to store the files related to this project. With that project selected, use Project > Properties > C/C++ Build > Behavior > Build on resource save to automatically build your project each time you save it. You will need to do this for every Eclipse project, so make a habit of doing it each time you create a new project.

As described previously, we want to test our software to ensure that it works as expected. Your boss has supplied you with the following testSongs playlist, so inside this project, create a new text file named testSongs.txt containing the following:

Call Me Maybe
Carly Rae Jepsen
2012

Let It Be
The Beatles
1967

Let It Be
Joan Baez
1971

Penny Lane
The Beatles
1967

Note the structure of this song and playlist information. A song has its title, artist/group, and year, each on separate lines. Within the playlist, each song is followed by a blank line to separate it from the next one. This playlist file structure will determine how we write some of our methods.

Test-Driven Development

To build this software, we are going to use a methodology called test-driven development. The basic idea is that, as we think of an operation that a class should provide, we:
  1. Write a test to evaluate whether or not the method works;
  2. Write the method; and
  3. Run the test to see whether or not the method is working correctly. If the method fails the test, revise the method until it passes the test.
For each class in our application (e.g., Song, PlayList), we will create a test class (e.g., SongTester, PlayListTester) that stores and runs the tests for that class. Since each C++ class has two files -- a header (.h) and an implementation (.cpp) file -- we might envision the structure of today's project as follows:

A structure diagram for lab 01

(In this diagram, the diamond-shaped symbols denote the "has a" relationship.)

We will thus be creating nine files during today's exercise. Let's get started!

Part 1: The Song Class

Since a playlist is made up of songs, we will begin with the Song class (sort of). First, we will create our testing application, then a SongTester class, and finally the Song class itself.

The Testing Application

To run our tests, we can create a source file named main.cpp containing a simple main() function:

   /* main.cpp tests the classes in our project.
    * Student Name:
    * Date:
    * Begun by: Joel Adams, for CS 112 at Calvin College.
    */
 
   #include "SongTester.h"

   int main() {
       SongTester sTester;
       sTester.runTests();
   }
Be sure you customize the opening documentation by adding your name and the date. Every C++ source file you create for this course should contain such opening documentation that indicates: Note that the program is quite simple: it just creates a SongTester object, and tells that object to run its tests. This simplicity is typical of test-driven development.

However, when we try to build this program, we get compilation errors. The problem is that there is no class SongTester nor header file SongTester.h.

The SongTester Class

To fix the compilation errors, we must create a SongTester class to store the tests we will run on the methods of our Song class. Create a new class named SongTester that looks like this at the outset (again, customizing its opening documentation):

   /* SongTester.h declares a test-class for class Song.
    * Student Name:
    * Date:
    * Begun by: Joel Adams, for CS 112 at Calvin College.
    */

   #ifndef SONGTESTER_H_
   #define SONGTESTER_H_

   class SongTester {
   public:
      void runTests();
   };
  
   #endif /*SONGTESTER_H_*/

Generally speaking, C++ header (.h) files contain class and method/function declarations; function definitions are placed in the implementation (.cpp) files. At present, our SongTester class contains a prototype or declaration of a single method named runTests(). (We'll be adding other method prototypes shortly.)

A C++ class usually consists of two files:

  1. a header file whose name ends in .h, and
  2. an implementation file whose name ends in .cpp
so we also need to create SongTester.cpp:

   /* SongTester.cpp defines the test-methods for class SongTester.
    * Student Name:
    * Date:
    * Begun by: Joel Adams, for CS 112 at Calvin College.
    */
 
   #include "SongTester.h"
   #include <iostream>
   using namespace std;
 
   void SongTester::runTests() {
      cout << "Testing class Song..." << endl;

      cout << "All tests passed!" << endl;
   } 

Note that since we have no actual tests to run (yet), we just provide a minimal definition for our runTests() method for now.

At this point, you should able to build your project without errors, and run it to produce the messages:

   Testing class Song...
   All tests passed!

If you have errors, find and fix them before continuing.

Testing the Default Constructor

We should be able to declare Song objects with the expectation that they will be initialized to a sane default value. Such initialization is the job of a class's default constructor.

If we choose "empty" strings and zero values as our default values, we might build a test method in SongTester.cpp to test our constructors as follows:

   void SongTester::testConstructors() {
       cout << "- constructors ... " << flush;
       // default constructor
       Song s;
       assert( s.getTitle() == "" );
       assert( s.getArtist() == "" );
       assert( s.getYear() == 0 );
       cout << " 0 " << flush;
       cout << " Passed!" << endl;
   } 
That is, we expect to be able to send a Song object messages like getTitle(), getArtist(), and so on, to access the values of its instance variables, so we use those to test its values.

In order for this test-method to be a method of class SongTester, we must add a prototype for the method to our SongTester class:

   class SongTester {
   public:
      void runTests();
      void testConstructors();
   }; 

You will also need to invoke this method in our SongTester::runTests() method (in SongTester.cpp):

   void SongTester::runTests() {
      cout << "Testing class Song..." << endl;
      testConstructors();
      cout << "All tests passed!" << endl;
   } 
and finally #include any header files this method requires (e.g., <cassert>).

Next, try to build your project. The compiler should generate an error message something like the following:

   ../SongTester.cpp: In member function 'void SongTester::testConstructors()':
   ../SongTester.cpp:21:5: error: 'Song' was not declared in this scope
        Song s;
   ...
Note that this is expected, as we have not yet created our Song class. Let's do so!

The Song Class

From the structure of our boss's test file, our Song class needs to store these attributes about a song:

Our Song class will therefore need three data members or instance variables to store these attributes. We might envision a Song object named aSong as follows:

A Song object

Instance variables should generally be declared as private:, to keep developers from writing software that depend upon them, so we can start by creating the following class declaration in Song.h:

   /* Song.h declares a class for storing song information.
    * Student Name:
    * Date:
    * Begun by: Joel Adams, for CS 112 at Calvin College.
    */
 
   #ifndef SONG_H
   #define SONG_H

   #include <string>
   using namespace std;

   class Song { 
   public:
 
   private:
      string   myTitle;
      string   myArtist;
      unsigned myYear;
   };

   #endif /*SONG_H_*/

Since our SongTester class is trying to create a Song object, it must see the declaration of class Song. To do so, it must #include "Song.h" so take a moment to add that directive to SongTester.cpp.

Since a C++ class has both a header file and an implementation file, so we also need to create the file Song.cpp:

   /* Song.cpp defines the methods for class Song (see Song.h).
    * Student Name:
    * Date:
    * Begun by: Joel Adams, for CS 112 at Calvin College.
    */
 
    #include "Song.h"

Defining the Default Constructor and Getter Methods

As the file's header comment indicates, Song.cpp is where we will define the methods of our Song class. The SongTester::testConstructors() method invokes our Song default constructor, plus three Song "getter" methods. The test-method tells us the default values the Song class should provide, and since it is the role of the default constructor to initialize the members to these values, we might define these four operations as follows:

   /* Song default constructor
    * Postcondition: myTitle and myArtist are empty strings
    *            && myYear == 0.
    */
   Song::Song() {
      myTitle = myArtist = "";
      myYear = 0;
   }

   /* getter method for myTitle
    * Return: myTitle
    */
   string Song::getTitle() const {
      return myTitle;
   }

   /* getter method for myArtist
    * Return: myArtist
    */
   string Song::getArtist() const {
      return myArtist;
   }

   /* getter method for myYear
    * Return: myYear
    */
   unsigned Song::getYear() const {
      return myYear;
   }
Note that a constructor has no return-type (not even void). Each of the other operations requires a return-type appropriate to the value the test indicates the method is returning.

We must then add prototypes for these methods to Song.h:

   class Song { 
   public:
      Song();
      string getTitle() const;
      string getArtist() const;
      unsigned getYear() const;
   private:
      string   myTitle;
      string   myArtist;
      unsigned myYear;
   };

Now that we have a Song class created, we must #include "Song.h" in our SongTester.cpp file, so that our testConstructors() method can declare its Song..

With that change, our project should compile without any errors.

   Testing class Song...
   - constructors ... 0 Passed!
   All tests passed!

Congratulations! You've just written your first C++ methods using test-driven development!

Brief Aside

The key to test-driven development is sometimes called programming by intent -- you have to know what you intend a method to do, write a test-method that thoroughly tests your intent, and then define the methods needed to pass your test.

Test-driven development is the sort of thing that gets easier with practice, so we are starting with "simple" methods before we progress to more complicated ones.

As we will see, test-driven development also supports regression-testing -- making sure that changes you make to a class in one method do not cause another method to "break".

Testing the Explicit-Value Constructor

In addition to a default constructor, most classes should provide an explicit-value constructor that lets the programmer initialize its members to specific values. For example, we might invoke such a constructor for our Song class by adding the following code to our testConstructors() method:

   void SongTester::testConstructors() {
      cout << "- constructors... " << flush;
      // default constructor
      Song s;
      assert( s.getTitle() == "" );
      assert( s.getArtist() == "" );
      assert( s.getYear() == 0 );
      cout << " 0 " << flush;
      // explicit constructor
      Song s1("Badge", "Cream", 1969);
      assert( s1.getTitle() == "Badge" );
      assert( s1.getArtist() == "Cream" );
      assert( s1.getYear() == 1969 );
      cout << " 1 " << flush;

      cout << "Passed!" << endl;
   }

Next, try to build your project. As expected, we get a compilation error because the compiler cannot (yet) find a prototype or definition for our explicit-value constructor:

   ../SongTester.cpp: In member function 'void SongTester::testConstructors()':
   ../SongTester.cpp:28:35: error: no matching function for call to 'Song::Song(const char [6], const char [6], int)'
        Song s1("Badge", "Cream", 1969);
   ...  
To eliminate this error, we must define the explicit-value constructor!

The Explicit-Value Constructor

Once again, we write a method to satisfy our test. The test indicates the order in which the arguments are passed to the constructor, so add this definition to the definitions in Song.cpp:

   /* Explicit constructor
    * @param: title, a string
    * @param: artist, a string
    * @year: an unsigned int.
    * Postcondition: myTitle == title &&
    *                myArtist == artist &&
    *                myYear == year.
    */
   Song::Song(const string& title, const string& artist, unsigned year) {
      myTitle = title;
      myArtist = artist;
      myYear = year;
   }

Note that since title and artist are string objects whose values flow into but not out of the constructor, we pass them using pass-by-const-reference. By contrast, year is an unsigned int, which is a primitive type, so we pass it using pass-by-value.

Next, add a prototype for this constructor to our Song class. You should now have two constructors declared there: a default-value constructor and an explicit-value constructor. When you have done so, our test-program should compile and run correctly:

   Testing class Song...
   - constructors ...  0  1  Passed!
   All tests passed!

To see what happens when you make a mistake, simulate making a mistake by "commenting out" one of the lines in our constructor:

   Song::Song(const string& title, const string& artist, unsigned year) {
      myTitle = title;
      myArtist = artist;
      //myYear = year;
   }
Now when our test-method runs, the test for the explicit-value constructor will fail:
   Testing class Song...
   - constructors ...  0 Assertion failed: (s1.getYear() == 1969), function testConstructors, file ../SongTester.cpp, line 31.
By checking whether or not your methods pass the tests, test-driven development provides a methodology to help you keep from making mistakes.

"Uncomment" the myYear = year; line in your constructor, rebuild your project, and double-check that your code passes the tests before continuing.

An Input Method

Since our songs are stored in a text file, it appears that we will need a method to fill a Song object with data from a file, or more precisely, from an ifstream to a file. To test such a method, we can use the test file our boss provided.

We can then define a new test-method in SongTester.cpp to test our input method, which we will call readFrom():

   void SongTester::testReadFrom() {
      cout << "- readFrom()... " << flush;
      ifstream fin("testSongs.txt");
      assert( fin.is_open() );
      Song s;

      // read first song in test playlist
      s.readFrom(fin);
      assert( s.getTitle() == "Call Me Maybe" );
      assert( s.getArtist() == "Carly Rae Jepsen" );
      assert( s.getYear() == 2012 );
      cout << " 0 " << flush;

      // read second song in test playlist
      string separator;
      getline(fin, separator);
      s.readFrom(fin);
      assert( s.getTitle() == "Let It Be" );
      assert( s.getArtist() == "The Beatles" );
      assert( s.getYear() == 1967 );
      cout << " 1 " << flush;

      // read third song in test playlist
      getline(fin, separator);
      s.readFrom(fin);
      assert( s.getTitle() == "Let It Be" );
      assert( s.getArtist() == "Joan Baez" );
      assert( s.getYear() == 1971 );
      cout << " 2 " << flush;

      fin.close();
      cout << "Passed!" << endl;
   }
Using this as a model, add a final test to this method that reads the fourth song from our test playlist, checks that it has the correct values, and if so, displays the value 3.

Note how the testReadFrom() method deals with the blank lines between the 1st and 2nd, and the 2nd and 3rd songs. To work correctly, the test-code you add will need to deal with the blank line between the 3rd and 4th songs...

Then try to build your project. You should see several error messages. As before, we must:

When the only error we see is the compiler being unable to find our readFrom() method, we are ready to define that method!

Note that our test method invokes readFrom() multiple times. It is generally a good idea to test a method multiple times, as invoking a method multiple times will sometimes reveal errors that do not show up when the method is only invoked once.

Defining The Input Method

Now that we know what our input method has to do to pass the test, we write that method. We might try the following definition:

   /* Song input method...
    * @param: in, an istream
    * Precondition: in contains the title, artist, and year data for a Song.
    * Postcondition: the title, artist, and year data have been read from in &&
    *                 myTitle == title &&
    *                 myArtist == artist &&
    *                 myYear == year.
    */
   void Song::readFrom(istream& in) {
      getline(in, myTitle);
      getline(in, myArtist);
      string yearString;
      getline(in, yearString);
      myYear = atoi( yearString.c_str() );
   }

Note that because our method modifies the stream it receives throught its parameter in, we declare that parameter using pass-by-reference.

Note also that we use the getline() method to read in the various string values. We do this because getline() will read an entire line of text, whereas the string version of operator>> will only read a word of text.

Note finally that for simplicity's sake, we also use getline() to read the year value; however getline() only reads strings, so we read the year as a string into a string variable named yearString. To store this in our year instance variable, we must convert this string to an integer. One way to do this is to use the C function atoi(), which stands for ascii-to-integer. However, being a C function, atoi() requires a C-style string, not a C++-style string. To convert yearString to a C-style string, we can send it the c_str() message, which retrieves the C-style string from within yearString. We then pass that C-style string to the atoi() function, which converts that C-style string to the corresponding integer value. Whew!

In order for this method to compile correctly, add a prototype for this method to the Song class, as well as any #include directives you need in order for this method to compile correctly (e.g., <cstdlib>).

Once our program compiles correctly, run it and verify that it passes all our tests:

   Testing class Song...
   - constructors ...  0  1  Passed!
   - readFrom()...  0  1  2  3  Passed!
   All tests passed!

Once we have passed all our tests, we can continue to the next operation.

Testing An Output Method

One of the required playlist operations is to print all the songs with a given word in their title, which means we also need an operation to print/output a song. Since C++ output is via ostream objects, we will pass our operation an ostream to which a Song can write its information. The ostream class is a superclass of the ofstream class, so this will allow us to use the same method for writing both to the console (via cout) and to a file (via an ofstream). We will name our method writeTo().

Testing our writeTo() method using assert() takes some effort:

   void SongTester::testWriteTo() {
      cout << "- writeTo()... " << flush;

      // declare three songs
      Song s1("Badge", "Cream", 1969);
      Song s2("Godzilla", "Blue Oyster Cult", 1977);
      Song s3("Behind Blue Eyes", "The Who", 1971);

      // write the three songs to an output file
      ofstream fout("testSongOutput.txt");
      assert( fout.is_open() );
      s1.writeTo(fout);
      s2.writeTo(fout);
      s3.writeTo(fout);
      fout.close();

      // use readFrom() to see if writeTo() worked
      ifstream fin("testSongOutput.txt");
      assert( fin.is_open() );
      Song s4, s5, s6;

      // read and check the first song
      s4.readFrom(fin);
      assert( s4.getTitle() == "Badge" );
      assert( s4.getArtist() == "Cream" );
      assert( s4.getYear() == 1969 );
      cout << " 0 " << flush;

      // read and check the second song
      s5.readFrom(fin);
      assert( s5.getTitle() == "Godzilla" );
      assert( s5.getArtist() == "Blue Oyster Cult" );
      assert( s5.getYear() == 1977 );
      cout << " 1 " << flush;

      // read and check the third song
      s6.readFrom(fin);
      assert( s6.getTitle() == "Behind Blue Eyes" );
      assert( s6.getArtist() == "The Who" );
      assert( s6.getYear() == 1971 );
      cout << " 2 " << flush;

      fin.close();
      cout << " Passed!" << endl;
  }
Two things are worth noting about this test:
  1. To automate the test, we open an ofstream to a file and use our writeTo() method to write three songs to that file. After closing the stream, we then open an ifstream to the file and use our readFrom() method to read the songs from the file into new Song variables. We can then assert what the values in thos songs should be. By using files instead of interactive I/O, this approach automates the test, so that the user need not enter any values manually.
  2. To thoroughly test the readFrom() and writeTo() methods, we use multiple songs.

After placing a prototype of testWriteTo() in our SongTester class and invoking this method in runTests(), the only error we should generate is due to writeTo() not being defined:

   ../SongTester.cpp: In member function 'void SongTester::testWriteTo()':
   ../SongTester.cpp:72:8: error: 'class Song' has no member named 'writeTo'
        s1.writeTo(fout);
           ^
   ...
This means it is time for us to define that method!

The Output Method

Since the test tells us what we expect our method to do, we just have to define our method in a way that passes the test:
   /* Song output...
    * @param: out, an ostream
    * Postcondition: out contains myTitle, a newline,
    *                             myArtist, a newline,
    *                             myYear, and a newline.
    */
   void Song::writeTo(ostream& out) const {
	out << myTitle << '\n'
	    << myArtist << '\n'
	    << myYear  << '\n';
   }
Note that our method changes its parameter out so we define that parameter using call-by-reference.

Note also that an output method should not change any of its class's instance variables, so we define writeTo() as a const method. This tells the compiler that if we should inadvertantly change an instance variable in this method, the compiler should generate an error to alert us to our mistake.

Define this method and place a prototype for it in your Song class. Then compile and run your test-application. If all is well, pass all the tests should pass.

Congratulations! You have just built a reasonably complex Song class!

Part 2: The PlayList Class

Our next class is the PlayList class, in which we will store the songs of a given playlist. Since it must store multiple songs, we might envision a PlayList object named aPlayList containing the songs from testSongs.txt as follows:

A PlayList object

Testing the PlayList Class

To test this new class, we will create a PlayListTester class, similar to the SongTester class we built previously.

We start by returning to main.cpp and adding code that (a) #includes the header file PlayListTester.h, (b) creates a PlayListTester object, and (c) invokes its runTests() method:

   #include "SongTester.h"
   #include "PlayListTester.h"

   int main() {
       SongTester sTester;
       sTester.runTests();
       PlayListTester plTester;
       plTester.runTests();
   }

If we try to build our project, we get errors because neither class PlayListTester nor the file PlayListTester.h exist. To proceed, we must create them, which we might do as follows:

   /* PlayListTester.h tests the PlayList class.
    * Student Name:
    * Date:
    * Begun by: Joel Adams, for CS 112 at Calvin College.
    */

   #ifndef PLAYLISTTESTER_
   #define PLAYLISTTESTER_

   class PlayListTester {
   public:
       void runTests();
   };

   #endif /*PLAYLISTTESTER_*/

and

   /* PlayListTester.cpp defines the PlayList test-methods.
    * Student Name:
    * Date:
    * Begun by: Joel Adams, for CS 112 at Calvin College.
    */
 
   #include "PlayListTester.h"
   #include <iostream>
   using namespace std; 

   void PlayListTester::runTests() {
      cout << "\nTesting class PlayList..." << endl;

      cout << "All tests passed!" << endl;
   }

This should be sufficient to allow our project to build and run successfully:

   Testing class Song...
   - constructors ...  0  1  Passed!
   - readFrom()...  0  1  2  3  Passed!
   - writeTo()...  0  1  2  Passed!
   All tests passed!

   Testing class PlayList...
   All tests passed!

Note that we do not replace our SongTester tests. We want to continue to continue to re-run the Song tests each time we test our PlayList methods, to make sure that nothing we write breaks anything that worked previously.

This kind of testing -- in which we rerun all of our tests every time we modify the system to ensure that our modifications haven't broken something -- is called regression testing. Regression testing is a powerful technique for writing reliable software.

Testing the PlayList Constructor

Recall that a playlist's song-data is stored in a file. Because of this, it really doesn't make much sense to build a default-constructor for our PlayList class, at least not yet. Instead, we will build an explicit constructor to which we pass the name of a playlist file as an argument. Our constructor can get the name of the file via its parameter, and then read the playlist's data from that file.

To test our constructor, we can once again use the testSongs.txt file that our boss provided. We can then write a method to test our PlayList constructor:

   void PlayListTester::testConstructors() {
      cout << "- constructors..." << flush;
      PlayList pList("testSongs.txt");
      assert( pList.getNumSongs() == 4 );
      cout << " 0 " << flush;

      cout << " Passed!" << endl;
   }

Note that our test method (a) invokes an explicit-value PlayList constructor, passing it the name of our playlist file; and (b) checks that this worked by checking that the PlayList contains four songs, the number in the playlist file.

We must of course invoke this method in runTests():

   void PlayListTester::runTests() {
      cout << "Testing class PlayList..." << endl;
      testConstructors();
      cout << "All tests passed!" << endl;
   }

and place its prototype in our PlayListTester class:

   class PlayListTester {
   public:
      void runTests();
      void testConstructors();
   }; 

When we try to build, the compiler will generate errors because we have not yet defined a PlayList class. That means it's time to do so!

The PlayList Class

Given the functionality required by our testConstructors() method, we might declare class PlayList as follows:

   /* PlayList.h declares class PlayList.
    * Student Name:
    * Date:
    * Begun by: Joel Adams, for CS 112 at Calvin College.
    */
   
   #ifndef PLAYLIST_H_
   #define PLAYLIST_H_

   #include <string>
   using namespace std;

   class PlayList {
   public:
      PlayList(const string& fileName);
      unsigned getNumSongs() const;
   };

   #endif /*PLAYLIST_H_*/
Note that we declare an explicit-value constructor, which has a parameter to store the name of the playlist file; and a getNumSongs() method, since the test requires that.

Note also that since the parameter for our PlayList constructor is a string (which is a class type), we declare it using pass-by-const-reference.

Note finally that since the getNumSongs() method should not change any of our instance variables, we declare it as a const method.

Take a moment to #include "PlayList.h" in PlayListTester.cpp, so that our test-methods can see class PlayList; and <cassert> so that they can use assert().

If you try to build the project at this point, there will still be errors because we have declared the PlayList() explicit-value constructor and the getNumSongs() method, but we have not defined them. We can fix that by adding the following stub definitions to PlayList.cpp:

   /* PlayList.cpp defines the PlayList methods.
    *
    * Student Name:
    * Date:
    * Begun by: Joel Adams, for CS 112 at Calvin College.
    */

   #include "PlayList.h"

   /* PlayList constructor
    * @param: fileName, a string
    * Precondition: fileName contains the name of a playlist file.
    */
   PlayList::PlayList(const string& fileName) {

   }

   /* Retrieve length of the playlist
    * Return: the number of songs in the playlist.
    */
   unsigned PlayList::getNumSongs() const {
	
   }
With that addition, our program should build without errors. Make sure that this is the case before proceeding.

While our program builds, when we try to run it, it fails our test:

   Testing class Song...
   - constructors ...  0  1  Passed!
   - readFrom()...  0  1  2  3  Passed!
   - writeTo()...  0  1  2  Passed!
   All tests passed!

   Testing class PlayList...
   - constructors...Assertion failed: (pList.getNumSongs() == 4), function testConstructors, file ../PlayListTester.cpp, line 22.

To pass the test, we must fill in the stubs of our PlayList constructor and getNumSongs() methods.

The PlayList Constructor

Before we can complete the constructor definition, we have to decide how we are going to store the playlist's Song objects. One way we might do so is using the vector class-template from the C++ standard template library (STL). To do this, we can add an instance variable to our PlayList class whose type is vector<Song> and whose name is mySongs, as shown below:

   /* PlayList.h declares class PlayList.
    * Student Name:
    * Date:
    * Begun by: Joel Adams, for CS 112 at Calvin College.
    */

   #ifndef PLAYLIST_H_
   #define PLAYLIST_H_

   #include "Song.h"
   #include <vector>   // STL vector
   #include <string>
   using namespace std;

   class PlayList {
   public:
      PlayList(const string& fileName);
      unsigned getNumSongs() const;
   private:
      vector<Song> mySongs;
};

#endif /*PLAYLIST_H_*/

A vector is an array that can grow or shrink as the program runs. For this reason, a vector is sometimes called a dynamic array.

With a vector data structure in which to store our songs, we can define our PlayList() constructor as follows:

   /* PlayList.cpp ...
    * ...
    */
 
   #include "PlayList.h"
   #include <fstream>      // ifstream
   #include <cassert>      // assert()
   using namespace std;

   /* PlayList constructor
    * @param: fileName, a string
    * Precondition: fileName contains the name of a playlist file.
    */
   PlayList::PlayList(const string& fileName) {
      // open a stream to the playlist file
      ifstream fin( fileName.c_str() );
      assert( fin.is_open() );

      // read each song and append it to mySongs 
      Song s;
      string separator;
      while (true) {
          s.readFrom(fin);
          if ( !fin ) { break; }
          getline(fin, separator);
          mySongs.push_back(s);
      }

      // close the stream
      fin.close();
   }

Note that this method uses a vector method named push_back() to append a Song object to our mySongs instance variable. If the vector is full, the push_back() method makes it bigger to accomodate the value being appended.

Make certain that this much builds correctly before proceeding.

To pass our test, our getNumSongs() method just needs to return the number of songs in mySongs. Since mySongs is a vector, we can use the vector method size(), which returns the number of items in a vector. We can thus define getNumSongs() as follows:

   /* Retrieve length of the playlist
    * Return: the number of songs in the playlist.
    */
   unsigned PlayList::getNumSongs() const {
      return mySongs.size();
   } 
}

With that change, your program should build correctly, and when you run it, it should pass all our tests. Make certain this is the case before proceeding.

Searching by Artist

Last but not least, let's see how to implement one of the operations our boss told us our software must support. The boss said we must be able to search a playlist for all the songs by a given artist, so let's build a test for this. Here is one way we might do so:

   void PlayListTester::testSearchByArtist() {
      cout << "- searchByArtist()... " << flush;
      // load a playlist with test songs
      PlayList pList("testSongs.txt");
	
      // empty case (0)
      vector<Song> searchResult = pList.searchByArtist("Cream");
      assert( searchResult.size() == 0 );
      cout << " 0 " << flush;

      // case of 1
      searchResult = pList.searchByArtist("Baez");
      assert( searchResult.size() == 1 );
      assert( searchResult[0].getTitle() == "Let It Be" );
      cout << " 1 " << flush;

      // case of 2
      searchResult = pList.searchByArtist("Beatles");
      assert( searchResult.size() == 2 );
      assert( searchResult[0].getTitle() == "Let It Be" );
      assert( searchResult[1].getTitle() == "Penny Lane" );
      cout << " 2 " << flush;

      cout << " Passed!" << endl;
   }

Since searchByArtist() needs to return all songs in our playlist by that artist, we need a data structure to store those songs. We thus use a vector<Song> to store this info. This implies that searchByArtist() must return a vector<Song>.

Note also that to exercise the various possibilities, our method tests

Making assumptions is always risky, but we will assume that if our method works correctly for these three cases, it will work correctly for others.

If you try to build your project, the compiler will not find a declaration of this test-method in our PlayListTester class:

   ../PlayListTester.cpp:28:41: error: no 'void PlayListTester::testSearchByArtist()' member function declared in class 'PlayListTester'
    void PlayListTester::testSearchByArtist() { 
so add a prototype for this method to PlayListTester.h.

If you then try to build your project, the compiler will be unable to find a searchByArtist() method in class PlayList:

   ../PlayListTester.cpp: In member function 'void PlayListTester::testSearchByArtist()':
   ../PlayListTester.cpp:34:41: error: 'class PlayList' has no member named 'searchByArtist'
          vector searchResult = pList.searchByArtist("Cream");

That means it's time for us to define the searchByArtist() method!

The searchByArtist() Method

To build our searchByArtist() method, we might:

  1. Declare an empty vector v in which to store our results;
  2. Iterate through the Song objects in mySongs, checking whether the string returned by getArtist() contains the name we receive via the parameters, and if it does, appending that Song to v.
  3. Once we have sequenced through the entire collection, we can return v.
To accomplish step 2, we can use a for loop containing an if statement. Since getArtist() returns a string, we can use the string::find() method to search that string for artist. If the argument we pass to find() is not present within the string, find() returns the value string::npos. Putting all of this together gives us this definition:

   /* Search by artist
    * @param: artist, a string.
    * Return: a vector containing all the Songs in mySongs by artist.
    */
   vector<Song> PlayList::searchByArtist(const string& artist) const {
      vector<Song> v;

      for (unsigned i = 0; i < mySongs.size(); i++) {
         if ( mySongs[i].getArtist().find(artist) != string::npos ) {
            v.push_back( mySongs[i] );
         }
      }

      return v;
   }

Note that:

Note also that given the way find() works, a call like:

   searchByArtist("Beatles")
will still match songs for which getArtist() returns "The Beatles". If we had written:
   ...
   for (unsigned i = 0; i < mySongs.size(); i++) {
      if ( mySongs[i].getArtist() == artist ) {
         v.push_back( mySongs[i] );
      }
   }
   ...
then the argument to searchByArtist() would have to exactly match the string returned by mySongs[i].getArtist(). For example,
   searchByArtist("The Beatles")
would find the Beatles songs in our playlist, but
   searchByArtist("Beatles")
would not. That is the reason we are using the find() method instead of the equality operator.

As always, you will need to add a prototype for this method to class PlayList, so take a moment to do so.

This should remove the remaining errors, so that when you compile and run the test-application, all tests should be passed:

   Testing class Song...
   - constructors ...  0  1  Passed!
   - readFrom()...  0  1  2  3  Passed!
   - writeTo()...  0  1  2  Passed!
   All tests passed!

   Testing class PlayList...
   - constructors... 0  Passed!
   - searchByArtist()...  0  1  2  Passed!
   All tests passed!

Congratulations! You now have a working data structure that we can use to store playlists for our company!

This week's project is to finish the data structure and build the other playlist operations.

Turn In

The folder /home/cs/112/current should contain a folder with your user-name. You can check by typing this command into a terminal window:

$ ls /home/cs/112/current
Each week's lab and project must be copied to your folder, so that the grader can grade it. If your lab is not copied to your folder within /home/cs/112/current, the grader will not be able to find it, and you will receive a zero!

To illustrate, suppose that your user-name is abc1, that you are currently in your home folder, that your Eclipse workspace is named cs112, that your cs112 folder is within your home folder, and that your lab01 folder is inside your cs112 folder (i.e., /home/abc1/cs112/lab01). Then you should enter:

$ cd cs112

to change directory to your workspace -- the folder "above" your lab01 folder. Then enter:

$ ls
to view the contents of your workspace. At this point, you should see folders for last week's lab (lab0) and this week's lab (lab01). Then enter:
$ cp -r lab01 /home/cs/112/current/abc1

where you replace abc1 with your user-name. The cp -r command will recursively copy lab01 and all of its subfolders into your folder inside /home/cs/112/current/. You can check that this worked by listing the contents of your folder:

$ ls /home/cs/112/current/abc1
lab01

and then verifying that your folder contains a copy of the today's files:

$ ls /home/cs/112/current/abc1/lab01
... files for lab01 should appear ...

We will be using these same commands to submit our labs and projects each week for the rest of the semester, so you should record them somewhere that you can easily find them (or maybe bookmark this page).

When your folder within current contains a copy of your lab work, you are all done. Congratulations!

If time permits, you may begin working on this week's project.


CS > 112 > Labs > 01


This page maintained by Joel Adams.