Instead of
struct IShape
{
    virtual ~IShape() = default;
    virtual void print() const = 0;
    virtual double area() const = 0;
    virtual double perimeter() const = 0;
    // .. and so on
};
Visitors can be used:
// The concrete shapes
struct Square;
struct Circle;
// The visitor interface
struct IShapeVisitor
{
    virtual ~IShapeVisitor() = default;
    virtual void visit(const Square&) = 0;
    virtual void visit(const Circle&) = 0;
};
// The shape interface
struct IShape
{
    virtual ~IShape() = default;
    virtual void accept(IShapeVisitor&) const = 0;
};
Now the concrete shapes:
struct Point {
    double x;
    double y;
};
struct Circle : IShape
{
    Circle(const Point& center, double radius) : center(center), radius(radius) {}
    
    // Each shape has to implement this method the same way
    void accept(IShapeVisitor& visitor) const override { visitor.visit(*this); }
    Point center;
    double radius;
};
struct Square : IShape
{
    Square(const Point& topLeft, double sideLength) :
         topLeft(topLeft), sideLength(sideLength)
    {}
    // Each shape has to implement this method the same way
    void accept(IShapeVisitor& visitor) const override { visitor.visit(*this); }
    Point topLeft;
    double sideLength;
};
then the visitors:
struct ShapePrinter : IShapeVisitor
{
    void visit(const Square&) override { std::cout << "Square"; }
    void visit(const Circle&) override { std::cout << "Circle"; }
};
struct ShapeAreaComputer : IShapeVisitor
{
    void visit(const Square& square) override
    {
        area = square.sideLength * square.sideLength;
    }
    void visit(const Circle& circle) override
    {
         area = M_PI * circle.radius * circle.radius;
    }
    double area = 0;
};
struct ShapePerimeterComputer : IShapeVisitor
{
    void visit(const Square& square) override { perimeter = 4. * square.sideLength; }
    void visit(const Circle& circle) override { perimeter = 2. * M_PI * circle.radius; }
    double perimeter = 0.;
};
And use it:
const Square square = {{-1., -1.}, 2.};
const Circle circle{{0., 0.}, 1.};
const IShape* shapes[2] = {&square, &circle};
ShapePrinter shapePrinter;
ShapeAreaComputer shapeAreaComputer;
ShapePerimeterComputer shapePerimeterComputer;
for (const auto* shape : shapes) {
    shape->accept(shapePrinter);
    std::cout << " has an area of ";
    // result will be stored in shapeAreaComputer.area
    shape->accept(shapeAreaComputer);
    // result will be stored in shapePerimeterComputer.perimeter
    shape->accept(shapePerimeterComputer); 
    std::cout << shapeAreaComputer.area
              << ", and a perimeter of "
              << shapePerimeterComputer.perimeter
              << std::endl;
}
Expected output:
Square has an area of 4, and a perimeter of 8
Circle has an area of 3.14159, and a perimeter of 6.28319
Explanation:
In void Square::accept(IShapeVisitor& visitor) const override { visitor.visit(*this); }, the static type of this is known, and so the chosen (at compile time) overload
is void IVisitor::visit(const Square&);.
For square.accept(visitor); call, the dynamic dispatch through virtual is used to know which accept to call.
Pros:
SerializeAsXml, ...) to the class IShape just by adding a new visitor.Cons:
Triangle, ...) requires to modifying all visitors.The alternative of putting all functionalities as virtual methods in IShape has opposite pros and cons: Adding new functionality requires to modify all existing shapes, but adding a new shape doesn't impact existing classes.