Sans Pareil Technologies, Inc.

Key To Your Business

Lab Exercise 7 - Object Serialisation


In this exercise we will develop a complex data structure (Person) that is composed of a few other simple data structures. We will support two functions in each class:
  • read - Read an input stream and create a new instance of the class
  • write - Write the data encapsulated in the instance to a specified output stream.

We will write a test suite that validates that objects serialised to a output stream (file for instance) may be recomposed to an equivalent instance using the read/write functions.
I/O functions 

Create a header file (io.h) that defines template functions for reading/writing data from/to streams.

#pragma once
#include <istream>
#include <memory>

namespace csc240
{
  namespace io
  {
    template <typename Data>
    Data read( std::istream& stream )
    {
      Data data;
      read( data, stream );
      return data;
    }

    template <>
    inline std::string read<std::string>( std::istream& stream )
    {
      auto length = read<std::size_t>( stream );
      auto buffer = std::make_unique<char[]>( length );
      stream.read( buffer.get(), length );
      return std::string{ buffer.get(), length };
    }

    template <typename Data>
    void read( Data& data, std::istream& stream )
    {
      stream.read( reinterpret_cast<char*>( &data ), sizeof ( data ) );
    }

    template <typename Data>
    bool write( const Data& data, std::ostream& stream )
    {
      if ( ! stream ) return false;
      stream.write( reinterpret_cast<const char*>( &data ), sizeof( data ) );
      return stream.good();
    }

    template <>
    inline bool write<std::string>( const std::string& string, std::ostream& stream )
    {
      const auto length = string.size();
      write( length, stream );
      stream.write( string.c_str(), length );
      return stream.good();
    }
  }
}
Date class 

Create a header and source file for a simple class that encapsulates a date. Note that we (de)serialise the data structure in a single operation since Date objects do not encapsulate any dynamically allocated memory.

//Date.h

#pragma once
#include <iostream>
#include <string>

namespace csc240
{
  class Date
  {
  public:
    Date( uint8_t day, uint8_t month, uint16_t year );

    uint8_t getDay() const { return day; }
    uint8_t getMonth() const { return month; }
    uint16_t getYear() const { return year; }

    bool write( std::ostream& stream ) const;
    static Date read( std::istream& stream );

    static const Date& bad();

  private:
    uint8_t day;
    uint8_t month;
    uint16_t year;
  };

  std::ostream& operator<< ( std::ostream& stream, const Date &date );

  inline bool operator== ( const Date& lhs, const Date& rhs )
  {
    return ( lhs.getDay() == rhs.getDay() ) &&
      ( lhs.getMonth() == rhs.getMonth() ) &&
      ( lhs.getYear() == rhs.getYear() );
  }
}

// Date.cpp

#include "Date.h"
#include "io.h"
#include "catch.hpp"

using csc240::Date;

Date::Date( uint8_t day, uint8_t month, uint16_t year ) :
  day{ day }, month{ month }, year{ year } {}

bool Date::write( std::ostream & stream ) const
{
  return io::write( *this, stream );
}

Date Date::read( std::istream & stream )
{
  if ( ! stream  ) return Date::bad();

  auto d{ Date::bad() };
  io::read( d, stream );
  return d;
}

const Date & csc240::Date::bad()
{
  static const Date date{ 0, 0, 0 };
  return date;
}

std::ostream & csc240::operator<<( std::ostream & stream, const Date & date )
{
  stream << date.getYear() << "/" << date.getMonth() << "/" << date.getDay();
  return stream;
}


SCENARIO( "Date instances can be (de)serialised" )
{
  GIVEN( "Member functions in Date for (de)serialisation" )
  {
    std::stringstream ss;
    const Date date{ 29, 2, 1980 };
    date.write( ss );
    REQUIRE( date == Date::read( ss ) );

    ss.str( "" );
    ss.clear();
    const auto d = Date::bad();
    csc240::io::write( d, ss );
    REQUIRE( d == Date::read( ss ) );
  }
}
Height class 

Create a header and source file for a simple class that encapsulates a person’s height measurement. We will use a float for the measurement, and an enum to represent a few common units for measurement. Similar to Date, we (de)serialise the data structure in a single operation since objects do not encapsulate any dynamically allocated memory.

// Height.h

#pragma once
#include <iostream>

namespace  csc240
{
  class Height
  {
  public:
    enum class Unit : short { cm, in };

    Height( float height, Unit unit );

    float getHeight() const { return height; }
    Unit getUnit() const { return unit;  }

    bool write( std::ostream& stream ) const;
    static Height read( std::istream& stream );

    static const Height& bad();

  private:
    float height;
    Unit unit;
  };

  std::ostream& operator<< ( std::ostream&, const Height& );

  inline bool operator== ( const Height& lhs, const Height& rhs )
  {
    return ( lhs.getHeight() == rhs.getHeight() ) &&
      ( lhs.getUnit() == rhs.getUnit() );
  }
}
```c++

// Height.cpp

#include "Height.h"
#include "io.h"
#include "catch.hpp"

using csc240::Height;

Height::Height( float height, Unit unit ) : height{ height }, unit{ unit } {}


bool Height::write( std::ostream& stream ) const
{
  return io::write( *this, stream );
}


Height csc240::Height::read( std::istream & stream )
{
  if ( !stream ) return Height::bad();
  auto h{ Height::bad() };
  io::read( h, stream );
  return h;
}


const Height& Height::bad()
{
  static const Height err{ 0.0, Unit::in };
  return err;
}

std::ostream & csc240::operator<<( std::ostream& stream, const Height& height )
{
  stream << height.getHeight();

  switch ( height.getUnit() )
  {
    case Height::Unit::cm:
      stream << "cm";
      break;
    case Height::Unit::in:
      stream << "in";
      break;
  }

  return stream;
}


SCENARIO( "Height instances can be (de)serialised" )
{
  GIVEN( "Member functions in Height for (de)serialisation" )
  {
    std::stringstream ss;
    const Height height{ 172.2f, Height::Unit::cm };
    height.write( ss );
    REQUIRE( height == Height::read( ss ) );

    ss.str( "" );
    ss.clear();
    const auto h = Height::bad();
    csc240::io::write( h, ss );
    REQUIRE( h == Height::read( ss ) );
  }
}
Weight class 

Create a header and source file for a simple class that encapsulates a person’s weight. Similar to Height, we will use a float for the measurement and an enum for some common units of measurement.

// Weight.h

#pragma once
#include <iostream>

namespace csc240
{
  class Weight
  {
  public:
    enum class Unit : short { kg, lb };

    Weight( float weight, Unit unit );

    float getWeight() const { return weight; }
    Unit getUnit() const { return unit; }

    bool write( std::ostream& stream ) const;
    static Weight read( std::istream& stream );

    static const Weight& bad();

  private:
    float weight;
    Unit unit;
  };

  std::ostream& operator<< ( std::ostream&, const Weight& );

  inline bool operator== ( const Weight& lhs, const Weight& rhs )
  {
    return ( lhs.getWeight() == rhs.getWeight() ) &&
      ( lhs.getUnit() == rhs.getUnit() );
  }
}

// Weight.cpp

#include "Weight.h"
#include "io.h"
#include "catch.hpp"

using csc240::Weight;

Weight::Weight( float weight, Unit unit ) : weight{ weight }, unit{ unit } {}


bool Weight::write( std::ostream & stream ) const
{
  return io::write( *this, stream );
}


Weight Weight::read( std::istream & stream )
{
  if ( !stream ) return Weight::bad();
  auto w{ Weight::bad() };
  io::read( w, stream );
  return w;
}


const Weight& Weight::bad()
{
  static const Weight err{ 0.0, Unit::lb };
  return err;
}

std::ostream & csc240::operator<<( std::ostream& stream, const Weight& weight )
{
  stream << weight.getWeight();

  switch ( weight.getUnit() )
  {
    case Weight::Unit::kg:
      stream << "Kg";
      break;
    case Weight::Unit::lb:
      stream << "lb";
      break;
  }

  return stream;
}


SCENARIO( "Weight instances can be (de)serialised" )
{
  GIVEN( "Member functions in Weight for (de)serialisation" )
  {
    std::stringstream ss;
    const Weight weight{ 72.2f, Weight::Unit::kg };
    weight.write( ss );
    REQUIRE( weight == Weight::read( ss ) );

    ss.str( "" );
    ss.clear();
    const auto w = Weight::bad();
    csc240::io::write( w, ss );
    REQUIRE( w == Weight::read( ss ) );
  }
}
Person class 

Create a header and source file for a class that represents a person. Person objects will be composed of a few relevant details such as name, address, date of birth, height etc. Note that we implement a few utility functions in the Person source file to help with (de)serialisation. Implementing them in the source file helps us hide these functions from users who have access to the header file.

// Person.h

#pragma once
#include "Date.h"
#include "Height.h"
#include "Weight.h"

#include <string>
#include <vector>

namespace csc240
{
  class Person
  {
  public:
    using Address = std::vector<std::string>;

    Person( uint32_t identifier, std::string&& name, Address&& address,
      Date dateOfBirth, Height height, Weight weight );

    const std::string& getName() const { return name; }
    const Address& getAddress() const { return address; }
    const Date& getDateOfBirth() const { return dateOfBirth; }
    const Height& getHeight() const { return height; }
    const Weight& getWeight() const { return weight; }
    uint32_t getIdentifier() const { return identifier; }

    bool write( std::ostream& stream ) const;
    static Person read( std::istream& stream );

    static const Person& bad();

  private:
    std::string name;
    Address address;
    Date dateOfBirth;
    Height height;
    Weight weight;
    uint32_t identifier;
  };

  std::ostream& operator<< ( std::ostream&, const Person& );
  bool operator== ( const Person&, const Person& );
}

// Person.cpp

#include "Person.h"
#include <memory>
#include <iterator>
#include "io.h"

using csc240::Person;
using csc240::Date;
using csc240::Height;
using csc240::Weight;

namespace csc240
{
  void writeStringWithLength( std::ostream& stream, const std::string& value )
  {
    io::write( value, stream );
  }


  void writeNullTerminatedString( std::ostream& stream, const std::string& value )
  {
    stream.write( value.c_str(), value.size() );
    stream.write( "\0", sizeof( char ) );
  }


  void writeAddress( std::ostream& stream, const Person::Address& address )
  {
    io::write( address.size(), stream );
    for ( auto& line : address ) writeNullTerminatedString( stream, line );
  }


  std::string readStringUsingLength( std::istream& stream )
  {
    return io::read<std::string>( stream );
  }


  std::string readNullTerminatedString( std::istream& stream )
  {
    std::string value;
    std::getline( stream, value, '\0' );
    return value;
  }


  Person::Address readAddress( std::istream& stream )
  {
    Person::Address address;
    auto lines = address.size();
    io::read( lines, stream );
    address.reserve( lines );

    for ( uint32_t i = 0; i < lines; ++i )
    {
      address.emplace_back( readNullTerminatedString( stream ) );
    }

    return address;
  }


  std::ostream & operator<<( std::ostream& stream, const Person& person )
  {
    stream << "Person: identifier:[" <<
      person.getIdentifier() << "], name:[" <<
      person.getName() << "], dateOfBirth:[" <<
      person.getDateOfBirth() << "], height:[" <<
      person.getHeight() << "], weight:[" <<
      person.getWeight() << "], address:[";

    std::copy( person.getAddress().cbegin(), person.getAddress().cend(),
      std::ostream_iterator<std::string>( stream, ", " ) );

    stream << "]";

    return stream;
  }


  bool operator==( const Person& lhs, const Person& rhs )
  {
    if ( &lhs == &rhs ) return true;
    return ( lhs.getIdentifier() == rhs.getIdentifier() ) &&
      ( lhs.getName() == rhs.getName() ) &&
      ( lhs.getAddress() == rhs.getAddress() ) &&
      ( lhs.getDateOfBirth() == rhs.getDateOfBirth() ) &&
      ( lhs.getHeight() == rhs.getHeight() ) &&
      ( lhs.getWeight() == rhs.getWeight() );
  }
}


Person::Person( uint32_t identifier, std::string&& name, Address&& address,
    Date dateOfBirth, Height height, Weight weight) :
  name{ name }, address{ address }, dateOfBirth{ dateOfBirth },
  height{ height }, weight{ weight }, identifier{ identifier } {}


bool Person::write( std::ostream& stream ) const
{
  if ( ! stream ) return false;

  io::write( identifier, stream );
  writeStringWithLength( stream, name );
  writeAddress( stream, address );
  dateOfBirth.write( stream );
  height.write( stream );
  weight.write( stream );

  return stream.good();
}


Person Person::read( std::istream& stream )
{
  if ( !stream ) return bad();
  const auto identifier = io::read<uint32_t>( stream );
  auto name = readStringUsingLength( stream );
  auto address = readAddress( stream );
  const auto date = Date::read( stream );
  const auto height = Height::read( stream );
  const auto weight = Weight::read( stream );

  return Person{ identifier, std::move( name ), std::move( address ),
    date, height, weight };
}


const Person& Person::bad()
{
  static const Person err{ 0, std::string{}, Address{}, Date::bad(),
    Height::bad(), Weight::bad() };
  return err;
}
Test suite 

Create a new source file that will hold a simple test suite. We will create a person instance, serialise to a file, and then read the file into another person instance and compare the person instances for equality.

// io.cpp

#include "catch.hpp"
#include "Person.h"
#include <cstdio>
#include <fstream>

namespace csc240
{
  std::streampos fileSize( const std::string& file )
  {
    std::ifstream stream{ file, std::ios::binary | std::ios::ate };
    REQUIRE( stream );

    auto length = stream.tellg();
    stream.close();
    return length;
  }

  SCENARIO( "Person instances can be written to and read from files" )
  {
    const std::string fileName{ "person.dat" };

    GIVEN( "A Person instance" )
    {
      Person person{
        1234,
        "Unit Test",
        {"123 Main Street", "Chicago, IL"},
        Date{ 1, 1, 1970 },
        Height{ 180, Height::Unit::cm },
        Weight{ 70, Weight::Unit::kg }
      };


      WHEN( "Written to a file on disk" )
      {
        std::ofstream os{ fileName, std::ios::trunc | std::ios::binary };
        REQUIRE( person.write( os ) );
        os.close();

        THEN( "File contains data" )
        {
          REQUIRE( fileSize( fileName ) );
        }

        AND_THEN( "Can be read from the file on disk" )
        {
          std::ifstream is{ fileName, std::ios::binary | std::ios::beg };
          REQUIRE( is );

          const auto p = Person::read( is );
          REQUIRE( is );
          REQUIRE( person == p );
          REQUIRE_FALSE( Person::bad() == p );
        }
      }

      std::remove( fileName.c_str() );

      AND_WHEN( "Written to a buffer" )
      {
        std::stringstream ss;
        person.write( ss );
        const auto p = Person::read( ss );
        REQUIRE( person == p );
        REQUIRE_FALSE( Person::bad() == p );
      }
    }
  }
}
The read/write functions can be written as template functions, which will make them more general purpose. We will make that modification in a future lab exercise once template functions have been introduced.