Skip to content

Latest commit

History

History
329 lines (269 loc) 路 14.3 KB

headers_with_classes.md

File metadata and controls

329 lines (269 loc) 路 14.3 KB

Headers with classes

Video

We now know a thing or two about classes. We know how to implement them and understand how valuable they are to write safe and efficient code as well as the great benefits that they bring for readability and abstraction.

There is only one thing missing. We still can't properly use them in our CMake projects, largely because we don't know how to create a library that holds the code that lives in classes.

What we do know is how to create such a library from functions... Are classes any different?

And here I have good news for you - no, no they are not! In fact, the situation is very much alike to how we create a library out of functions! The differences are pretty minor.

What stays the same

Lots of things stay exactly as we had them before:

  • We still declare stuff in a header (*.hpp) file
  • We still define stuff in a source (*.cpp) file
  • We still create static, shared or header-only libraries from these files
  • We still use these libraries just like before. For a refresher, see headers and libraries lecture

What is different

That being said, some things are different.

  • We now have data, not only methods, so we'll have to learn where they land
  • The definitions in the source file must show that they belong to a class
  • Our methods also have some attributes, like the trailing const modifier. We must somehow deal with those

The Chatbot illustrative example

Let's see all of this in detail. As always, we will be looking at an example. As the various AI chat bots are so popular now, we'll write a very stupid one 馃槈 And by very stupid, I mean very stupid!

Modeling the interface

Largely speaking, any AI system is just a black box that "trains" by looking at lots of training data, stores them in some internal representation and then uses this representation to predict certain answers when new unseen test data arrives.

Given that, our "chatbot" can be simply a class. To design such a class, let's talk about the public interface this class must have:

  • It should return an Answer through a method GetAnswer that gets a question as a std::string parameter
  • In order to be able to answer the provided question, we would have to train our chat bot. Therefore, it must be able to ingest some training Data into a method Train and do some magic to become smarter (don't get your hopes high)

Thinking about the implementation

That's about all the interface we need here. Now we would have to fill in some details:

  • The Answer is going to be a very simple struct holding the actual answer and its probability (here is how you know our example is fictional, no chatbot is going to provide this to us anytime soon). Oh, and we can put this struct to be class-internal for our Chatbot class.
  • The Data is also going to be a class-internal struct with its own data in the form of questions and correct answers to them as well as some function to check its validity. I know it should be a class, but let's just stick with struct here for simplicity.
  • The Chatbot class must have some internal parameters that we train with our Train function and that influence its answers. We will just call our internal parameter smartness and represent it as an int 馃し What? I did promise a very stupid chatbot. 馃槈
  • We need some implementation for all of the methods we discussed above. The implementation is going to be really trivial, this is a lecture about C++, not machine learning after all.

Finally, we need a main function to test that the chatbot does something.

The code in a single cpp file

Putting it all together into a chatbot.cpp file, we get something like this:

chatbot.cpp:

#include <iostream>
#include <string>
#include <vector>

class Chatbot {
 public:
  struct Answer {
    float probability{};
    std::string text{};
  };

  struct Data {
    bool IsValid() const { return questions.size() == correct_answers.size(); }

    std::vector<std::string> questions{};
    std::vector<std::string> correct_answers{};
  };

  void Train(const Data &data) {
    if (!data.IsValid()) { return; }
    IngestData(data);
  }

  Answer GetAnswer(const std::string &question) const {
    if (smartness_ < 1) { return Answer{0.1, "I don't know"}; }
    if (smartness_ < 5) { return Answer{0.8, "Yes."}; }
    if (question.length() > 10) {
      return Answer{1.0, "You will regret this question..."};
    }
    return Answer{1.0, "Can't you ask anything more important?"};
  }

 private:
  void IngestData(const Data &data) {
    smartness_ += data.correct_answers.size();
  }

  int smartness_{};
};

int main() {
  Chatbot chatbot{};
  chatbot.Train({{"How much is 2 + 2?",
                  "What color is the sky?",
                  "What is the answer to life and everything?"},
                 {"4", "It depends", "42"}});
  const auto question = "Are you self aware?";
  std::cout << "Asking chatbot: " << question << std::endl;
  std::cout << "Chatbot answered: " << chatbot.GetAnswer(question).text
            << std::endl;
  return 0;
}

This example is a bit simplistic (again, do run it on your own!), but covers quite a few things that can happen within a class. It has classes and structs declared inside of it, it has methods and data and some of the methods are even const.

For now we have it all in one file that we can easily compile from a command line, see how it's done in previous lectures:

c++ -std=c++17 chatbot.cpp -o chatbot_example

Making it a header-only library

But what if we want to be serious about our development and make a library out of our Chatbot class that we can use from our CMake project? Let's start by moving the implementation into a header file by simply renaming our chatbot.cpp into chatbot.hpp, adding the #pragma once or include guards statements to the top of the new header file, and moving the main function to some other file, say main.cpp that includes chatbot.hpp. If we now try to compile main.cpp in exactly the same way, it still compiles!

c++ -std=c++17 main.cpp -o chatbot_example

馃挕 By the way, all class member functions defined in a header file are implicitly inline, so no need to worry about the One Definition Rule (ODR) violations.

We can of course also put the appropriate commands into a CMakeLists.txt file:

# Indicate that we have header-only library
add_library(chatbot INTERFACE)
target_link_libraries(chatbot INTERFACE cxx_setup)

# A binary that uses our header-only library
add_executable(chatbot_example main.cpp)
target_link_libraries(chatbot_example PRIVATE chatbot cxx_setup)

馃挕 If you are confused about the cxx_setup part, see the lecture on CMake

Converting it all into a compiled library

If we only need a header-only library, we could stop there, but sometimes we want a compiled library. For that we would have to split our header file into a header and a source file.

馃挕 Oh, and if you are shaky on the differences between the two or why it is important that the class member functions are implicitly inline, do check out my lecture on various kinds of libraries.

Generally speaking, all the data (apart from static data, stay tuned) belongs in the header file.

As for the implementation of any methods (and static data, stay tuned) we can move them to the source file, a new chatbot.cpp file, leaving only their declarations in the header file. In the definitions, we must tell the compiler that we are defining not just a free standing function, but one from a class, thus the Chatbot:: and the Chatbot::Data:: prefixes. Note also, that we have the Answer as a return type of the GetAnswer function. For such return types we also have to tell the compiler if they are part of some class, the Chatbot class in this example. Within the definition of the function we can use these types without the prefix as the compiler already knows that it operates within the namespace of a certain class.

Finally, note how the const postfix in functions that need it is present in both the header and the source file.

The final header-source code

chatbot.hpp:

#pragma once

#include <string>
#include <vector>

class Chatbot {
 public:
  struct Answer {
    float probability{};
    std::string text{};
  };

  struct Data {
    bool IsValid() const;

    std::vector<std::string> questions{};
    std::vector<std::string> correct_answers{};
  };

  void Train(const Data &data);

  Answer GetAnswer(const std::string &question) const;

 private:
  void IngestData(const Data &data);

  int smartness_{};
};

chatbot.cpp:

#include <chatbot/chatbot.hpp>

void Chatbot::Train(const Data &data) {
  if (!data.IsValid()) { return; }
  IngestData(data);
}

bool Chatbot::Data::IsValid() const {
  return questions.size() == correct_answers.size();
}

Chatbot::Answer Chatbot::GetAnswer(const std::string &question) const {
  if (smartness_ < 1) { return Answer{0.1, "I don't know"}; }
  if (smartness_ < 5) { return Answer{0.8, "Yes."}; }
  if (question.length() > 10) {
    return Answer{1.0, "You will regret this question..."};
  }
  return Answer{1.0, "Can't you ask anything more important?"};
}

void Chatbot::IngestData(const Data &data) {
  smartness_ += data.correct_answers.size();
}

main.cpp:

#include <chatbot/chatbot.hpp>

#include <iostream>

int main() {
  Chatbot chatbot{};
  chatbot.Train({{"How much is 2 + 2?",
                  "What color is the sky?",
                  "What is the answer to life and everything?"},
                 {"4", "It depends", "42"}});
  const auto question = "Are you self aware?";
  std::cout << "Asking chatbot: " << question << std::endl;
  std::cout << "Chatbot answered: " << chatbot.GetAnswer(question).text
            << std::endl;
  return 0;
}

And that's it. Now we just need to update our CMakeLists.txt file, create a compiled library in it and link it to a binary that has the main function in it:

Main CMakeLists.txt of the project:

cmake_minimum_required(VERSION 3.16..3.24)
project(chatbot VERSION 0.0.1
                    DESCRIPTION "Our first project"
                    LANGUAGES CXX)
if(NOT CMAKE_BUILD_TYPE)
  set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE)
endif()
message(STATUS "CMAKE_BUILD_TYPE: ${CMAKE_BUILD_TYPE}")

add_library(cxx_setup INTERFACE)
target_compile_options(cxx_setup INTERFACE -Wall -Wpedantic -Wextra)
target_compile_features(cxx_setup INTERFACE cxx_std_17)
target_include_directories(cxx_setup INTERFACE ${PROJECT_SOURCE_DIR})

add_subdirectory(${PROJECT_NAME})

chatbot/CMakeLists.txt:

# Create a compiled library
add_library(chatbot chatbot.cpp)
target_link_libraries(chatbot PUBLIC cxx_setup)

# A binary that uses our library
add_executable(chatbot_example main.cpp)
target_link_libraries(chatbot_example PRIVATE chatbot cxx_setup)

Conclusion

So, you see, there is only some marginal differences here and there but largely the pattern is exactly the same as we have already seen with the free-standing functions. It goes without saying that we can and should also test our classes with some unit testing library like GoogleTest too! I won't do it here but do give it a try on you own. More on that in one of my previous lectures.

So now we know how to create libraries from the code that lives either in free-standing functions or in our own your classes. We also know how to compile and link all of this code together with CMake, which means that we can write pretty complex projects from scratch while still maintaining a certain level of abstraction and overview over the logic. How cool is that?

I have to give you a short glimpse into what awaits us next! It is a homework where we will put all of this to the test and write a full program that reads an image and pixelates it. So stay tuned for the next video!