Since we can't know what option.person is at compile-time, we need to find a way to work around that at runtime.
One option for doing so is std::variant, which can store any number of different types; but does so at the cost of always having the same size as the largest templated type.
As an example, if I did this:
std::variant<char, int> myVariant = '!';
Even though myVariant holds a char (1 byte), it uses 4 bytes of RAM because an int is 4 bytes.
Using Variants
Rather than inheriting from different objects that do not share a common base at compile-time, we can maintain the 'base' type as a variable within Student instead.
#include <iostream>
#include <variant>
#include <concepts>
class Person {
public:
    void f()
    {
        std::cout << "I'm a person!\n";
    }
};
class University {
public:
    void f()
    {
        std::cout << "I'm a university!\n";
    }
};
class Student {
public:
    using variant_t = std::variant<Person, University>;
    variant_t base;
    // Here we accept an rvalue of any type, then we move it to the 'base' variable.
    //  if the type is not a Person or University, a compiler error is thrown.
    Student(auto&& owner) : base{ std::move(owner) } {}
    void g()
    {
        // METHOD 1:  Using std::holds_alternative & std::get
        //             This has the advantage of being the simplest & easiest to understand.
        if (std::holds_alternative<Person>(base))
            std::get<Person>(base).f();
        else if (std::holds_alternative<University>(base))
            std::get<University>(base).f();
        // METHOD 2:  Using std::get_if
        //             This has the advantage of being the shortest.
        if (auto* person = std::get_if<Person>(&base))
            person->f();
        else if (auto* university = std::get_if<University>(&base))
            university->f();
        // METHOD 3:  Using std::visit
        //             This has the advantage of throwing a meaningful compiler error if-
        //             -we modify `variant_t` and end up passing an unhandled type.
        std::visit([](auto&& owner) {
            using T = std::decay_t<decltype(owner)>;
            if constexpr (std::same_as<T, Person>)
                owner.f(); //< this calls  `Person::f()`
            else if constexpr (std::same_as<T, University>)
                owner.f(); //< this calls  `University::f()`
            else static_assert(false, "Not all potential variant types are handled!");
        }, base);
    }
};
In this example, I showed 3 different methods of accessing the underlying value of base.
As a result, the output is:

Further reading: