Pointeurs intelligents (smart pointers)

Motivation

Le soucis

Voici un code C :

void f() {
    int* p = malloc(sizeof(int));
    *p = 5;
}

Oh no... On oublier de libérer la mémoire. C'est une fuite mémoire. On aurait du écrire :

void f() {
    int* p = malloc(sizeof(int));
    *p = 5;
	...
	free(p);
}

Pareil, le langage C++ offre le même souci :

void f() {
    int* p = new int(5);
}

alors qu'il faut écrire :

void f() {
    int* p = new int(5);
	delete p;
}

C'est trop en demander d'embêter les gens à devoir écrire free(p) (en C) ou delete p (en C++). Il faut un code sûr.

Solution

Les pointeurs intelligents sont des structures de données qui permettent de garantir que la mémoire est libérée automatiquement. Ils sont disponibles en C++ via la STL et sont construit selon le principe RAII : si tu crées, tu t'occupes aussi de détruire. Ils sont disponibles nativement en Rust, et dans une moindre mesure en Python !

On oppose les pointeurs intelligents aux pointeurs bruts du C, C++, Pascal, qui ne sont que des adresses mémoires. Sauf besoin contraire, quand vous programmez en C++ ou Rust, il n'y a pas de raison d'utiliser des pointeurs bruts car ils ne sont pas sûrs (fuite mémoire).

La solution n'est pas parfaite, il peut encore y avoir des fuites mémoire dans des cas spéciaux. On verra ça à la fin. D'abord, soyons optimiste.

Principe

Voici un exemple d'utilisation d'un pointeur intelligent en C++ :

std::unique_ptr<int> p = std::make_unique<int>(5);

Pas de soucis car l'objet unique_ptr<int> est propriétaire du pointeur vers d'une zone mémoire où il y a écrit 5. Quand on quitte la fonction, l'objet est détruit et c'est dans le destructeur de l'objet que le pointeur est désalloué. Pas besoin d'utiliser free (C) ou delete.

Une solution qui ne marche pas

template <typename T>
class stupid_smart_ptr
{
	T* m_ptr {};
	
public:
	// Pass in a pointer to "own" via the constructor
	stupid_smart_ptr(T* ptr=nullptr)
		:m_ptr(ptr)
	{
	}

	// The destructor will make sure it gets deallocated
	~stupid_smart_ptr()
	{
		delete m_ptr;
	}

	// Overload dereference and operator-> so we can use stupid_smart_ptr like m_ptr.
	T& operator*() const { return *m_ptr; }
	T* operator->() const { return m_ptr; }
};

// A sample class to prove the above works
class Resource
{
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource destroyed\n"; }
};

int main()
{
	stupid_smart_ptr<Resource> res(new Resource()); // Note the allocation of memory here

	//...

	return 0;
} // no explicit delete needed: res goes out of scope here, and the destructor destroys the allocated Resource for us

Que fait le programme suivant :

int main()
{
	Auto_ptr1<Resource> res1(new Resource());
	Auto_ptr1<Resource> res2(res1); // Alternatively, don't initialize res2 and then assign res2 = res1;

	return 0;
} // res1 and res2 go out of scope here

?

Unique ptr

Un unique pointer est un pointeur intelligent qui ne peut pas être copié. Il est garanti "non copiable".

En C++ :

std::unique_ptr<int> p = std::make_unique<int>(5);

En Rust :

let b = Box::new(5);

Voici une implémentation académique d'une classe de unique pointer :

template <typename T>
class UniquePtr {
private:
    T* ptr;

public:
    // Default constructor
    explicit UniquePtr(T* p = nullptr) noexcept : ptr(p) {}

    // Destructor
    ~UniquePtr() {
        delete ptr;
    }

    // copy constructor and assignment DO NOT EXIST (unique ownership)
    UniquePtr(const UniquePtr&) = delete;
    UniquePtr& operator=(const UniquePtr&) = delete;

    // Move constructor
    UniquePtr(UniquePtr&& other) noexcept : ptr(other.ptr) {
        other.ptr = nullptr;
    }

    // Move assignment operator
    UniquePtr& operator=(UniquePtr&& other) noexcept {
        if (this != &other) {
            delete ptr;            // free current resource
            ptr = other.ptr;       // take ownership
            other.ptr = nullptr;   // release other's ownership
        }
        return *this;
    }


    // Access underlying pointer
    T* get() const noexcept { return this->ptr; }

    // Dereference operators
    T& operator*() const noexcept { return *this->ptr; }
    T* operator->() const noexcept { return this->ptr; }
};

Shared pointers

Un shared pointer est un pointeur intelligent qui compte combien il y a de référence vers la cellule mémoire. On dit que la cellule mémoire est partagée.

#include <memory>

void f() {
    int* rawp = new int(5);
    shared_ptr<int> p(rawp);
    ...
}
  • Qu'imprime le programme C++ ci-dessous ?
#include <memory>

void f() {
    int* rawp = new int(5);
    shared_ptr<int> p(rawp);
    auto q = p;
    std::cout << q.use_count();
}

2 car il y a deux shared pointers qui partage l'entier.

Le C++ offre une construction pour carrément faire disparaître le pointeur brut.

#include <memory>

void f() {
    auto p = std;;make_shared<int>(5);
    auto q = p;
    std::cout << q.use_count();
}

Implémentation d'un shared pointer

Solution naïve

On pourrait une structure de données avec le nombre de référence suivi d'un pointeur brut :

Est-ce que ça ça marche ? Non, car si on copie la structure, on ne peut pas mettre à jour le compteur de référence.

Solution en C++

En C++, un pointeur intelligent shared pointer est une structure avec un pointeur (brut) vers le nombre de référence, et un pointeur brut.

Ainsi, si on copie la structure, le nombre de références est partagé. Voici une implémentation fictive en C++ d'un shared pointer :

template<class T>
class my_shared_ptr
{
private:
	T * ptr = nullptr;
	unsigned int * refCount = nullptr;

public:
    //ptr should non nullptr
	my_shared_ptr(T * ptr) : ptr(ptr), refCount(new unsigned int(1))
	{
	}

	/*** Copy Semantics ***/
	my_shared_ptr(const my_shared_ptr & obj)
	{
		this->ptr = obj.ptr;
		this->refCount = obj.refCount;
		(*this->refCount)++;
	}

	my_shared_ptr& operator=(const my_shared_ptr & obj) // copy assignment
	{
		__cleanup__(); // cleanup any existing data
		
		this->ptr = obj.ptr;
		this->refCount = obj.refCount;
        (*this->refCount)++;
	}

	/*** Move Semantics ***/
	my_shared_ptr(my_shared_ptr && dyingObj)
	{
		this->ptr = dyingObj.ptr;
		this->refCount = dyingObj.refCount;
		dyingObj.ptr = dyingObj.refCount = nullptr;
	}

	my_shared_ptr& operator=(my_shared_ptr && dyingObj)
	{
		__cleanup__();
		
		this->ptr = dyingObj.ptr;
		this->refCount = dyingObj.refCount;
		dyingObj.ptr = dyingObj.refCount = nullptr;
	}

	unsigned int get_count() const
	{
		return *refCount;
	}

	T* get() const
	{
		return this->ptr;
	}

	T* operator->() const
	{
		return this->ptr;
	}

	T& operator*() const
	{
		return *this->ptr;
	}

	~my_shared_ptr() // destructor
	{
		__cleanup__();
	}

private:
	void __cleanup__()
	{
        if(refCount == nullptr)
            return;

		(*refCount)--;
		if (*refCount == 0)
		{
			if (nullptr != ptr)
				delete ptr;
			delete refCount;
		}
	}
};

En Rust

En Rust, la structure pour un pointeur intelligent shared pointer est un unique pointeur brut vers une structure qui contient le nombre de référence, suivi des données.

En Rust, il y a Rc (Reference Counted) :

#![allow(unused)]
fn main() {
use std::rc::Rc;
let five = Rc::new(5);
}

Il y a aussi Arc qui thread safe (en C++ shared_ptr est thread safe) :

#![allow(unused)]
fn main() {
use std::sync::Arc;

let mut data = Arc::new(5);
}