Expanding the Image API

Key techniques for implementing class designs in more complex scenarios
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Free, Unlimited Access
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Posted

In this lesson, we’ll discuss some ways we can improve upon the Image class created in the previous lesson. We’ll cover three topics:

  • Techniques for dealing with errors and unexpected situations
  • Adding flexibility to our class, and working around some common problems that can occur when implementing a versatile API
  • Using comments to document how our API works

The examples we use here are based on the Image class we created previously:

#pragma once
#include <SDL_image.h>
#include <string>
#include <iostream>

enum class ScalingMode { None, Fill, Contain };

class Image {
public:
  Image(
    const std::string& File,
    const SDL_Rect& SourceRect,
    const SDL_Rect& DestRect,
    ScalingMode ScalingMode = ScalingMode::None
  );

  void Render(SDL_Surface* Surface);
  void SetSourceRectangle(const SDL_Rect& Rect);
  void SetDestinationRectangle(
    const SDL_Rect& Rect);
  ~Image();
  Image(const Image&) = delete;
  Image& operator=(const Image&) = delete;

private:
  SDL_Surface* mImageSurface{ nullptr };
  std::string mFile;
  SDL_Rect mAppliedSrcRectangle;
  SDL_Rect mRequestedSrcRectangle;
  SDL_Rect mAppliedDestRectangle;
  SDL_Rect mRequestedDestRectangle;
  ScalingMode mScalingMode{ ScalingMode::None };

  void LoadFile(const std::string& File);

  SDL_Rect MatchAspectRatio(
    const SDL_Rect& Source,
    const SDL_Rect& Target) const;

  bool ValidateRectangle(
    const SDL_Rect&    Rect,
    const SDL_Surface* Surface,
    const std::string& Context) const;

  bool RectangleWithinSurface(
    const SDL_Rect&    Rect,
    const SDL_Surface* Surface) const;
};
#include "Image.h"

Image::Image(const std::string& File,
  const SDL_Rect& SourceRectangle,
  const SDL_Rect& DestinationRectangle,
  ScalingMode ScalingMode
) : mScalingMode(ScalingMode) {
  LoadFile(File);
  SetSourceRectangle(SourceRectangle);
  SetDestinationRectangle(DestinationRectangle);
}

void Image::Render(SDL_Surface* Surface) {
  if (mScalingMode == ScalingMode::None) {
    SDL_BlitSurface(mImageSurface,
      &mAppliedSrcRectangle, Surface,
      &mAppliedDestRectangle);
  } else {
    SDL_BlitScaled(mImageSurface,
      &mAppliedSrcRectangle, Surface,
      &mAppliedDestRectangle);
  }
}

void Image::SetSourceRectangle(
  const SDL_Rect& Rect) {
  mRequestedSrcRectangle = Rect;
  if (ValidateRectangle(
    Rect, mImageSurface, "Source Rectangle")) {
    mAppliedSrcRectangle = Rect;
  } else {
    mAppliedSrcRectangle = {
      0, 0, mImageSurface->w, mImageSurface->h };
  }
}

void Image::SetDestinationRectangle(
  const SDL_Rect& Rect) {
  mRequestedDestRectangle = Rect;
  if (ValidateRectangle(Rect, nullptr,
    "Destination Rectangle")) {
    mAppliedDestRectangle =
      mScalingMode == ScalingMode::Contain
      ? MatchAspectRatio(Rect,
        mAppliedSrcRectangle)
      : Rect;
  } else {
    mAppliedDestRectangle = {
      0, 0, mImageSurface->w, mImageSurface->h };
  }
}

Image::~Image() {
  SDL_FreeSurface(mImageSurface);
}

void Image::LoadFile(const std::string& File) {
  if (File == mFile) { return; }

  SDL_FreeSurface(mImageSurface);
  mFile = File;
  mImageSurface = IMG_Load(File.c_str());
}

bool Image::ValidateRectangle(
  const SDL_Rect&    Rect,
  const SDL_Surface* Surface,
  const std::string& Context) const {
  if (SDL_RectEmpty(&Rect)) {
    std::cout << "[ERROR] " << Context <<
      ": Rectangle has no area\n";
    return false;
  }
  if (Surface && !
    RectangleWithinSurface(Rect, Surface)) {
    std::cout << "[ERROR] " << Context <<
      ": Rectangle not within target surface\n";
    return false;
  }
  return true;
}

bool Image::RectangleWithinSurface(
  const SDL_Rect& Rect,
  const SDL_Surface* Surface
) const {
  if (Rect.x < 0)
    return false;
  if (Rect.x + Rect.w > Surface->w)
    return false;
  if (Rect.y < 0)
    return false;
  if (Rect.y + Rect.h > Surface->h)
    return false;
  return true;
}

SDL_Rect Image::MatchAspectRatio(
  const SDL_Rect& Source,
  const SDL_Rect& Target) const {
  float TargetRatio{ Target.w
    / static_cast<float>(Target.h) };
  float SourceRatio{ Source.w
    / static_cast<float>(Source.h) };

  SDL_Rect ReturnValue = Source;

  if (SourceRatio < TargetRatio) {
    ReturnValue.h = static_cast<int>(
      Source.w / TargetRatio);
  } else {
    ReturnValue.w = static_cast<int>(
      Source.h * TargetRatio);
  }

  return ReturnValue;
}

Error Handling

So far, our class is handling and recovering from errors involving invalid source and destination rectangles. However, there are some situations from which we can't recover, and we should implement ways to communicate these states.

For example, the user may provide an invalid file path, causing our LoadFile() function to fail. The SDL_Surface pointer returned from SDL’s IMG_Load() function is a nullptr when this happens, and we can retrieve the associated error message by calling SDL_GetError().

Let’s add a ValidateSurface() function to test a surface and log errors:

// Image.h
// ...

class Image {
  // ...
private:
  // ...
  void ValidateSurface(
    const SDL_Surface* Surface,
    const std::string& Context) const;
};
// Image.cpp
// ...

void Image::ValidateSurface(
  const SDL_Surface* Surface,
  const std::string& Context) const {
  if (!Surface) {
    std::cout << "[ERROR] " << Context << ": "
      << SDL_GetError() << '\n';
  }
}

We’ll call it at the end of our LoadFile() function to report if the IMG_Load() failed:

// Image.cpp
// ...

void Image::LoadFile(const std::string& File) {
  if (File == mFile) { return; }
  SDL_FreeSurface(mImageSurface);
  mFile = File;
  mImageSurface = IMG_Load(File.c_str());
  ValidateSurface(mImageSurface, "Loading File");
}

Reacting to Errors Internally

This alerts users that an error has happened when they instantiate our class with an invalid path. However, our Image class can be updated to handle this scenario more gracefully.

For example, we can ensure an image load is successful before we get rid of our existing surface. Let’s update ValidateSurface() to return a bool - true if the surface is valid, and false otherwise:

// Image.h
// ...

class Image {
  // ...
private:
  // ...
  bool ValidateSurface(
    const SDL_Surface* Surface,
    const std::string& Context) const;
};
// Image.cpp
// ...

bool Image::ValidateSurface(
  const SDL_Surface* Surface,
  const std::string& Context) const {
  if (!Surface) {
    std::cout << "[ERROR] " << Context << ": "
      << SDL_GetError() << '\n';
    return false;
  }
  return true;
}

We can update our LoadFile() function to make use of this return value. We’ll attempt to create a new surface using IMG_Load() as before, but we’ll only update our existing surface if that operation is successful:

// Image.cpp
// ...

void Image::LoadFile(const std::string& File) {
  if (File == mFile) { return; }

  SDL_Surface* NextSurface{ IMG_Load(
    File.c_str()) };

  if (ValidateSurface(
    NextSurface, "Loading File")) {
    SDL_FreeSurface(mImageSurface);
    mFile = File;
    mImageSurface = NextSurface;
  }
}

Reacting to Errors Externally

We should also consider whether our public functions should give external users the ability to react to errors. Whilst logging an error is helpful, it’s difficult for external code to react to logs.

SDL functions often return an integer representing whether the requested action succeeded. They generally return 0 on success and a negative number on failure.

We could use that same technique in our API. For example, let’s update SetSourceRectangle() to return 0 if the provided rectangle was valid, and -1 if it wasn’t:

// Image.h
// ...

class Image {
 public:
  int SetSourceRectangle(const SDL_Rect& Rect);
  // ...
};
// Image.cpp
// ...

int Image::SetSourceRectangle(
  const SDL_Rect& Rect) {
  mRequestedSrcRectangle = Rect;
  if (ValidateRectangle(Rect, mImageSurface,
    "Source Rectangle")) {
    mAppliedSrcRectangle = Rect;
    return 0;
  } else {
    mAppliedSrcRectangle = {
      0, 0, mImageSurface->w, mImageSurface->h
    };
    return -1;
  }
}

This makes it easier for external code to understand if their operation succeeded, and implement corrections if it didn’t.

Using Preprocessor Directives

Error checking can often have a performance cost, so it’s typically a good practice to disable it in our final release builds. We can use preprocessor directives to help us here.

For example, let’s update our Render() method to determine if our destination rectangle has been clipped. Render() is called in a hot loop, so we should be particularly mindful of performance here.

Accordingly, we’ll only perform these checks if the DEBUG preprocessor directive is defined:

// Image.cpp
// ...

void Image::Render(SDL_Surface* Surface) {
  #ifdef DEBUG
  int InitialWidth = mAppliedDestRectangle.w;
  int InitialHeight = mAppliedDestRectangle.h;
  #endif

  if (mScalingMode == ScalingMode::None) {
    SDL_BlitSurface(
      mImageSurface, &mAppliedSrcRectangle,
      Surface, &mAppliedDestRectangle);
  } else {
    SDL_BlitScaled(
      mImageSurface, &mAppliedSrcRectangle,
      Surface, &mAppliedDestRectangle);
  }

  #ifdef DEBUG
  if (InitialWidth != mAppliedDestRectangle.w) {
    std::cout << "Horizontal clipping\n";
  }
  if (InitialHeight != mAppliedDestRectangle.h) {
    std::cout << "Vertical clipping\n";
  }
  #endif
}

Adding Constructors

Currently, our class has a single constructor, and that constructor requires the user to provide all the key settings:

  • The file name
  • The source rectangle
  • The destination rectangle
  • The scaling mode

There are some sensible defaults we can choose here, so let’s make our class more flexible. We’ll make the source rectangle, destination rectangle, and scaling mode optional.

When designing APIs, it can be helpful to write the code that uses the API first. For example, if I wanted to create an Image with a source rectangle, what would I like that expression to look like?

It might be something like this:

Image Example{"img.png", {0, 0, 200, 200}};

One challenge we’ll have is that the source and destination rectangles have the same type - SDL_Rect. As such, the friendliest API will be ambiguous. The previous example could be defining a source rectangle, but it could just as easily be defining a destination rectangle.

One way to disambiguate this is to introduce new types to support our API. These types can typically be extremely simple:

struct SourceRect : SDL_Rect {};
struct DestRect : SDL_Rect {};

Our API can then look like this:

Image A{"A.png", SourceRect{0, 0, 200, 200}};

Let’s add some constructors to support this:

// Image.h
// ...

struct SourceRect : SDL_Rect {};
struct DestRect : SDL_Rect {};

class Image {
public:
  Image(
    const std::string& File,
    ScalingMode ScalingMode = ScalingMode::None
  );

  Image(
    const std::string& File,
    const SourceRect& SourceRect,
    ScalingMode ScalingMode = ScalingMode::None
  );

  Image(
    const std::string& File,
    const DestRect& DestRect,
    ScalingMode ScalingMode = ScalingMode::None
  );

  Image(
    const std::string& File,
    const SourceRect& SourceRect,
    const DestRect& DestRect,
    ScalingMode ScalingMode = ScalingMode::None
  );

  // ...
};

Let’s implement these constructors. We’ll use the following behavior:

  • If the user doesn’t provide a source rectangle, we’ll default it to cover the entire image.
  • If the user doesn’t provide a destination rectangle, we’ll default it to the same values as the source rectangle.
// Image.cpp
// ...

Image::Image(
  const std::string& File,
  ScalingMode Mode
) : mScalingMode{Mode} {
  LoadFile(File);
  SetSourceRectangle({
    0, 0, mImageSurface->w, mImageSurface->h
  });
  SetDestinationRectangle(mAppliedSrcRectangle);
}

Image::Image(
  const std::string& File,
  const SourceRect& SourceRectangle,
  ScalingMode Mode
) : mScalingMode(Mode) {
  LoadFile(File);
  SetSourceRectangle(SourceRectangle);
  SetDestinationRectangle(mAppliedSrcRectangle);
}

Image::Image(
  const std::string& File,
  const DestRect& DestRectangle,
  ScalingMode Mode
) : mScalingMode(ScalingMode) {
  LoadFile(File);
  SetSourceRectangle({
    0, 0, mImageSurface->w, mImageSurface->h
  });
  SetDestinationRectangle(DestRectangle);
}

Image::Image(
  const std::string& File,
  const SourceRect& SourceRectangle,
  const DestRect& DestRectangle,
  ScalingMode Mode
) : mScalingMode(Mode) {
  LoadFile(File);
  SetSourceRectangle(SourceRectangle);
  SetDestinationRectangle(DestRectangle);
}

In situations where initialization is more complex, it can be helpful to add a private initialization function, which many constructors can defer to. For example:

// Image.cpp
// ...

Image::Image(
  const std::string& File,
  ScalingMode Mode
): mScalingMode{Mode} {
  Initialize(
    File,
    {0, 0, mImageSurface->w, mImageSurface->h },
    mAppliedSrcRectangle
  );
}

Image::Image(
  const std::string& File,
  const SourceRect& SourceRectangle,
  ScalingMode Mode
) : mScalingMode{Mode} {
  Initialize(
    File,
    SourceRectangle,
    mAppliedSrcRectangle
  );
}

// ...

void Image::Initialize(
  const std::string& File,
  const SourceRect& SourceRectangle,
  const DestRect&   DestRectangle
) {
  LoadFile(File);
  SetSourceRectangle(SourceRectangle);
  SetDestinationRectangle(DestRectangle);
  // ... additional initialization work
}

With these changes, consumers have a flexible and intuitive way to construct objects using our class:

using enum ScalingMode;
Image A{"A.png"};
Image B{"B.png", Contain};
Image C{"C.png", SourceRect{1,2,3,4}};
Image D{"D.png", SourceRect{1,2,3,4}, Fill};
Image E{"E.png", DestRect{1,2,3,4}};
Image F{"F.png", {1,2,3,4}, {5,6,7,8}};
Image G{"G.png", {1,2,3,4}, {5,6,7,8}, None};

Disambiguation Tags

Another technique to guide function selection when dealing with similar argument lists is through disambiguation tags.

This approach involves defining a simple type and creating an instance of that type in a location accessible to users of our API. Here's how it works:

struct WithSourceRect_t{};
WithSourceRect_t WithSourceRect;

We then update one of our otherwise identical functions to include a parameter of this type:

// Image.h
// ...

struct WithSourceRect_t{};
WithSourceRect_t WithSourceRect;

class Image{
public:
  Image(
    const SDL_Rect& DestinationRectangle,
  );

  Image(
    WithSourceRect_t,
    const SDL_Rect& SourceRectangle,
  );
}

On the consumer side, it would create an API that looks like this:

// Providing destination rectangle
Image A{{1, 2, 3, 4}};

// Providing source rectangle
Image B{WithSourceRect, {1, 2, 3, 4}};

Adding Setters

So far, our class offers fairly limited flexibility once it has been constructed. It can be helpful to give users the ability to change various things in an existing image.

For this, we’ll add some public setter methods. When working with more complex objects, setters typically go beyond simply updating a member variable.

They often need to do additional work to ensure the overall state of our object remains valid. Let’s see some examples with our Image class.

Setting File

The source rectangle and image surface of our objects are intrinsically linked. The source rectangle needs to overlap the image surface so, when our image surface changes, our source rectangle may need to change, too.

However, users may or may not want to provide a source rectangle. As with our constructors, we can overload a setter to provide both options:

// Image.h
// ...

class Image {
 public:
  // ...
  void SetFile(const std::string& File);
  void SetFile(
    const std::string& File,
    const SDL_Rect& SourceRectangle);
};

If the user doesn’t provide a source rectangle, we’ll default to setting one that covers the entire image surface. Our implementation might look like this:

// Image.cpp
// ...

void Image::SetFile(const std::string& File) {
  LoadFile(File);
  mRequestedSrcRectangle = {
    0, 0, mImageSurface->w, mImageSurface->h
  }
  SetSourceRectangle(mRequestedSrcRectangle);
}

void Image::SetFile(
  const std::string& File,
  const SDL_Rect& SourceRectangle
) {
  LoadFile(File);
  mRequestedSrcRectangle = SourceRectangle;
  SetSourceRectangle(SourceRectangle);
}

Setting Scaling Mode

Similarly, the scaling mode and destination rectangle are closely related in our class design. Again, we can give them two options to change the scaling mode - one that uses the existing destination rectangle, and one that uses a new one that they provide:

// Image.h
// ...

class Image {
public:
  // ...
  void SetScalingMode(ScalingMode Mode);
  void SetScalingMode(ScalingMode Mode,
    const SDL_Rect& DestinationRectangle);
};
// Image.cpp
// ...

void Image::SetScalingMode(ScalingMode Mode) {
  mScalingMode = Mode;
  SetDestinationRectangle(mAppliedSrcRectangle);
}

void Image::SetScalingMode(
  ScalingMode Mode,
  const SDL_Rect& DestinationRectangle
) {
  mScalingMode = Mode;
  SetDestinationRectangle(DestinationRectangle);
};

Setting Pixel Format

As a final example, let’s allow our Image objects to receive a preferred pixel format. We’ll add a setter and the associated member variable.

We’ll also add a private ConvertSurface() function, which will update our image surface to match the preferred format:

// Image.h
// ...

class Image {
public:
  // ...
  void SetPreferredFormat(
    SDL_PixelFormat* Format);
  
private:
  void ConvertSurface();
  SDL_PixelFormat* mPreferredFormat{nullptr};
  // ...
};

When a preferred format is set, we’d want to convert our existing image surface to that format:

// Image.cpp
// ...

void Image::ConvertSurface() {
  SDL_Surface* Converted{
    SDL_ConvertSurface(
      mImageSurface, Format, 0)};
  if (ValidateSurface(
    Converted, "Converting Surface"
  )) {
    SDL_FreeSurface(mImageSurface);
    mImageSurface = Converted;
  }
}

void Image::SetPreferredFormat(
  SDL_PixelFormat* Format) {
  mPreferredFormat = Format;
  ConvertSurface();
};

Additionally, when a new image is loaded, we want to convert it to the preferred format. Let’s add that to the end of our LoadFile() function:

// Image.cpp
// ...

void Image::LoadFile(const std::string& File) {
  if (File == mFile) { return; }

  SDL_Surface* NextSurface{ IMG_Load(
    File.c_str()) };

  if (ValidateSurface(
    NextSurface, "Loading File")) {
    SDL_FreeSurface(mImageSurface);
    mFile = File;
    mImageSurface = NextSurface;
  }
  
  if (mPreferredFormat) {
    ConvertSurface();
  }
}

A pixel format is often something a user would want to specify at object creation, so we’d likely want to it as a parameter on our constructors:

// Image.h
// ...

class Image {
public:
  Image(
    const std::string& File,
    ScalingMode ScalingMode = ScalingMode::None,
    SDL_PixelFormat* PreferredFormat = nullptr
  );
  // ...
};

Adding Getters

External code would likely benefit from having access to read the current state of the object, so we’ll often add a collection of getters to our class.

Given their only purpose is to return a value - if a caller discards that return value, they’ve made a mistake. So, we’d also mark them as [[nodiscard]] so the compiler will generate warnings in that scenario:

// Image.h
// ...

class Image {
public:
  [[nodiscard]]
  int GetWidth() const {
    return ImageSurface->w;
  }

  [[nodiscard]]
  int GetHeight() const {
    return ImageSurface->h;
  }
  
  [[nodiscard]]
  ScalingMode GetScalingMode() const {
    return mScalingMode;
  }
  
  // ...
}

Giving Access to the Surface

A lot of information that external code may be interested in is included in the SDL_Surface that stores our image. This includes things like the width, height, and pixel format.

However, we may not want to give external code access to it. Doing so can reveal a little too much about the inner workings of our class (thereby violating encapsulation) and changes to the SDL_Surface can place our objects in invalid states.

If we do want to expose the surface anyway, we should consider having the getter return it as a const:

// Image.h
// ...

class Image {
public:
  [[nodiscard]]
  const SDL_Surface* GetSurface() {
    return mImageSurface;
  }
  // ...
}

Documentation

As a final step, we can add documentation in the form of comments to explain how our methods behave. In the introductory course, we introduced the JSDoc format:

// Image.h
// ...

class Image {
public:
  /**
   * @brief Sets the image file and source
   * rectangle used for this object.
   *
   * @param File The file path to use.
   * @param SourceRectangle The source rectangle
   * to use when blitting.
   * @returns 0 if successful, or a negative
   * integer otherwise. Call SDL_GetError() for
   * error details.
   */
  int SetFile(const std::string& File,
    const SDL_Rect& SourceRectangle);
    
    // ...
 }

Comments in this format can be understood by external tools, such as IDEs, making our class easier to use:

Screenshot of Visual Studio displaying JSDoc comments

Summary

In this lesson, we've covered a range of techniques for improving our API, using examples from our Image class. We covered:

  • Techniques for dealing with errors, including using preprocessor directives to minimize performance impact
  • Creating constructors and setters in the context of a more complex class
  • Practical examples of using comments as documentation

Remember, good class design is an iterative process - always look for ways to improve your code!

Was this lesson useful?

Next Lesson

Rendering Text with SDL_ttf

Learn to render and manipulate text in SDL2 applications using the official SDL_ttf extension
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Posted
sdl2-promo.jpg
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Free, Unlimited Access
  • 51.GPUs and Rasterization
  • 52.SDL Renderers
sdl2-promo.jpg
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Free, unlimited access

This course includes:

  • 53 Lessons
  • 100+ Code Samples
  • 91% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Rendering Text with SDL_ttf

Learn to render and manipulate text in SDL2 applications using the official SDL_ttf extension
Abstract art representing computer programming
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved