Chapitre 13 · Forme canonique, encapsulation & héritage

Ce qu'Objective-C faisait en coulisse — getters, setters, protected et la forme canonique C++

En ObjC, @property (nonatomic, strong) générait automatiquement un getter, un setter, et gérait l'ARC. En C++, tu déclares explicitement ce que ton objet sait faire avec lui-même. C'est plus verbeux — et c'est une très bonne chose.

La forme canonique C++ — Rule of 5

En ObjC, le compilateur prenait en charge la copie, l'assignation et le cycle de vie des objets. En C++, tu exprimes ces comportements explicitement. Un objet qui gère des ressources GPU doit définir les cinq opérations suivantes :

/// C++
class Renderer
{
public:
    // 1. Constructeur — acquiert les ressources
    Renderer(MTL::Device* device,
             const std::string& resourcePath,
             NS::UInteger width, NS::UInteger height,
             MTL::PixelFormat colorPixelFormat,
             MTL::PixelFormat depthPixelFormat);

    // 2. Destructeur — libère les ressources
    ~Renderer();

    // 3 & 4. Copie — supprimée : un Renderer possède un GPU, on ne le duplique pas
    Renderer(const Renderer&)            = delete;
    Renderer& operator=(const Renderer&) = delete;

    // 5 & 6. Move — supprimé aussi : géré par std::unique_ptr dans ApplicationController
    Renderer(Renderer&&)                 = delete;
    Renderer& operator=(Renderer&&)      = delete;
};
= delete
= delete n'est pas "je n'ai pas eu le temps de l'écrire" — c'est une décision explicite de conception. En déclarant la copie supprimée, tu empêches à la compilation toute tentative de dupliquer un objet qui possède des pointeurs GPU raw. std::unique_ptr<Renderer> dans ApplicationController repose précisément sur cette garantie.

La manière propre de l'écrire : créer un fichier que tu inclus quand nécessaire.

/// C++ — NonCopyable.hpp — update:18/05/26
#ifndef NONCOPYABLE_HPP
#define NONCOPYABLE_HPP

class NonCopyable
{
public:
    NonCopyable()                              = default;
    NonCopyable(const NonCopyable&)            = delete;
    NonCopyable(NonCopyable&&)                 = delete;
    NonCopyable& operator=(const NonCopyable&) = delete;
    NonCopyable& operator=(NonCopyable&&)      = delete;
};

#endif // NONCOPYABLE_HPP
/// C++ — Renderer.hpp — update:18/05/26
#include "NonCopyable.hpp"

// class Renderer
class Renderer : NonCopyable // Fait le job

L'Objective-C++ pointe vers ta classe, elle ne la copie pas.

Comparaison directe avec Objective-C

// Objective-C — le compilateur génère tout
@interface ApplicationController : NSObject
@property (nonatomic, strong, readonly) MTKView* mtkView;    // getter auto
@property (nonatomic, assign) BOOL isPaused;                // getter + setter auto
@end
// C++ équivalent — tu écris ce dont tu as besoin
class Renderer
{
public:
    // getter — const, ne modifie pas l'objet
    MTL::PixelFormat    pixelFormat()       const { return m_pixelFormat; }
    NS::UInteger        width()             const { return (NS::UInteger)m_viewport.width; }
    NS::UInteger        height()            const { return (NS::UInteger)m_viewport.height; }
    bool               isPaused()          const { return m_paused; }

    // setter — valide la donnée avant de l'accepter
    void setPaused(bool paused)                     { m_paused = paused; }
    void setPreferredFPS(uint32_t fps)
    {
        m_preferredFPS = (fps > 0 && fps <= 240) ? fps : 60;  // validation intégrée
    }

private:
    MTL::PixelFormat    m_pixelFormat;
    MTL::Viewport       m_viewport;
    bool               m_paused        = false;
    uint32_t           m_preferredFPS  = 60;
};

Un point rapide sur les équivalences ;

  1. @property (strong) → private: + constructeur + destructeur → en ObjC, strong gérait automatiquement le retain/release via ARC. En C++, tu l'écris une fois dans le constructeur (->retain()) et une fois dans le destructeur (->release()). C'est plus d'écriture, mais tu vois exactement quand la ressource est acquise et libérée.
  2. @property (readonly) → private: membre + public: getter const → en ObjC, readonly interdisait le setter depuis l'extérieur. En C++, l'équivalent est de mettre le membre en private et de n'exposer qu'un getter marqué const.
  3. @property (assign) → membre trivial sans gestion mémoire → types primitifs (BOOL, NSInteger, CGFloat) en ObjC. Équivalent direct en C++ : bool, int, float — pas de retain, pas de release.
  4. const après la signature → float width() const — le const dit au compilateur que cette méthode ne modifie pas l'état de l'objet. Cela permet de l'appeler sur un objet const Renderer& et active des optimisations du compilateur.

L'ARC en Objective-C vs la gestion manuelle en C++

// Ce que tu écrivais en ObjC
@property (nonatomic, strong) MTKView* mtkView;

// Ce que le compilateur générait automatiquement (ARC)
- (MTKView*)mtkView             { return _mtkView; }
- (void)setMtkView:(MTKView*)v  { [_mtkView release]; _mtkView = [v retain]; }
// Ce que tu écris en C++ dans le constructeur de Renderer
Renderer::Renderer(MTL::Device* device, ...)
: m_device(device->retain()),              // retain explicite — tu possèdes le device
  m_commandQueue(m_device->newCommandQueue()),
  m_shaderLibrary(m_device->newDefaultLibrary())
{}

Renderer::~Renderer()
{
    m_shaderLibrary->release();           // release dans l'ordre inverse
    m_commandQueue->release();
    m_device->release();
}
L'ordre du destructeur est important : libère dans l'ordre inverse de la construction. m_shaderLibrary a été créée depuis m_device, donc elle doit partir avant. Libérer le device en premier alors qu'un objet qui en dépend existe encore est un crash silencieux ou un comportement indéfini.

Visibilité : private / protected / public

/*
 *  En ObjC, l'encapsulation était optionnelle :
 *
 *  @public   → accessible partout (rare, déconseillé)
 *  @protected → accessible dans la classe et ses sous-classes
 *  @private  → accessible dans la classe seulement (par défaut)
 *
 *  La vraie encapsulation se faisait via les @property et
 *  les extensions de classe (@interface Foo ()).
 */
// En C++ — déclaré explicitement dans la classe
class Renderer
{
public:
    // Accessible depuis n'importe où — interface publique
    void draw(MTK::View* view, double timeStamp, float delta);
    void resizeViewAndUpdateViewportWindow(NS::UInteger width, NS::UInteger height);

protected:
    // Accessible dans Renderer ET ses sous-classes — pas depuis l'extérieur
    MTL::Device*        m_device;
    MTL::CommandQueue*  m_commandQueue;
    MTL::Library*       m_shaderLibrary;
    MTL::Viewport       m_viewport;
    MTL::PixelFormat    m_pixelFormat;

private:
    // Accessible uniquement dans Renderer — détail d'implémentation
    uint64_t            m_frame         = 0;
    uint32_t            m_frameIndexBuffer = 0;
    bool               m_paused         = false;
};

protected en pratique — l'héritage de renderer

La vraie utilité de protected apparaît quand tu veux spécialiser le renderer selon la technique de rendu :

/*
 *  Hiérarchie de renderers
 *
 *             Renderer          ← base commune, protected: device, commandQueue
 *                │
 *        ┌───────┴───────┐
 *  ForwardRenderer   DeferredRenderer   ← accès à protected, pas à private
 */

class ForwardRenderer : public Renderer
{
public:
    ForwardRenderer(MTL::Device* device,
                    const std::string& resourcePath,
                    NS::UInteger width, NS::UInteger height,
                    MTL::PixelFormat colorPixelFormat,
                    MTL::PixelFormat depthPixelFormat);

    void draw(MTK::View* view, double timeStamp, float delta) override;

private:
    MTL::RenderPipelineState*   m_pipelineState;    // spécifique au forward
    MTL::DepthStencilState*     m_depthStencilState;
};

void ForwardRenderer::draw(MTK::View* view, double timeStamp, float delta)
{
    // m_device        ✓ protected → accessible depuis ForwardRenderer
    // m_commandQueue  ✓ protected → accessible depuis ForwardRenderer
    // m_frame         ✗ private  → erreur de compilation

    auto* commandBuffer = m_commandQueue->commandBuffer();
    auto* rpd           = view->currentRenderPassDescriptor();
    auto* enc           = commandBuffer->renderCommandEncoder(rpd);

    enc->setViewport(m_viewport);                          // ✓ protected
    enc->setRenderPipelineState(m_pipelineState);          // ✓ private de ForwardRenderer
    enc->setDepthStencilState(m_depthStencilState);
    enc->endEncoding();

    commandBuffer->presentDrawable(view->currentDrawable());
    commandBuffer->commit();
}

Récapitulatif des trois niveaux ;

  1. public → interface que tu exposes au monde. draw(), resize(), getters. ApplicationController appelle ces méthodes directement via le unique_ptr<Renderer>.
  2. protected → ressources GPU partagées entre le renderer de base et ses spécialisations : m_device, m_commandQueue, m_shaderLibrary, m_viewport. Pas de setter nécessaire — les sous-classes les partagent mais ne les remplacent pas.
  3. private → état interne pur : compteur de frames, flags de pause, index de triple buffering. Aucune sous-classe n'a de raison légitime d'y toucher directement.
/*
 *  Tableau de correspondance ObjC → C++
 *
 *  ObjC                          C++
 *  ────────────────────────────────────────────────────────
 *  @property (strong)            private: T* m_x; + constructeur/destructeur
 *  @property (weak)              private: T* m_x; (pas de retain/release)
 *  @property (readonly)          private: T m_x; + public: T x() const;
 *  @property (copy) NSString*    private: std::string m_x;
 *  @public                       public:
 *  @protected                    protected:
 *  @private (défaut @impl)       private: (défaut class)
 *  alloc/init                    constructeur
 *  dealloc                       destructeur
 *  ARC retain/release            ->retain() / ->release() + Rule of 5
 *  NSNotification delegate       virtual void onEvent() = 0; (interface)
 */
struct vs class en C++

La seule différence entre struct et class en C++ est la visibilité par défaut : struct démarre en public:, class démarre en private:.

Convention dans ce projet : struct pour les données pures sans logique (SceneUniforms, VertexIn...), class pour les objets avec comportement et ressources (Renderer, RMDLZoneWT...).

Chapitre 0X · C++ orienté objet

La POO en C++

La programmation orienté objet en détails

Ce chapitre utilise la classe Camera comme fil conducteur. Elle est représentative d'un code C++ moderne : encapsulation stricte, const-correctness, cache paresseux, transitions animées.

1 — La classe

Une classe regroupe des données (membres) et les fonctions qui les manipulent (méthodes). Elle est le building block de la POO en C++.

/// C++
class Camera
{
public:
    Camera();
    ~Camera();

    simd::float3 position() const;

private: // données internes, inaccessibles de l'extérieur
    simd::float3 _up;
    simd::float3 _position;
    simd::float3 _direction;
};

#endif /* Camera_hpp */

2 — Encapsulation : public / private

L'encapsulation protège l'état interne. On expose une interface propre et on cache les détails d'implémentation :

class RMDLCamera
{
public:
    // interface publique — stable, documentée
    simd::float3    position()  const;
    simd::float4x4 viewMatrix() const;
    void            setPosition(simd::float3 newPosition);

private:
    // état interne — personne n'y touche directement
    simd::float3   _position;
    simd::float3   _direction;
    bool           _uniformsDirty;
    RMDLCameraUniforms _uniforms;

    // helpers internes — pas dans l'interface
    void orthogonalizeFromNewForward(simd::float3 newForward);
    void updateOrbitPosition();
};

updateOrbitPosition() est private : c'est un détail d'implémentation. L'appelant n'a pas à savoir que l'orbit recalcule la position — il appelle juste orbit().

Règle pratique
Tout ce qui peut changer sans casser le code appelant → private. Tout ce qui fait partie du contrat de la classe → public. En cas de doute, commence par private.

3 — Constructeur & liste d'initialisation

Le constructeur est appelé automatiquement à la création de l'objet. La liste d'initialisation (après le :) initialise les membres avant l'entrée dans le corps du constructeur — c'est plus efficace qu'une assignation dans le corps.

RMDLCamera::RMDLCamera()
: _position{0, 0, 0}      // liste d'initialisation
, _direction{0, 0, 1}
, _up{0, 1, 0}
, _viewAngle(0)
, _aspectRatio(1.0)
, _nearPlane(0.1f)
, _farPlane(100.0f)
, _width(0)
, _uniformsDirty(true)
{}  // corps vide : tout est fait dans la liste
Pour les types float, bool, ou des pointeurs bruts, la liste d'initialisation est obligatoire pour éviter des valeurs indéterminées. Pour les objets (std::string, std::vector…), ne pas initialiser appelle leur constructeur par défaut — mais la liste reste plus claire et parfois plus efficace.

Valeurs par défaut des membres (C++11)

On peut aussi initialiser directement dans la déclaration du membre, dans le header. C'est pratique pour les membres qui ont une valeur "naturelle" par défaut :

class RMDLCamera
{
private:
    // initialisés directement dans le header
    float        _yaw         = 0.0f;
    float        _pitch       = 0.0f;
    float        _pitchMin    = -M_PI_2 + 0.001f;
    float        _pitchMax    =  M_PI_2 - 0.1f;
    simd::float3 _orbitTarget = {0, 0, 0};
    float        _orbitDistance = 10.0f;
    bool         _orbitMode   = false;
    bool         _transitionActive = false;
};

Si le constructeur fournit aussi une valeur pour ce membre dans sa liste d'initialisation, c'est la valeur du constructeur qui gagne. Les deux mécanismes coexistent sans conflit.

4 — const : méthodes en lecture seule

Une méthode marquée const garantit qu'elle ne modifie pas l'état de l'objet. Elle peut être appelée sur un objet const ou une référence const&.

// ✅ const : lecture seule, appelable sur const RMDLCamera&
simd::float3 RMDLCamera::position()  const { return _position; }
simd::float3 RMDLCamera::forward()   const { return _direction; }
float        RMDLCamera::nearPlane() const { return _nearPlane; }
bool         RMDLCamera::isOrbitMode() const { return _orbitMode; }

// ❌ pas const : modifie _position, _uniformsDirty
void RMDLCamera::setPosition(simd::float3 v)
{
    _position      = v;
    _uniformsDirty = true;
}
Règle pratique
Tout getter, toute méthode de requête, tout snapshot → const. Si tu hésites, demande-toi : "est-ce que cet appel peut changer ce que renvoie un autre appel ?" Si non → const.

Const en paramètre

const s'applique aussi aux paramètres. Passer un objet par const& évite la copie et garantit qu'on ne le modifie pas :

// 'snap' est passé par const référence : pas de copie, pas de modification
void RMDLCamera::applySnapshot(const RMDLCameraSnapshot& snap)
{
    _position  = snap.position;
    _direction = snap.direction;
    // ...
    _uniformsDirty = true;
}

// 'other' est const : on ne peut appeler que ses méthodes const
void RMDLCamera::transitionTo(const RMDLCamera& other, float duration, /*...*/)
{
    transitionTo(other.snapshot(), duration); // snapshot() doit être const
}

5 — mutable : cache dans un objet const

mutable est le seul mot-clé qui permet de modifier un membre depuis une méthode const. Il sert exclusivement pour les caches : des données dérivées recalculées à la demande, qui ne font pas partie de l'état logique de l'objet.

Dans RMDLCamera, les matrices view/projection sont dérivées de la position, direction, fov… Elles ne sont pas l'état — elles en sont la conséquence calculée.

class RMDLCamera
{
private:
    // état logique — modifiable seulement depuis les méthodes non-const
    simd::float3 _position;
    float        _viewAngle;

    // cache dérivé — modifiable même depuis une méthode const
    mutable bool               _uniformsDirty;
    mutable RMDLCameraUniforms _uniforms;
};
// Peut être const car elle ne touche qu'aux membres mutable
void RMDLCamera::updateUniforms() const
{
    _uniforms.viewMatrix = sInvMatrixLookat(_position + _shakeOffset,
                                            _position + _shakeOffset + _direction, _up);
    // ... calcul des matrices ...
    _uniformsDirty = false;  // ✅ OK : _uniformsDirty est mutable
}

RMDLCameraUniforms RMDLCamera::uniforms() const
{
    if (_uniformsDirty)
        updateUniforms();    // ✅ appel d'une méthode const sur *this const
    return _uniforms;
}
mutable n'est pas une excuse pour contourner const. Il est réservé aux caches et aux membres de synchronisation (mutex). Modifier de l'état logique depuis une méthode const via mutable est un bug de design.

6 — Le pattern Dirty Flag

Le dirty flag est un pattern de cache paresseux (lazy evaluation) : on marque les données comme obsolètes quand l'état change, et on ne les recalcule qu'au moment où elles sont effectivement nécessaires.

// Chaque setter pose le flag :
void RMDLCamera::setPosition(simd::float3 v)
{
    _position      = v;
    _uniformsDirty = true;   // les matrices sont maintenant obsolètes
}

void RMDLCamera::setViewAngle(float v)
{
    _width         = 0;
    _viewAngle     = v;
    _uniformsDirty = true;
}

// Le getter recalcule seulement si nécessaire :
RMDLCameraUniforms RMDLCamera::uniforms() const
{
    if (_uniformsDirty)     // recalcul uniquement si quelque chose a changé
        updateUniforms();
    return _uniforms;       // sinon : retour immédiat du cache
}
Pourquoi c'est utile ici
updateUniforms() calcule 7 matrices (view, proj, viewProj, 3 inverses, frustum planes). Si tu enchaînes setPosition() + setViewAngle() + setDirection() dans le même frame, le recalcul n'a lieu qu'une seule fois, au premier appel à uniforms(). Sans dirty flag : 3 recalculs inutiles.

7 — enum class

Un enum class (enum fortement typé, C++11) est préférable au enum classique : les valeurs sont dans leur propre espace de noms, il n'y a pas de conversion implicite en int, et les collisions de noms sont impossibles.

// enum class : les valeurs sont préfixées par le type
enum class RMDLCameraEase {
    Linear,
    SmoothStep,
    SmootherStep,
    EaseInQuad,
    EaseOutQuad,
    EaseInOutQuad,
    EaseInCubic,
    EaseOutCubic,
    EaseInOutCubic,
    EaseInOutBack,
};
// Utilisation — le compilateur rejette toute confusion de type
camera.transitionTo(snap, 1.5f, RMDLCameraEase::EaseInOutCubic);

// switch exhaustif — le compilateur avertit si un cas manque
float RMDLCamera::applyEase(float t, RMDLCameraEase ease) const
{
    switch (ease)
    {
        case RMDLCameraEase::Linear:       return t;
        case RMDLCameraEase::SmoothStep:   return t * t * (3.0f - 2.0f * t);
        case RMDLCameraEase::EaseInQuad:   return t * t;
        case RMDLCameraEase::EaseOutQuad:  return t * (2.0f - t);
        // ...
    }
    return t;
}

8 — std::function comme membre

std::function permet de stocker n'importe quel callable (lambda, fonction, functor) en tant que membre. Ici il sert de callback de fin de transition — l'appelant passe ce qu'il veut exécuter quand la caméra arrive à destination.

// Dans le header :
#include <functional>

class RMDLCamera {
private:
    std::function<void()> _transitionOnComplete = nullptr;
};
// Signature de transitionTo :
void transitionTo(const RMDLCameraSnapshot& target,
                  float              duration,
                  RMDLCameraEase     ease       = RMDLCameraEase::SmoothStep,
                  std::function<void()> onComplete = nullptr);
// Appelé en fin de transition — usage lambda :
camera.transitionTo(cutsceneShot, 2.0f, RMDLCameraEase::EaseInOutCubic,
    [&]() {
        startDialogue();
        player.setControllable(true);
    });

// Dans updateTransition() — appel conditionnel :
if (t >= 1.0f)
{
    _transitionActive = false;
    applySnapshot(_transitionTo);
    if (_transitionOnComplete)          // teste si le callback est set
        _transitionOnComplete();         // appel
}

9 — struct vs class

En C++, la seule différence entre struct et class est l'accessibilité par défaut : public pour struct, private pour class. Par convention, struct est utilisé pour les agrégats de données pures sans invariants à protéger.

// struct : données publiques, pas de logique métier à protéger
struct RMDLCameraSnapshot
{
    simd::float3 position;
    simd::float3 direction;
    simd::float3 up;
    float        viewAngle;
    float        nearPlane;
    float        farPlane;
    simd::float3 orbitTarget;
    float        orbitDistance;
    float        yaw;
    float        pitch;
};

// class : état encapsulé, invariants maintenus, interface publique contrôlée
class RMDLCamera { /* ... */ };

RMDLCameraSnapshot est un simple sac de données. Il peut être copié, stocké, comparé, interpolé — sans logique. RMDLCamera maintient des invariants (base orthonormée, dirty flag cohérent, pitch dans ses limites) — d'où la class.

10 — Méthodes inline dans le header

Les petites méthodes (getters d'un mot) peuvent être définies directement dans le header. Le compilateur les inline automatiquement — zéro overhead d'appel de fonction.

class RMDLCamera
{
public:
    float        yaw()           const { return _yaw; }
    float        pitch()         const { return _pitch; }
    bool         isOrbitMode()   const { return _orbitMode; }
    simd::float3 orbitTarget()   const { return _orbitTarget; }
    float        orbitDistance() const { return _orbitDistance; }
    bool         isTransitioning() const { return _transitionActive; }
    void         cancelTransition()     { _transitionActive = false; }
};
Pour les méthodes non-triviales (calculs, allocations, appels système), définis-les dans le .cpp. Exposer le corps dans le header augmente le temps de compilation et peut forcer des recompilations en cascade si l'implémentation change.

Récapitulatif

Ce qu'on a vu à travers RMDLCamera :

class / struct → agrégat de données + méthodes. struct pour les données pures, class pour les objets avec invariants.
public / private → encapsuler l'état, exposer une interface stable. Les helpers internes (updateOrbitPosition) restent private.
Liste d'initialisation → initialiser avant le corps du constructeur. Obligatoire pour les membres sans constructeur par défaut.
const méthode → garantit la lecture seule. Permet l'appel sur const RMDLCamera&.
mutable → réservé aux caches. Permet de modifier un membre depuis une méthode const sans trahir le contrat.
Dirty flag → ne recalculer les données dérivées que quand c'est nécessaire. Ici : les 7 matrices GPU.
enum class → type énuméré fortement typé. Pas de conversion implicite, pas de collision de noms.
std::function → stocker un callable générique comme membre. Idéal pour les callbacks de fin d'animation.