POO 2 - Classes et héritage

Bonjour à toutes et à tous,

Avant de lire la suite, il est important d’avoir étudié le devlog précédent ou d’avoir de bonnes notions sur les metatables en langage lua.

Aujourd’hui je vais parler de Programmation Orientée Objet (POO), de classe, d’objet, d’héritage et de surcharge des opérateurs. L’objectif est de créer deux classes à l’aide des metatables, chacune traitée dans un fichier lua spécifique : une classe Point2D qui gère deux coordonnées x et y, et une classe Sprite qui ajoute la gestion d’une image et qui hérite de la classe précédente.

Voici tout d’abord le squelette du fichier main.lua qui servira à afficher et mettre à jour les différents objets créés. De plus, il permettra d’effectuer dans la fonction love.load les tests unitaires vérifiant l’ajout des différentes fonctionnalités. Je ne reviens pas dessus car il n’y a rien de nouveau.

io.stdout:setvbuf(‘no’)
love.graphics.setDefaultFilter(« nearest »)
if arg[#arg] == « -debug » then require(« mobdebug »).start() end

function math.dist(x1,y1, x2,y2) return ((x2-x1)^2+(y2-y1)^2)^0.5 end
function math.angle(x1,y1, x2,y2) return math.atan2(y2-y1, x2-x1) end

Point2D = require(‘Point2D’)
Sprite = require(‘Sprite’)

function love.load()
listPoints = {}
listSprites = {}
images = {}

images[1] = love.graphics.newImage(« images/dotGreen.png »)
images[2] = love.graphics.newImage(« images/dotRed.png »)
end

function love.update(dt)
print(« Number of Point2D : « ..#listPoints)
for _, point in ipairs(listPoints) do
point:update(dt)
end
end

function love.draw()
for _, point in ipairs(listPoints) do
point:draw()
end
end

A noter cependant les deux instructions qui serviront à appeler les deux classes précédemment mentionnées :

Point2D = require(‘Point2D’)
Sprite = require(‘Sprite’)

La première classe à implémenter est la classe Point2D qui gère les coordonnées. Mais avant cela petite précision sur ce qu’est une classe et ce qu’est un objet. Une classe représente un ensemble d’éléments (ou objets). Elle est composée d’attributs (ou variables) qui modifient l’état des objets et de méthodes (ou fonctions) qui modifient le comportement des objets. Ainsi, par définition, un objet est une instance de la classe.

Voici le code minimum (programmation modulaire) pour générer la classe Point2D (dans le fichier Point2D.lua) :

local Point2D = {}
return Point2D

Cependant, le but ici est de faire de la POO à l’aide des metatables. Le code suivant permet d’en ajouter une à la classe Point2D. Pour le moment, celle-ci ne gère que la métaméthode de lecture « __index », qui doit faire référence à la classe Point2D elle-même.

Point2D.__classMeta = {
__index = Point2D
}
setmetatable(Point2D, Point2D.__classMeta)

A ce stade, il est possible d’ajouter à la classe Point2D des méthodes et des attributs (implémentés dans le constructeur, c’est-à-dire la méthode Point2D:new). Voici un exemple de code capable de gérer deux coordonnées x et y :

function Point2D:new(pX, pY) — constructeur
local obj = {}

obj.x = pX or 0 — met la coordonnée à 0 si le paramètre pX vaut nil
obj.y = pY or 0 — met la coordonnée à 0 si le paramètre pY vaut nil

return obj
end

function Point2D:update(dt) — mise à jour de l’objet Point2D
print(« point is updated »)
end

function Point2D:distanceFrom(other) — renvoie la distance entre le Poin2D self et l’objet « other »
return math.dist(self.x, self.y, other.x, other.y)
end

function Point2D:angleFrom(other) — renvoie l’angle entre le Poin2D self et l’objet « other »
return math.angle(self.x, self.y, other.x, other.y)
end

function Point2D:draw() — affichage de l’objet Point2D
love.graphics.points(self.x, self.y)
end

Pour aller plus loin, on va également ajouter une metatable aux objets, ce qui permettra de modifier cette fois-ci non pas le comportement de la classe Point2D, mais celui des objets Point2D. Une seconde metatble doit donc être implémentée dans le constructeur Point2D:new :

Point2D.__objectMeta = {
__index = Point2D
}

function Point2D:new(pX, pY)
local obj = {}

obj.x = pX or 0
obj.y = pY or 0

return setmetatable(obj, Point2D.__objectMeta) – association de la metatable aux objets Point2D
end

Il est à noter que la metaméthode « __index » de la metatable « __objectMeta » fait elle aussi référence à Point2D.

A ce stade, tous les ingrédients nécessaires pour faire de la POO à l’aide des metatables sont mis en place. Il ne reste plus qu’à les utiliser en remplissant les metatables « __classMeta » et « __objectMeta ».

Pour la metatable associée à la classe Point2D, on va rajouter un metaméthode « __call » qui simplifie la création d’un nouvel objet :

Point2D.__classMeta = {
__index = Point2D,
__call = function(self, pX, pY) return Point2D:new(pX, pY) end
}

Ainsi il n’est plus nécessaire de passer par la fonction « Point2D :new » lors de la création d’un nouvel objet Point2D, Point2D() suffit. En effet, la metaméthode « __call » s’en occupe pour nous.

Pour la metatable associée aux objets Point2D, on va pour le moment lui ajouter deux metaméthodes : « __tostring » qui permettra l’affichage d’un Point2D grâce à la fonction string, et « __call » qui pourra spécifier les nouvelles coordonnées du Point2D.

Point2D.__objectMeta = {
__index = Point2D,
__call = function(self, pX, pY) self.x = pX or 0 self.y = pY or 0 end,
__tostring = function(self) return string.format(« (%d,%d) », self.x, self.y) end
}

Le code suivant gère les tests unitaires associés aux différents ajouts. Il est à placer dans la fonction love.load du fichier main.lua et montre toute la puissance des metatables. En effet, regardez comment leur utilisation permet de voir l’objet / la classe (qui sont en réalité des tables) comme de simples variables.

p1 = Point2D(10, 10) — génère un nouveau Point2D de coordonnées (10, 10)
table.insert(listPoints, p1)
print(p1) — affiche les coordonnées de p1

p2 = Point2D() — génère un nouveau Point2D de coordonnées (0, 0)
table.insert(listPoints, p2)
print(p2) — affiche les coordonnées de p2
p2() — spécifie les nouvelles coordonnées de p2 à (0,0)
print(p2)
p2(400, 400) — spécifie les nouvelles coordonnées de p2 à (400,400)
print(p2)

Il est également à noter que le code implémenté dans les fonctions love.update et love.draw fait appel au méthodes de classe Point2D:update et Point2D:draw sans se soucier de l’objet considéré.

Et ce n’est pas tout ! On va désormais pouvoir aller plus loin en parlant de la surcharge des opérateurs. Cette notion consiste à proposer des comportements spécifiques aux opérations arithmétiques, +, -, /, *, %… et à celles de relation d’ordre, ==, <, <=… Ces comportements sont alors partagés par tous les objets de la classe.
Dans mon exemple, voici les comportements que j'ai ajoutés à la metatable « __objectMeta » de la classe Point2D :

Point2D.__objectMeta = {
__index = Point2D,
__call = function(self, pX, pY) self.x = pX or 0 self.y = pY or 0 end,
__tostring = function(self) return string.format(« (%d,%d) », self.x, self.y) end,
__unm = function(self) return Point2D(-self.x, -self.y) end, — changement de signe
__add = function(self, other) return Point2D(self.x + other.x, self.y + other.y) end, — addition
__sub = function(self, other) return Point2D(self.x – other.x, self.y – other.y) end, — soustraction
__mul = function(i, self) return Point2D(i * self.x, i * self.y) end, — multiplication
__div = function(self, i) return Point2D(self.x / i, self.y / i) end, — division
__eq = function(self, other) return self.x == other.x and self.y == other.y end, — relation ==
__concat = function(st, self) return tostring(st).. » « ..tostring(self) end — concaténation ..
}

Libre à vous d’utiliser d’autres opérateurs et de modifier les règles de vos classes, tout est permis !

Le code ci-dessous s’ajoute dans la fonction love.load et montre l’utilité de la surcharge des opérateurs :

p3 = Point2D(100, 100)
table.insert(listPoints, p3)
p4 = Point2D(150, 150)
table.insert(listPoints, p4)

p3 = -p3 — change le signe de p3
print(p3)
p3 = -p3

table.remove(listPoints, 4)
p4 = p4 + p1 — ajoute p1 à p4
table.insert(listPoints, 4, p4)
print(p4)

p5 = p4 + p1 — crée un nouvel objet p5, somme de p4 et p1
table.insert(listPoints, p5)
print(p5)

p6 = p5 – p4 — crée un nouvel objet p6, soustraction de p5 et p4
print(p6)
table.insert(listPoints, p6)

p1 = 3 * p1 — multiplication de p1 par un entier valant 3
print(p1)
p1 = p1 / 3 — division de p1 par un entier valant 3
print(p1)

print(p1 == p3) — renvoie false
p3(p1.x, p1.y) — spécifie les coordonnées de p3 comme étant celles de p1
print(p1 == p3) — renvoie true

print(p2..p3) — affichage p2 et p3 dans la même chaîne de caractères
print(« p2 vaut : « ..p2)
print(p3.. » valeur de p3″)

print( » la distance entre « ..p1.. » et « ..p2.. » vaut « ..p1:distanceFrom(p2))
p4(600, 200)
print( » l’angle entre « ..p1.. » et « ..p4.. » vaut « ..math.deg(p1:angleFrom(p4)).. » degrés »)

Maintenant qu’on vient de créer une première classe Point2D qui fonctionne, il est temps d’ajouter une classe Sprite qui hérite de la classe Point2D. Mais avant tout, que signifie la notion d’héritage ? On dit que la classe B hérite de la classe A quand tous les attributs et méthodes constituant la classe A font également partie de la classe B. Ils n’ont donc pas besoin d’être de nouveau spécifiés dans la classe B mais peuvent toutefois être réécrits. La classe B permet également l’ajout de nouveaux attributs / méthodes spécifiques à sa classe. La classe A est alors appelée classe « mère » et la classe B classe « fille ».

Voici le code (très basique) nécessaire au fonctionnement de la classe Sprite qui ajoute la prise en compte d’une image. Libre à vous de le modifier pour gérer différemment la surcharge des opérateurs ou d’y ajouter de nouvelles méthodes ou spécificités.

local Sprite = {}

Point2D = require(‘Point2D’)

Sprite.__classMeta = {
__index = Point2D,
__call = function(self, pX, pY, pImage, pZ) return Sprite:new(pX, pY, pImage, pZ) end
}

Sprite.__objectMeta = {
__index = Sprite,
__call = function(self, pX, pY, pZ) self.x = pX or 0 self.y = pY or 0 if pZ ~= nil then self.zLevel = pZ end end,
__tostring = function(self) return string.format(« (%d,%d) zLevel : %d », self.x, self.y, self.zLevel) end,
__unm = function(self) return Sprite(-self.x, -self.y, self.image or -1, self.zLevel) end,
__add = function(self, other) return Sprite(self.x + other.x, self.y + other.y, self.image or -1, self.zLevel) end,
__sub = function(self, other) return Sprite(self.x – other.x, self.y – other.y, self.image or -1, self.zLevel) end,
__mul = function(i, self) return Sprite(i * self.x, i * self.y, self.image or -1, self.zLevel) end,
__div = function(self, i) return Sprite(self.x / i, self.y / i, self.image or -1, self.zLevel) end,
__eq = function(self, other) return self.x == other.x and self.y == other.y and self.image == other.image and self.zLevel == other.zLevel end,
__concat = function(st, self) return tostring(st).. » « ..tostring(self) end
}

setmetatable(Sprite, Sprite.__classMeta)

function Sprite:new(pX, pY, pImage, pZ)
local obj = setmetatable(Point2D(pX, pY), Sprite.__objectMeta)
obj.image = pImage or -1
if pImage ~= nil then
obj.width = pImage:getWidth()
obj.height = pImage:getHeight()
end

obj.zLevel = pZ or 1
return obj
end

function Sprite:setImage(pImage)
if pImage == nil then return end
self.image = pImage
self.width = pImage:getWidth()
self.height = pImage:getHeight()
end

function Sprite:setZLevel(pZ)
self.zLevel = pZ
end

function Sprite:update(dt)
print(« sprite updated dt = « ..dt)
end

function Sprite:draw()
if self.image ~= -1 then
love.graphics.draw(self.image, self.x, self.y, 0, 1, 1, 0.5 * self.width, 0.5 * self.height)
else
love.graphics.points(self.x, self.y)
end
end

return Sprite

Deux points importants sont à mentionner car ils permettent de faire hériter la classe Sprite de la classe Point2D. Là encore, tout s’effectue à l’aide des metatables :

Point2D = require(‘Point2D’)

Sprite.__classMeta = {
__index = Point2D,
__call = function(self, pX, pY, pImage, pZ) return Sprite:new(pX, pY, pImage, pZ) end
}

Il est ici extrêmement important que la metaméthode « __index » appelle la classe mère Point2D. Sans cela, pas d’héritage. De plus, selon ce que vous mettriez une erreur pourrait être soulevée.

Le constructeur Sprite :new nécessite également l’appel au constructeur de la classe Point2D :

function Sprite:new(pX, pY, pImage, pZ)
local obj = setmetatable(Point2D(pX, pY), Sprite.__objectMeta)
obj.image = pImage or -1
if pImage ~= nil then
obj.width = pImage:getWidth()
obj.height = pImage:getHeight()
end

obj.zLevel = pZ or 1
return obj
end

Il est temps de passer aux test unitaires de la fonction love.load. Et là, c’est magique ! 🙂 Il est possible de combiner tout un tas de propriétés et d’opérations grâce à l’héritage, et même de combiner des objets Point2D avec des objets Sprite puisqu’ils partagent une base de propriétés communes !

sprite1 = Sprite(600, 500, images[1], 3) — crée un nouveau Sprite positionné en 600,500
print(sprite1)
print( » la distance entre « ..p1.. » et « ..sprite1.. » vaut « ..p1:distanceFrom(sprite1))
table.insert(listPoints, sprite1)
table.insert(listSprites, sprite1)

sprite2 = Sprite() — crée un nouveau Sprite vide
sprite2(40, 40) — spécifie uniquement les coordonnées du Sprite
sprite2:setImage(images[2])
table.insert(listPoints, sprite2)
table.insert(listSprites, sprite2)
sprite2:setZLevel(5)
print(sprite2)
sprite2(100, 100, 10) — spécifie les coordonnées et l’attribut zLevel du Sprite
print(sprite2)

sprite3 = sprite1 – sprite2 — soustrait les coordonnées entre deux sprites et créé un nouveau sprite 3
print(sprite3)
table.insert(listPoints, sprite3)
table.insert(listSprites, sprite3)

print(sprite1..sprite2) — concatène deux sprites
print(sprite1 == sprite3) — compare deux sprites

De plus, dans les fonctions love.update et love.draw on gère une liste « listPoints » qui inclut à la fois des Point2D et des Sprite. Elles ne font aucune différence dans l’appel de la fonction mais renvoie bien un comportement différent selon le type d’objet.

Pour conclure ce devlog, vous pourrez trouver un fichier zip contenant les trois fichiers ainsi que les images permettant de faire fonctionner cet exemple. Si je trouve comment l’insérer, ce qui ne semble pas gagné… ^^’

Le prochain devlog portera sur la refonte du code de l’atelier « Interface graphique » en utilisant toutes les notions vues jusqu’à présent.

PS 1 : Si quelqu’un sait comment faire pour ajouter des tabulations dans le devlog je suis preneur car ça me permettrait de rendre les parties « code » plus claires en les indentant. 😉

PS 2 : De plus, si quelqu’un fait des tests / ajoute des spécificités puis rencontre un bug ou quelque chose qui ne semble pas fonctionner, je suis intéressé pour le savoir. Pour ma part cela semble bien fonctionner mais peut-être que je n’ai pas pensé à certains aspects en codant tout ça. Merci. 🙂

Laisser un commentaire

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.