Types et classes en C++
Déclaration de variables
Les variables doivent se déclarer avec leur type
type nomVariable;
// ex.
// int maVariable;
// float y;
Il est possible d'affecter une valeur directement lors de la déclaration
type nomVariable = valeur;
// ou similairement:
// type nomVariable(valeur);
// type nomVariable = type(valeur);
Il est possible de spécifier qu'une variable va être constante par le mot clé "const"
type const nomVariable = valeur;
// ou similairement
const type nomVariable = valeur;
Une variable qualifiée de "const" ne peut plus voir sa valeur modifiée.
int const a = 5;
a = 6; // erreur de compilation
Remarque: L'utilisation de "const" ne modifie généralement rien à l'exécution du programme, mais il permet d'aider le programmeur à éviter de modifier la valeur d'une variable qui ne le devrait pas par inadvertance. Il s'agit principalement d'un mot clé permettant de préserver une bonne qualité de code et d'éviter des bugs.
Durée de vie des variables
La
durée de vie des variables standard ("scope") est celle du bloc d'instruction dans laquelle elle est créée ("bloc scope") - c-a-d depuis sa déclaration jusqu'à l'accolade fermante "}" du bloc où elle a été créée.
int main()
{
if(someCondition) {
int x = 5; // x est défini dans le bloc "if"
...
// x existe jusqu'à la fin du bloc
}
// x n'existe plus ici
}
int main()
{
int x = 5; // x est défini dans le bloc
// de la fonction main()
if(someCondition) {
...
// x peut être utilisé ici (sous-bloc)
}
// x existe toujours jusqu'à la fin de main()
}
Remarque:
-
- Ce comportement est différent de celui de Python qui définit la durée de vie jusqu'à la fin de la fonction.
-
- On ne peut pas définir plusieurs variables ayant le même nom dans un même bloc (il est possible de le faire dans des sous-blocs, mais c'est à éviter).
-
- Bonne pratique: dans le cas général, préférez déclarer vos variables dans le bloc de plus courte durée de vie - cela améliore la lisibilité de votre code.
Classes
Considérez le code du répertoire
scenes_inf443/03a_cpp_classes/ qui définit un objet appelé
vec3 qui est composé de trois nombres flottants: x,y,z.
#include <iostream>
#include <cmath>
struct vec3 {
float x, y, z;
};
int main()
{
// create an un-initialized vec3
vec3 p1;
// create and initialize vec3
vec3 p2 = {1.0f, 2.0f, 5.0f};
// Access attributes of the struct
p2.y = -4.0f;
std::cout<<p2.x<<","<<p2.y<<","<<p2.z<<std::endl;
return 0;
}
Les objets en C++ se déclarent de la manière suivante en utilisant le mot clé
struct ou
class:
struct/class nomObject {
public:
// parametres et méthodes accessible publiquement
private:
// parametres et méthodes privées de l'objet.
};
Exemple de déclaration:
struct vec3 {
float x, y, z;
};
class vec3 {
public:
float x, y, z;
};
Utilisation:
int main() {
// Creation d'une instance de l'objet
vec3 p = {5.3f, 1.1f, 3.2f};
// Accès à un membre public
p.y = -4.5f;
}
Remarques:
-
- Les paramètres et méthodes d'une struct sont publics par défaut. Alors qu'ils sont privés pour une class. Il faut donc explicitement indiquer "public" pour une class, et pas nécessairement pour une struct. En dehors de cette différence, struct et class sont similaires.
-
- Il est commun d'utiliser struct pour des objets simples qui sont vus comme des agrégations d'attributs publics, et class pour des objets plus complexes avec des données privés et des états cachés. Mais cela peut dépendre des habitudes et des codes.
Méthodes
Les classes peuvent porter des
méthodes -- également appelée
fonction membres.
Exemple
struct vec3 {
float x, y, z;
float norm() const;
void display() const;
void normalize();
};
float vec3::norm() const {
return std::sqrt(x * x + y * y + z * z);
}
void vec3::normalize() {
float n = norm();
x /= n;
y /= n;
z /= n;
}
void vec3::display() const {
std::cout <<"(" << x << "," << y << "," << z << ")" << std::endl;
}
int main()
{
vec3 p2 = { 1.0f, 2.0f, 5.0f };
// Display the norm (doesn't change p2)
std::cout << p2.norm() << std::endl;
// modifies p2
p2.normalize();
// Display the values on the command line
p2.display();
return 0;
}
-
- Les méthodes peuvent accéder directement aux attributs des classes sans avoir à préciser par this comme en Java (ou self en Python). Il reste cependant possible de le faire explicitement (dans ce cas, ce serait this->x/y/z).
-
- On sépare généralement la déclaration de la structure montrant les attributs et les en-tête des méthodes, de l'implementation effective des méthodes.
-
-
- L'implémentation du code des méthodes déclarées dans la struct/class suit la syntaxe suivante
returnType objectName::methodName(arguments ...) [const] {
...
}
-
- Le mot clé const à la fin d'une méthode indique que les attributs de la méthode ne sont pas modifiées par celle-ci. Ce qui est le cas pour norm() et display(), mais pas normalize() qui modifie (x,y,z). Le mot clé est optionnel, mais il permet d'obtenir un code de meilleur qualité et d'éviter des bugs.
-
- En C++, le choix entre function appliquée à une classe et méthode d'une classe est laissé libre au choix du développeur. Dans le cas de vec3, il serait également possible de définir une fonction (et les deux options peuvent co-exister)
// Define norm to be a non-member function
float norm(vec3 const& p) {
return std::sqrt(p.x*p.x + p.y*p.y + p.z*p.z);
}
// note: the use of const& avoids to copy the argument of the vec3
// however float norm(vec3 p) would works too.
int main() {
...
vec3 p = ...
// Use norm as a function (and not a member function)
float n = norm(p);
}
-
> Creez une méthode dot et cross qui calculent respectivement le produit scalaire (renvoie un float), et vectoriel entre deux vec3 (renvoie un vec3).
-
-
On écrira alors a.dot(b), et a.cross(b).
-
> Créez également les mêmes comportement, mais cette fois en tant que simple fonctions:
-
-
On écrira alors dot(a,b) et cross(a,b)
Testez votre implémentation sur ce type d'utilisation
int main()
{
vec3 p1 = { -1.0f, 1.0f, 2.0f };
vec3 p2 = { 1.0f, 2.0f, 5.0f };
// Expected display: 11 ; 11
std::cout << p1.dot(p2) <<" ; "<<dot(p1,p2) << std::endl;
vec3 p3 = p1.cross(p2);
vec3 p4 = cross(p1, p2);
p3.display(); // Expected display: (1,7,-3)
p4.display(); // Same
return 0;
}
Solution possible
Opérateurs
C++ permet d'utiliser des opérateurs (/symbols) entre classes tels que * / + -, ce qui permet de simplifier l'écriture d'opérations vectorielles.
La plupart des opérateurs peuvent s'écrire comme des fonctions simples. Exemple de définition de l'opérateur + entre deux vec3
// Enable to write: c = a+b with vec3
vec3 operator+(vec3 const& a, vec3 const& b) {
return { a.x + b.x, a.y + b.y, a.z + b.z };
}
// First argument a -> variable before the operator
// Second argument b -> variable after the operator
int main()
{
vec3 a = { -1.0f, 1.0f, 2.0f };
vec3 b = { 1.0f, 2.0f, 5.0f };
vec3 c = a + b;
c.display();
return 0;
}
> Définissez l'opérateur *, de manière à pouvoir écrire le code suivant
int main()
{
vec3 a = { -1.0f, 1.0f, 2.0f };
vec3 b = 2.0f * a;
vec3 c = a * 2.0f;
b.display(); // Expect (-2,2,4)
c.display(); // Same as before
return 0;
}
Aide: Vous devez définir deux fonctions dont l'ordre des arguments est différent.
Solution possible
Constructeurs et destructeurs
Les informations suivantes sont à destination des étudiants qui souhaitent en savoir plus sur le C++. Il n'est pas nécessaire de connaitre ces détails pour réaliser les TP d'INF443.
Les objets peuvent être associés à des méthodes particulières qui sont appelées automatiquement.
-
- Le "constructeur" est appelé lors de la création d'un object.
-
- Le "destructeur" lors de la "fin de vie" d'une variable (fin du bloc courant pour les variables définies sur la pile).
Considérez le code présent du répertoire
scenes_inf443/03a_cpp_classes/b_constructor/.
-
Executez le, et retrouvez l'ordre des appels des différents constructeurs et destructeurs.
#include <iostream>
#include <cmath>
struct vec3 {
float x, y, z;
// Constructor (without argument)
vec3();
// Constructor with arguments
vec3(float x, float y);
// Copy constructor
vec3(vec3 const& v);
// Destructor
~vec3();
void display() const;
};
// This is another operator: <<, that is used to be able to write cout<<vec3
std::ostream& operator<<(std::ostream& s, vec3 const& v)
{
s << "(" << v.x << "," << v.y << "," << v.z << ")";
return s;
}
void vec3::display() const
{
std::cout<< "(" << x << "," << y << "," << z << ")";
}
vec3::vec3() {
std::cout << "> I am the empty constructor. I set everything to 1." << std::endl;
// This is an unusual construction for a vector
// only for the sake of learning c++
x = 1; y = 1; z = 1;
}
vec3::vec3(float x_arg, float y_arg)
{
std::cout << "> I am the constructor with two argument (" <<x_arg<<", "<<y_arg <<")"<< std::endl;
x = x_arg;
y = y_arg;
z = 0;
}
vec3::~vec3()
{
std::cout << "> I am the destructor of this vector: ";
display();
std::cout << std::endl;
// The destructor is useless in this example
}
vec3::vec3(vec3 const& to_be_copied)
{
std::cout << "> I am the copy constructor. I am going to copy this vector: ";
to_be_copied.display();
std::cout << std::endl;
x = to_be_copied.x;
y = to_be_copied.y;
z = to_be_copied.z+1 ;//this is a very bad idea, but we can do it anywhere!
// the "copied" version has now (x,y,z+1) as coordinates
}
int main()
{
vec3 a; // Empty constructor call
std::cout << "a=" << a << std::endl;
vec3 b = { 4,7 }; // Constructor with two arguments
std::cout << "b=" << b << std::endl;
{
std::cout << "-- Beginning of block --" << std::endl;
vec3 c = { 8,1 }; // Constructor with two arguments
std::cout << "c=" << c << std::endl;
std::cout << "-- End of block --" << std::endl;
} // Call of the destructor on c
vec3 d = b; // Call of the copy constructor
std::cout << "d=" << d << std::endl;
return 0; // destroy a and b
}
Les constructeurs permettent d'initialiser des données au moment de la création d'un l'objet.
-
- Les constructeurs n'ont pas de type de retour: leur objectif est de compléter les attributs/états de l'objet.
-
- On peut redéfinir/surcharger un constructeur avec des paramètres différent. Le constructeur approprié sera alors appelé automatiquement.
-
- Définir le constructeur est optionnel.
-
-
- Si aucun constructeur n'est défini, le compilateur génère automatiquement des constructeurs dits "par défaut" (voir règles précises) qui permettent de créer une classe non initialisée (sans paramètre) ou en initialisant trivialement des attributs simples dans l'ordre des arguments (ex. vec3 p =vec3{x,y,z} qui fonctionne directement).
-
- Si on définit un constructeur, alors les autres ne sont plus définis automatiquement.
-
- On pourra également rencontrer la syntaxe
vec3::vec3(float x_arg, float y_arg)
:x(x_arg), y(y_arg), z(z_arg)
{
}
qui aura le même effet que d'écrire x=x_arg, y=y_arg, etc. dans le cas des types de bases.
Le constructeur dit "par copie" prenant un objet de même type en paramètre (vec3(vec3 const& ))est particulier, et est utilisé automatiquement lorsque l'on initialize un objet à partir d'un autre et lors d'un passage d'argument "par copie" dans une fonction.
-
- Si le constructeur par copie n'est pas défini explicitement, le compilateur réalise automatiquement la copie de chaque attribut d'un objet à l'autre.
-
- L'utilisateur peut définir des comportements différents s'il le souhaite.
-
-
- Le cas présenté dans l'exemple n'a aucun intérêt, si ce n'est de montrer que l'on peut modifier ce comportement.
-
- Il peut être intéressant de modifier ce comportement dans le cas de variables de type "pointeur" où l'égalité simple entre variable n'est pas l'effet souhaité. Par exemple pour dupliquer le contenu mémoire d'une variable dont on ne stockerait que l'adresse (appelé "deep copy"). Ou encore pour dupliquer le contenu mémoire présent sur la carte graphique lorsque l'on réalise la copie d'un objet visualisé en OpenGL, ou un shader.
Le destructeur est une méthode automatiquement appelée à la fin de vie d'une variable: généralement en fin de bloc (ou après appel à
delete si la variable a été initialisé par
new).
-
- Le rôle du destructeur est typiquement de nettoyer la mémoire, ou l'état de certaines variables, en ayant connaissance que cet objet va être détruit.
Remarque générale:
-
- Sauf cas très particulier (pointeurs bruts, compteurs d'éléments, etc) il n'est pas nécessaire de définir les contructeurs par copies ni destructeurs. Les méthodes par défault donnent le comportement attendu.
-
- Si les constructeurs par défaut ont le comportement attendu, il est en fait préférable de ne pas les re-définir. Moins de risque de bugs et d'oubli si vous ajouter un attribut par la suite, et plus optimisés qu'une constructeur "custom".
Exercice possible
(Ne pas passer de temps sur cet exercice sauf si vous êtes vraiment en avance: passez à la partie Modélisation avant)
> Créez deux variables globales (variable définit au début du fichier) stockant respectivement:
-
- Le nombre de variables totales de type vec3 instanciée.
-
- Le nombre de variables de type vec3 actuallement "en vie".
Testez votre résultat sur le code suivant:
// Note: these variables could also be defined as static variables of the class vec3
int nbr_total_vec3 = 0; // Total number of instanciation of vec3
int nbr_active_vec3 = 0; // Number of currently vec3 that are alive in the program
struct vec3 {
float x, y, z;
... // your code here
}
void display_vec3_status() {
std::cout << "Number of vec3: (total=" << nbr_total_vec3 << " ; active=" << nbr_active_vec3 << ")" << std::endl;
}
int main()
{
vec3 a;
vec3 b = vec3(4,8,1);
display_vec3_status(); // Expect (total=2, active=2)
for (int k = 0; k < 3; ++k) {
vec3 temp = vec3(k, 1, 0); // creation of vec3
std::cout << "Loop, step=" << k << std::endl;
display_vec3_status(); // Expect (total=3+k, active=3)
}// at every new loop, the previous vec3 is destroyed
display_vec3_status(); // Expect (total=5, active=2)
{
vec3 d = a;
display_vec3_status(); // Expect (total=6, active=3)
}
display_vec3_status(); // Expect (total=6, active=2)
return 0; // destroy a and b
}
Solution possible