Expérience personnelle: passer du développement C de bas niveau à la programmation Java



L'article reflète l'expérience personnelle de l'auteur, un programmeur de microcontrôleurs passionné, qui, après de nombreuses années d'expérience dans le développement de microcontrôleurs en C (et un peu en C ++), a eu l'occasion de participer à un projet Java majeur pour développer des logiciels pour les décodeurs TV fonctionnant sous Android. Au cours de ce projet, j'ai pu collecter des notes sur les différences intéressantes entre les langages Java et C / C ++, évaluer différentes approches d'écriture de programmes. L'article ne prétend pas être une référence, il n'examine pas l'efficacité et la productivité des programmes Java. Il s'agit plutôt d'un recueil d'observations personnelles. Sauf indication contraire, il s'agit d'une version Java SE 7.

Différences de syntaxe et constructions de contrôle


En bref - les différences sont minimes, la syntaxe est très similaire. Les blocs de code sont également formés par une paire d'accolades {}. Les règles de compilation des identifiants sont les mêmes que pour C / C ++. La liste des mots clés est presque la même qu'en C / C ++. Types de données intégrés - similaires à ceux de C / C ++. Tableaux - tous sont également déclarés à l'aide de crochets.

Le contrôle construit if-else, while, do-while, for, switch sont également presque complètement identiques. Il est à noter qu'en Java il y avait des labels familiers aux programmeurs C (ceux qui sont utilisés avec le mot-clé goto et dont l'utilisation est fortement déconseillée). Cependant, Java a exclu la possibilité de passer à une étiquette en utilisant goto. Les étiquettes ne doivent être utilisées que pour quitter les boucles imbriquées:

outer: for (int i = 0; i < 5; i++) { inner: for (int j = 0; j < 5; j++) { if (i == 2) break inner; if (i == 3) continue outer; } } 

Pour améliorer la lisibilité des programmes en Java, une opportunité intéressante a été ajoutée pour séparer les chiffres des nombres longs avec un trait de soulignement:

 int value1 = 1_500_000; long value2 = 0xAA_BB_CC_DD; 

Extérieurement, un programme Java n'est pas très différent d'un programme C familier. La principale différence visuelle est que Java n'autorise pas les fonctions, variables, définitions de nouveaux types (structures), constantes, etc., qui sont "librement" situées dans le fichier source. Java est un langage orienté objet, donc toutes les entités de programme doivent appartenir à une classe. Une autre différence importante est l'absence d'un préprocesseur. Ces deux différences sont décrites plus en détail ci-dessous.

Approche objet en langage C


Lorsque nous écrivons de gros programmes en C, nous devons essentiellement travailler avec des objets. Le rôle de l'objet ici est joué par une structure qui décrit une certaine essence du «monde réel»:

 //   – «» struct Data { int field; char *str; /* ... */ }; 

Il existe également en C des méthodes de traitement des "objets" - structures - fonctions. Cependant, les fonctions ne sont pas essentiellement fusionnées avec les données. Oui, ils sont généralement placés dans un seul fichier, mais à chaque fois il est nécessaire de passer un pointeur sur l'objet à traiter dans la fonction "typique":

 int process(struct Data *ptr, int arg1, const char *arg2) { /* ... */ return result_code; } 

Vous ne pouvez utiliser «l'objet» qu'après avoir alloué de la mémoire pour le stocker:

 Data *data = malloc(sizeof(Data)); 

Dans un programme C, une fonction est généralement définie qui est responsable de l'initialisation de «l'objet» avant sa première utilisation:

 void init(struct Data *data) { data->field = 1541; data->str = NULL; } 

Ensuite, le cycle de vie d'un «objet» en C est généralement le suivant:

 /*    "" */ struct Data *data = malloc(sizeof(Data)); /*  "" */ init(data); /*   "" */ process(data, 0, "string"); /*  ,  ""     . */ free(data); 

Maintenant, nous listons les erreurs d'exécution possibles qui peuvent être faites par le programmeur dans le cycle de vie de «l'objet»:

  1. Oubliez d'allouer de la mémoire pour "l'objet"
  2. Spécifiez la mauvaise quantité de mémoire allouée
  3. Oubliez d'initialiser "l'objet"
  4. Oubliez de libérer de la mémoire après avoir utilisé l'objet

Il peut être extrêmement difficile de détecter de telles erreurs, car elles ne sont pas détectées par le compilateur et apparaissent pendant le fonctionnement du programme. De plus, leur effet peut être très divers et affecter d'autres variables et «objets» du programme.

Approche des objets Java


Face à OOP - programmation orientée objet, vous avez probablement entendu parler d'une des baleines OOP - encapsulation. En Java, contrairement à C, les données et les méthodes pour les traiter sont combinées ensemble et sont de «vrais» objets. En termes de POO, cela s'appelle l'encapsulation. Une classe est une description d'un objet, l'analogue le plus proche d'une classe en C est de définir un nouveau type en utilisant la structure typedef. En termes Java, les fonctions qui appartiennent à une classe sont appelées méthodes.

 //   class Entity { public int field; //   public String str; //   //  public int process(int arg1, String arg2) { /* ... */ return resultCode; } //  public Entity() { field = 1541; str = "value"; } } 

L'idéologie du langage Java est basée sur l'énoncé «tout est un objet». Par conséquent, il n'est pas surprenant que Java interdise la création de méthodes (fonctions) et de champs de données (variables) séparément de la classe. Même la méthode main () familière, à partir de laquelle le programme démarre, doit appartenir à l'une des classes.

Une définition de classe en Java est analogue à une déclaration de structure en C. En décrivant une classe, vous ne créez rien en mémoire. Un objet de cette classe apparaît lors de sa création par le nouvel opérateur. La création d'un objet en Java est un analogue de l'allocation de mémoire dans le langage C, mais, contrairement à ce dernier, une méthode spéciale est automatiquement appelée lors de la création de l'objet - le constructeur de l'objet. Le constructeur joue le rôle de l'initialisation initiale de l'objet - un analogue de la fonction init () discutée précédemment. Le nom du constructeur doit correspondre au nom de la classe. Le constructeur ne peut pas retourner de valeur.

Le cycle de vie d'un objet dans un programme Java est le suivant:

 //   (   ,  ) Entity entity = new Entity(); //    entity.process(123, "argument"); 

Notez que le nombre d'erreurs possibles dans un programme Java est beaucoup plus petit que dans un programme C. Oui, vous pouvez toujours oublier de créer un objet avant la première utilisation (ce qui, cependant, conduira à une exception NullPointerException facilement déboguée), mais comme pour les autres erreurs inhérentes Programmes C, la situation est en train de changer fondamentalement:

  1. Il n'y a pas d'opérateur sizeof () en Java. Le compilateur Java calcule lui-même la quantité de mémoire pour stocker l'objet. Par conséquent, il n'est pas possible de spécifier la mauvaise taille de la sélection.
  2. L'initialisation de l'objet a lieu au moment de la création. Il est impossible d'oublier l'initialisation.
  3. La mémoire occupée par l'objet n'a pas besoin d'être libérée; le garbage collector fait ce travail. Il est impossible d'oublier de supprimer un objet après utilisation - il y a moins de risque d'effet de «fuite de mémoire».

Ainsi, tout en Java est un objet d'une classe ou d'une autre. Les exceptions sont des primitives qui ont été ajoutées au langage pour améliorer les performances et la consommation de mémoire. Vous trouverez plus d'informations sur les primitives ci-dessous.

Mémoire et collecteur d'ordures


Java conserve les concepts familiers de tas et de pile pour C / C ++, un programmeur. Lors de la création d'un objet avec le nouvel opérateur, la mémoire pour stocker l'objet est empruntée au tas. Cependant, un lien vers un objet (un lien est un analogue d'un pointeur), si l'objet créé ne fait pas partie d'un autre objet, est placé sur la pile. Sur le tas sont stockés les "corps" des objets, et sur la pile sont des variables locales: références aux objets et types primitifs. Si le segment de mémoire existe pendant l'exécution du programme et est disponible pour tous les threads du programme, la pile appartient à la méthode et n'existe que pendant son exécution et est également inaccessible aux autres threads du programme.

Java est inutile et plus encore - vous ne pouvez pas libérer manuellement la mémoire occupée par un objet. Ce travail est effectué par le garbage collector en mode automatique. Le runtime contrôle s'il est possible d'atteindre chaque objet du tas à partir de l'emplacement actuel du programme en suivant les liens d'objet à objet. Sinon, un tel objet est reconnu comme «poubelle» et devient un candidat à la suppression.

Il est important de noter que la suppression elle-même ne se produit pas au moment où l'objet «n'est plus nécessaire» - le garbage collector décide de la suppression, et la suppression peut être retardée autant que souhaité, jusqu'à la fin du programme.

Bien sûr, le travail du garbage collector nécessite une surcharge du processeur. Mais en retour, il soulage le programmeur d'un gros mal de tête lié à la nécessité de libérer de la mémoire après la fin de l'utilisation des "objets". En fait, nous «prenons» la mémoire quand nous en avons besoin et nous l'utilisons, sans penser que nous devons la libérer après nous-mêmes.

En parlant de variables locales, rappelons l'approche de Java pour leur initialisation. Si en C / C ++ une variable locale non initialisée contient une valeur aléatoire, alors le compilateur Java ne permettra tout simplement pas de la laisser non initialisée:

 int i; //  . System.out.println("" + i); //  ! 

Liens - Pointeurs de remplacement


Java n'a pas de pointeurs; par conséquent, un programmeur Java n'a pas la possibilité de faire l'une des nombreuses erreurs qui se produisent lors de l'utilisation des pointeurs. Lorsque vous créez un objet, vous obtenez un lien vers cet objet:

 //  entity –  . Entity entity = new Entity(); 

En C, le programmeur avait le choix: comment passer, disons, une structure à une fonction. Vous pouvez passer par valeur:

 //    . int func(Data data);    –   : //    . void process(Data *data); 

Le passage par la valeur garantissait que la fonction ne modifierait pas les données de la structure, mais était inefficace en termes de performances - au moment où la fonction a été appelée, une copie de la structure a été créée. Passer par un pointeur est beaucoup plus efficace: en fait, l'adresse en mémoire où se trouve la structure a été transmise à la fonction.

En Java, il n'y avait qu'une seule façon de passer un objet à une méthode - par référence. Passer par référence en Java est analogue à passer par un pointeur en C:
  • la copie (clonage) de la mémoire ne se produit pas,
  • en fait, l'adresse de la localisation de cet objet est transmise.

Cependant, contrairement au pointeur du langage C, un lien Java ne peut pas être incrémenté / décrémenté. «Exécuter» les éléments d'un tableau à l'aide d'un lien vers celui-ci en Java ne fonctionnera pas. Tout ce qui peut être fait avec un lien est de lui donner une valeur différente.

Bien sûr, l'absence de pointeurs en tant que tels réduit le nombre d'erreurs possibles, cependant, l'analogue du pointeur nul reste dans la langue - une référence nulle désignée par le mot-clé null.

Une référence nulle est un casse-tête pour un programmeur Java, comme force la référence d'objet à être vérifiée pour null avant de l'utiliser ou à gérer les exceptions NullPointerException. Si cela n'est pas fait, le programme se bloquera.

Ainsi, tous les objets en Java sont transmis via des liens. Les types de données primitifs (int, long, char ...) sont passés par valeur (plus d'informations sur les primitives sont données ci-dessous).

Fonctionnalités de Java Link


L'accès à n'importe quel objet du programme se fait via un lien - cela a clairement un effet positif sur les performances, mais cela peut surprendre un débutant:

 //  ,   entity1   . Entity entity1 = new Entity(); entity1.field = 123; //   entity2,     entity1. //    !   ! Entity entity2 = entity1; //   entity1  entity2         . entity2.field = 777; //  entity1.field  777. System.out.println(entity1.field); 

Arguments de méthode et valeurs de retour - tout est transmis via le lien. En plus des avantages, il y a un inconvénient par rapport aux langages C / C ++, où nous pouvons explicitement interdire aux fonctions de changer la valeur transmise via un pointeur à l'aide d'un qualificatif const:

 void func(const struct Data* data) { //  ! //    ,    ! data->field = 0; } 

Autrement dit, le langage C vous permet de suivre cette erreur au stade de la compilation. Java a également le mot clé const, mais il est réservé pour les futures versions et n'est actuellement pas utilisé du tout. Dans une certaine mesure, le mot clé final est appelé à remplir son rôle. Cependant, il ne protège pas l'objet transmis à la méthode contre les modifications:

 public class Main { void func(final Entity data) { //    . //    final,    . data.field = 0; } } 

Le fait est que le mot-clé final dans ce cas est appliqué au lien, et non à l'objet vers lequel le lien pointe. Si final est appliqué à la primitive, le compilateur se comporte comme prévu:

 void func(final int value) { //    . value = 0; } 

Les liens Java sont très similaires aux liens du langage C ++.

Primitives Java


Chaque objet Java, en plus des champs de données, contient des informations de support. Si nous voulons fonctionner, par exemple, dans des octets séparés et que chaque octet est représenté par un objet, alors dans le cas d'un tableau d'octets, la surcharge de mémoire peut plusieurs fois dépasser la taille utilisable.
Afin que Java reste suffisamment efficace dans les cas décrits ci-dessus, la prise en charge des types primitifs - primitifs - a été ajoutée au langage.
PrimitifAfficherProfondeur de bitsAnalogue possible en C
octetEntier8char
court16court
char16wchar_t
int32int (long)
longue64longue
flotterNuméros à virgule flottante32flotter
double64double
booléenLogique-int (C89) / bool (C99)

Toutes les primitives ont leurs analogues dans le langage C. Cependant, la norme C ne détermine pas la taille exacte des types entiers, mais la plage de valeurs que ce type peut stocker est fixe. Souvent, le programmeur veut garantir la même profondeur de bits pour différentes machines, ce qui conduit à l'apparition de types comme uint32_t dans le programme, bien que toutes les fonctions de bibliothèque nécessitent juste des arguments de type int.
Ce fait ne peut être attribué aux avantages de la langue.

Les primitives d'entier Java, contrairement à C, ont des profondeurs de bits fixes. Ainsi, vous n'avez pas à vous soucier de la profondeur de bits réelle de la machine sur laquelle le programme Java s'exécute, ainsi que de l'ordre des octets ("réseau" ou "Intel"). Ce fait permet de réaliser le principe «il est écrit une fois - il se réalise partout».

De plus, en Java, toutes les primitives entières sont signées (le langage n'a pas le mot-clé non signé). Cela élimine la difficulté d'utiliser des variables signées et non signées dans une seule expression inhérente à C.

En conclusion, l'ordre des octets dans les primitives multi-octets en Java est fixe (octet bas à adresse basse, Little-endian, ordre inverse).

Les inconvénients de l'implémentation d'opérations avec des primitives en Java incluent le fait qu'ici, comme dans le programme C / C ++, le débordement de la grille de bits peut se produire, sans exception:

 int i1 = 2_147_483_640; int i2 = 2_147_483_640; int r = (i1 + i2); // r = -16 

Ainsi, les données en Java sont représentées par deux types d'entités: les objets et les primitives. Les primitifs violent le concept de «tout est un objet», mais dans certaines situations, ils sont trop efficaces pour ne pas les utiliser.

Héritage


L'héritage est une autre baleine OOP dont vous avez probablement entendu parler. Si vous répondez brièvement à la question «pourquoi l'héritage est-il nécessaire du tout», alors la réponse sera «réutilisation du code».

Supposons que vous programmiez en C et que vous ayez une «classe» bien écrite et déboguée - une structure et des fonctions pour le traiter. Ensuite, le besoin se fait sentir de créer une «classe» similaire, mais avec des fonctionnalités améliorées, et la «classe» de base est toujours nécessaire. Dans le cas du langage C, vous n'avez qu'une seule façon de résoudre ce problème: la composition. Il s'agit de créer une nouvelle structure étendue - "classe", qui devrait contenir un pointeur sur la structure de base "classe":

 struct Base { int field1; char *field2; }; void baseMethod(struct Base *obj, int arg); struct Extended { struct Base *base; int auxField; }; void extendedMethod(struct Extended *obj, int arg) { baseMethod(obj->base, 123); /* ... */ } 

Java en tant que langage orienté objet vous permet d'étendre les fonctionnalités des classes existantes en utilisant le mécanisme d'héritage:

 //   class Base { protected int baseField; private int hidden; public void baseMethod() { } } //   -   . class Extended extends Base { public void extendedMethod() { //    public  protected     . baseField = 123; baseMethod(); // !   private  ! hidden = 123; } } 

Il convient de noter que Java n'interdit en aucun cas l'utilisation de la composition comme un moyen d'étendre les fonctionnalités de classes déjà écrites. De plus, dans de nombreuses situations, la composition est préférable à l'héritage.

Grâce à l'héritage, les classes en Java sont organisées dans une structure hiérarchique, chaque classe a nécessairement un et un seul «parent» et peut avoir n'importe quel nombre «d'enfants». Contrairement à C ++, une classe en Java ne peut pas hériter de plusieurs parents (cela résout le problème de "l'héritage diamant").

Pendant l'héritage, la classe dérivée obtient à son emplacement tous les champs et méthodes publics et protégés de sa classe de base, ainsi que la classe de base de sa classe de base, et ainsi de suite dans la hiérarchie d'héritage.

Au sommet de la hiérarchie d'héritage se trouve le progéniteur commun de toutes les classes Java - la classe Object, la seule qui n'a pas de parent.

Identification de type dynamique


L'un des points clés du langage Java est la prise en charge de l'identification de type dynamique (RTTI). En termes simples, RTTI vous permet de remplacer un objet d'une classe dérivée où une référence à la base est requise:

 //     Base link; //         link = new Extended(); 

Ayant un lien à l'exécution, vous pouvez déterminer le vrai type de l'objet auquel le lien se réfère - en utilisant l'opérateur instanceof:

 if (link instanceof Base) { // false } else if (link instanceof Extended) { // true } 

Substitutions de méthode


Redéfinir une méthode ou une fonction signifie remplacer son corps au stade de l'exécution du programme. Les programmeurs C sont conscients de la capacité d'un langage à modifier le comportement d'une fonction pendant l'exécution du programme. Il s'agit d'utiliser des pointeurs de fonction. Par exemple, vous pouvez inclure un pointeur vers une fonction dans la structure de la structure et attribuer diverses fonctions au pointeur pour modifier l'algorithme de traitement des données de cette structure:

 struct Object { //   . void (*process)(struct Object *); int data; }; void divideByTwo(struct Object *obj) { obj->data = obj->data / 2; } void square(struct Object *obj) { obj->data = obj->data * obj->data; } struct Object obj; obj.data = 123; obj.process = divideByTwo; obj.process(&obj); // 123 / 2 = 61 obj.process = square; obj.process(&obj); // 61 * 61 = 3721 

En Java, comme dans d'autres langages OOP, les méthodes de substitution sont inextricablement liées à l'héritage. Une classe dérivée accède aux méthodes publiques et protégées de la classe de base. Outre le fait qu'il puisse les appeler, vous pouvez changer le comportement d'une des méthodes de la classe de base sans changer sa signature. Pour ce faire, il suffit de définir une méthode avec exactement la même signature dans la classe dérivée:

 //   -   . class Extended extends Base { //  . public void method() { /* ... */ } //     ! // E      . //     . public void method(int i) { /* ... */ } } 

Il est très important que la signature (nom de la méthode, valeur de retour, arguments) corresponde exactement. Si le nom de la méthode correspond et que les arguments diffèrent, la méthode est surchargée, plus d'informations sur ce qui suit.

Polymorphisme


Comme l'encapsulation et l'héritage, la troisième baleine OOP - le polymorphisme - a également une sorte d'analogue dans le langage C orienté vers la procédure.

Supposons que nous ayons plusieurs "classes" de structures avec lesquelles vous souhaitez effectuer le même type d'action, et que la fonction qui exécute cette action doit être universelle - doit "pouvoir" fonctionner avec n'importe quelle "classe" comme argument. Une solution possible est la suivante:

  /*   */ enum Ids { ID_A, ID_B }; struct ClassA { int id; /* ... */ } void aInit(ClassA obj) { obj->id = ID_A; } struct ClassB { int id; /* ... */ } void bInit(ClassB obj) { obj->id = ID_B; } /* klass -   ClassA, ClassB, ... */ void commonFunc(void *klass) { /*   */ int id = (int *)klass; switch (id) { case ID_A: ClassA *obj = (ClassA *) klass; /* ... */ break; case ID_B: ClassB *obj = (ClassB *) klass; /* ... */ break; } /* ... */ } 

La solution semble lourde, mais le but est atteint - la fonction universelle commonFunc () accepte l '"objet" de n'importe quelle "classe" comme argument. Une condition préalable est qu'une structure de «classe» dans le premier champ doit contenir un identifiant par lequel la «classe» réelle de l'objet est déterminée. Une telle solution est possible grâce à l'utilisation de l'argument de type «void *». Cependant, un pointeur de n'importe quel type peut être transmis à une telle fonction, par exemple, "int *". Cela ne provoquera pas d'erreurs de compilation, mais au moment de l'exécution, le programme se comportera de manière imprévisible.

Voyons maintenant à quoi ressemble le polymorphisme en Java (cependant, comme dans tout autre langage OOP). Supposons que nous ayons de nombreuses classes qui devraient être traitées de la même manière par une méthode. Contrairement à la solution pour le langage C présentée ci-dessus, cette méthode polymorphe DOIT être incluse dans toutes les classes de l'ensemble donné, et toutes ses versions DOIVENT avoir la même signature.

 class A { public void method() {/* ... */} } class B { public void method() {/* ... */} } class C { public void method() {/* ... */} } 

Ensuite, vous devez forcer le compilateur à appeler exactement la version de la méthode qui appartient à la classe correspondante.

 void executor(_set_of_class_ klass) { klass.method(); } 

C'est-à-dire que la méthode executor (), qui peut être n'importe où dans le programme, doit pouvoir travailler avec n'importe quelle classe de l'ensemble (A, B ou C). Nous devons en quelque sorte «dire» au compilateur que _set_of_class_ désigne nos nombreuses classes. Ici, l'héritage est utile - il est nécessaire de faire toutes les classes à partir des dérivés d'ensemble de certaines classes de base, qui contiendront une méthode polymorphe:

 abstract class Base { abstract public void method(); } class A extends Base { public void method() {/* ... */} } class B extends Base { public void method() {/* ... */} } class C extends Base { public void method() {/* ... */} }   executor()   : void executor(Base klass) { klass.method(); } 

Et maintenant, toute classe héritière de Base (grâce à l'identification de type dynamique) peut lui être passée en argument:

 executor(new A()); executor(new B()); executor(new C()); 

Selon l'objet de classe qui est passé en argument, une méthode appartenant à cette classe sera appelée.

Le mot-clé abstrait vous permet d'exclure le corps de la méthode (rendez-le abstrait, en termes de POO). En fait, nous disons au compilateur que cette méthode doit être surchargée dans les classes qui en dérivent. Si ce n'est pas le cas, une erreur de compilation se produit. Une classe contenant au moins une méthode abstraite est également appelée abstraite. Le compilateur nécessite également de marquer ces classes avec le mot clé abstract.

Structure de projet Java


En Java, tous les fichiers source ont l'extension * .java. Les fichiers d'en-tête * .h et les prototypes de fonctions ou de classes sont manquants. Chaque fichier source Java doit contenir au moins une classe. Le nom de la classe est d'usage à écrire, en commençant par une majuscule.

Plusieurs fichiers avec du code source peuvent être combinés dans un package. Pour ce faire, les conditions suivantes doivent être remplies:
  1. Les fichiers avec le code source doivent se trouver dans le même répertoire du système de fichiers.
  2. Le nom de ce répertoire doit correspondre au nom du package.
  3. Au début de chaque fichier source, le package auquel appartient ce fichier doit être indiqué, par exemple:

 package com.company.pkg; 

Pour garantir l'unicité des noms de packages dans le monde, il est proposé d'utiliser le nom de domaine "inversé" de la société. Cependant, ce n'est pas une exigence et tous les noms peuvent être utilisés dans le projet local.

Il est également recommandé de spécifier des noms de package en minuscules. Ils peuvent donc être facilement distingués des noms de classe.

Dissimulation de la mise en œuvre


Un autre aspect de l'encapsulation est la séparation de l'interface et de la mise en œuvre. Si l'interface est accessible aux parties externes du programme (externes au module ou à la classe), l'implémentation est masquée. Dans la littérature, une analogie de boîte noire est souvent tracée lorsque l'implémentation interne n'est «pas visible» de l'extérieur, mais ce qui est introduit dans l'entrée de la boîte et ce qu'elle donne est «visible».

En C, le masquage des implémentations est effectué à l'intérieur d'un module, marquant les fonctions qui ne devraient pas être visibles de l'extérieur avec le mot-clé statique. Les prototypes des fonctions qui composent l'interface du module sont placés dans le fichier d'en-tête. Un module en C signifie une paire: un fichier source avec l'extension * .c et un en-tête avec l'extension * .h.

Java possède également le mot-clé statique, mais il n'affecte pas la «visibilité» de la méthode ou du champ de l'extérieur. Pour contrôler la «visibilité», il existe 3 modificateurs d'accès: privé, protégé, public.

Les champs et méthodes d'une classe marquée comme privée ne sont disponibles qu'à l'intérieur. Les champs et méthodes protégés sont également accessibles aux descendants de classe. Le modificateur public signifie que l'élément marqué est accessible de l'extérieur de la classe, c'est-à-dire qu'il fait partie de l'interface. Il est également possible qu'il n'y ait pas de modificateur, dans ce cas, l'accès à l'élément class est limité par le package dans lequel se trouve la classe.

Lors de l'écriture d'une classe, il est recommandé de marquer initialement tous les champs de la classe comme privés et d'étendre les droits d'accès si nécessaire.

Surcharge de méthode


L'une des caractéristiques gênantes de la bibliothèque standard C est la présence de tout un zoo de fonctions qui exécutent essentiellement la même chose, mais diffèrent dans le type d'argument, par exemple: fabs (), fabsf (), fabsl () - fonctions permettant d'obtenir la valeur absolue pour double, float et long types doubles respectivement.

Java (ainsi que C ++) prend en charge un mécanisme de surcharge de méthode - il peut y avoir plusieurs méthodes dans une classe avec un nom complètement identique, mais différant par le type et le nombre d'arguments. Par le nombre d'arguments et leur type, le compilateur choisira la version nécessaire de la méthode elle-même - elle est très pratique et améliore la lisibilité du programme.

En Java, contrairement à C ++, les opérateurs ne peuvent pas être surchargés. L'exception est les opérateurs "+" et "+ =", qui sont initialement surchargés pour les chaînes String.

Caractères et chaînes en Java


En C, vous devez travailler avec des chaînes null-terminal représentées par des pointeurs vers le premier caractère:

 char *str; //  ASCII  wchar_t *strw; //   ""  

Ces lignes doivent se terminer par un caractère nul. Si elle est accidentellement "effacée", une chaîne sera considérée comme une séquence d'octets en mémoire jusqu'au premier caractère nul. Autrement dit, si d'autres variables de programme sont placées dans la mémoire après la ligne, puis après avoir modifié une telle ligne endommagée, leurs valeurs peuvent (et très probablement) seront déformées.

Bien sûr, un programmeur C n'est pas obligé d'utiliser des chaînes null-terminal classiques, mais applique une implémentation tierce, mais ici, il faut garder à l'esprit que toutes les fonctions de la bibliothèque standard nécessitent des chaînes null-terminal comme arguments. De plus, la norme C ne définit pas l'encodage utilisé, ce point doit également être contrôlé par le programmeur.

En Java, le type de caractère primitif (ainsi que l'encapsuleur de caractères, à propos des encapsuleurs ci-dessous) représentent un seul caractère selon la norme Unicode. Le codage UTF-16 est utilisé, respectivement, un caractère occupe 2 octets en mémoire, ce qui vous permet de coder presque tous les caractères des langues actuellement utilisées.

Les caractères peuvent être spécifiés par leur Unicode:

 char ch1 = '\u20BD'; 

Si l'Unicode d'un caractère dépasse le maximum de 216 pour char, alors un tel caractère doit être représenté par int. Dans la chaîne, il occupera 2 caractères de 16 bits, mais là encore, les caractères avec un code supérieur à 216 sont extrêmement rarement utilisés.

Les chaînes Java sont implémentées par la classe String intégrée et stockent des caractères char 16 bits. La classe String contient tout ou presque tout ce qui peut être nécessaire pour travailler avec des chaînes. Il n'est pas nécessaire de penser au fait que la ligne doit nécessairement se terminer par zéro, ici il est impossible "d'effacer" imperceptiblement ce caractère de terminaison zéro ou d'accéder à la mémoire au-delà de la ligne. En général, lorsqu'il travaille avec des chaînes en Java, le programmeur ne pense pas à la façon dont la chaîne est stockée en mémoire.

Comme mentionné ci-dessus, Java n'autorise pas la surcharge d'opérateur (comme en C ++), cependant la classe String est une exception - seulement pour elle les opérateurs de fusion de ligne "+" et "+ =" sont initialement surchargés.

 String str1 = "Hello, " + "World!"; String str2 = "Hello, "; str2 += "World!"; 

Il est à noter que les chaînes en Java sont immuables - une fois créées, elles ne permettent pas leur modification. Lorsque nous essayons de changer la ligne, par exemple, comme ceci:

 String str = "Hello, World!"; str.toUpperCase(); System.out.println(str); //   "Hello, World!" 

Ainsi, la chaîne d'origine ne change pas réellement. Au lieu de cela, une copie modifiée de la chaîne d'origine est créée, qui à son tour est également immuable:

 String str = "Hello, World!"; String str2 = str.toUpperCase(); System.out.println(str2); //   "HELLO, WORLD!" 

Ainsi, chaque modification d'une chaîne entraîne en réalité la création d'un nouvel objet (en fait, en cas de fusion de chaînes, le compilateur peut optimiser le code et utiliser la classe StringBuilder, qui sera discutée plus loin).

Il arrive que le programme doive souvent changer la même ligne. Dans de tels cas, afin d'optimiser la vitesse du programme et la consommation de mémoire, vous pouvez empêcher la création de nouveaux objets de ligne. À ces fins, la classe StringBuilder doit être utilisée:

 String sourceString = "Hello, World!"; StringBuilder builder = new StringBuilder(sourceString); builder.setCharAt(4, '0'); builder.setCharAt(8, '0'); builder.append("!!"); String changedString = builder.toString(); System.out.println(changedString); //   "Hell0, W0rld!!!" 

Séparément, il convient de mentionner la comparaison des chaînes. Une erreur typique d'un programmeur Java novice consiste à comparer des chaînes en utilisant l'opérateur "==":

 //    "Yes" // ! if (usersInput == "Yes") { //    } 

Ce code ne contient pas formellement d'erreurs au stade de la compilation ni d'erreurs d'exécution, mais il fonctionne différemment de ce à quoi on pourrait s'attendre. Puisque tous les objets et chaînes, y compris en Java, sont représentés par des liens, la comparaison avec l'opérateur "==" donne une comparaison des liens, pas des valeurs des objets. Autrement dit, le résultat ne sera vrai que si 2 liens font vraiment référence à la même ligne. Si les chaînes sont des objets différents en mémoire et que vous devez comparer leur contenu, vous devez utiliser la méthode equals ():

 if (usersInput.equals("Yes")) { //    } 

La chose la plus surprenante est que dans certains cas, la comparaison à l'aide de l'opérateur «==» fonctionne correctement:

 String someString = "abc", anotherString = "abc"; //   "true": System.out.println(someString == anotherString); 

En effet, someString et anotherString font référence au même objet en mémoire. Le compilateur place les mêmes littéraux de chaîne dans le pool de chaînes - le soi-disant internement se produit. Ensuite, chaque fois que le même littéral de chaîne apparaît dans le programme, un lien vers la chaîne du pool est utilisé. L'internement de chaînes est précisément possible en raison de la propriété d'immuabilité des chaînes.

Bien que la comparaison du contenu des chaînes ne soit autorisée que par la méthode equals (), en Java, il est possible d'utiliser correctement les chaînes dans les constructions à commutateur (à partir de Java 7):

 String str = new String(); // ... switch (str) { case "string_value_1": // ... break; case "string_value_2": // ... break; } 

Curieusement, tout objet Java peut être converti en chaîne. La méthode toString () correspondante est définie dans la classe de base pour toutes les classes de la classe Object.

Approche de gestion des erreurs


Lors de la programmation en C, vous pouvez proposer l'approche de gestion des erreurs suivante. Chaque fonction d'une bibliothèque renvoie un type int. Si la fonction réussit, ce résultat est 0. Si le résultat est différent de zéro, cela indique une erreur. Le plus souvent, le code d'erreur est transmis via la valeur renvoyée par la fonction. Étant donné que la fonction ne peut renvoyer qu'une seule valeur et qu'elle est déjà occupée par le code d'erreur, le résultat réel de la fonction doit être renvoyé via l'argument sous la forme d'un pointeur, par exemple, comme ceci:

 int function(struct Data **result, const char *arg) { int errorCode; /* ... */ return errorCode; } 

Par ailleurs, c'est l'un des cas où dans un programme C, il devient nécessaire d'utiliser un pointeur vers un pointeur.

Parfois, ils utilisent une approche différente. La fonction ne renvoie pas de code d'erreur, mais directement le résultat de son exécution, généralement sous la forme d'un pointeur. Une situation d'erreur est indiquée par un pointeur nul. Ensuite, la bibliothèque contient généralement une fonction distincte qui renvoie le code de la dernière erreur:

 struct Data* function(const char *arg); int getLastError(); 

D'une manière ou d'une autre, lors de la programmation en C, le code qui fait le travail «utile» et le code responsable du traitement des erreurs s'entrelacent, ce qui ne rend évidemment pas le programme facile à lire.

En Java, si vous le souhaitez, vous pouvez utiliser les approches décrites ci-dessus, mais ici, vous pouvez appliquer une manière complètement différente de traiter les erreurs - la gestion des exceptions (cependant, comme en C ++). L'avantage de la gestion des exceptions est que dans ce cas, le code «utile» et le code responsable de la gestion des erreurs et des imprévus sont logiquement séparés les uns des autres.

Ceci est réalisé en utilisant des constructions try-catch: le code «utile» est placé dans la section try et le code de gestion des erreurs est placé dans la section catch.

 //       try (FileReader reader = new FileReader("path\\to\\file.txt")) { //    -   . while (reader.read() != -1){ // ... } } catch (IOException ex) { //     } 

Il existe des situations où il n'est pas possible de traiter correctement l'erreur au lieu de son occurrence. Dans de tels cas, une indication est placée dans la signature de méthode que la méthode peut provoquer ce type d'exception:

 public void func() throws Exception { // ... } 

Maintenant, l'appel à cette méthode doit nécessairement être encadré dans un bloc try-catch, ou la méthode appelante doit également être marquée pour pouvoir lever cette exception.

Absence de préprocesseur


Quelle que soit la commodité du préprocesseur familier aux programmeurs C / C ++, il est absent du langage Java. Les développeurs Java ont probablement décidé qu'il n'était utilisé que pour garantir la portabilité des programmes, et comme Java s'exécute partout (presque), aucun préprocesseur n'est nécessaire.

Vous pouvez compenser l'absence d'un préprocesseur à l'aide d'un champ indicateur statique et vérifier sa valeur dans le programme, si nécessaire.

Si nous parlons de l'organisation des tests, il est possible d'utiliser des annotations en conjonction avec la réflexion (réflexion).

Un tableau est également un objet.


Lorsque vous travaillez avec des tableaux en C, la sortie d'index au-delà des limites du tableau est une erreur très insidieuse. Le compilateur ne le signalera en aucune façon, et pendant l'exécution le programme ne sera pas arrêté avec le message correspondant:

 int array[5]; array[6] = 666; 

Très probablement, le programme continuera son exécution, mais la valeur de la variable qui se trouvait après le tableau matriciel dans l'exemple ci-dessus sera déformée. Le débogage de ce type d'erreur peut ne pas être facile.

En Java, le programmeur est protégé contre ce type d'erreurs difficiles à diagnostiquer. Lorsque vous essayez d'aller au-delà des limites du tableau, une ArrayIndexOutOfBoundsException est levée. Si l'exception catch n'a pas été programmée à l'aide de la construction try-catch, le programme se bloque et un message correspondant est envoyé au flux d'erreur standard indiquant le fichier avec le code source et le numéro de ligne où le tableau a été dépassé. Autrement dit, le diagnostic de ces erreurs devient une question triviale.

Ce comportement du programme Java est rendu possible car le tableau en Java est représenté par un objet. Le tableau Java ne peut pas être redimensionné; sa taille est codée en dur au moment où la mémoire est allouée. Au moment de l'exécution, obtenir la taille de la matrice est aussi simple que de décortiquer des poires:

 int[] array = new int[10]; int arraySize = array.length; // 10 

Si nous parlons de tableaux multidimensionnels, alors, par rapport au langage C, Java offre une opportunité intéressante d'organiser des tableaux "Ladder". Dans le cas d'un tableau à deux dimensions, la taille de chaque ligne individuelle peut différer des autres:

 int[][] array = new int[10][]; for (int i = 0; i < array.length; i++) { array[i] = new int[i + 1]; } 

Comme en C, les éléments du tableau sont situés en mémoire un par un, donc l'accès au tableau est considéré comme le plus efficace. Si vous devez effectuer des opérations d'insertion / suppression d'éléments ou créer des structures de données plus complexes, vous devez utiliser des collections, telles qu'un ensemble (Set), une liste (List), une carte (Map).

En raison du manque de pointeurs et de l'impossibilité d'incrémenter les liens, l'accès aux éléments du tableau est possible à l'aide d'index.

Les collections


Souvent, la fonctionnalité des tableaux n'est pas suffisante - vous devez alors utiliser des structures de données dynamiques. Étant donné que la bibliothèque C standard ne contient pas d'implémentation prête à l'emploi de structures de données dynamiques, vous devez utiliser l'implémentation dans des codes source ou sous forme de bibliothèques.

Contrairement à C, la bibliothèque Java standard contient un riche ensemble d'implémentations de structures de données dynamiques ou de collections, exprimées en termes de Java. Toutes les collections sont divisées en 3 grandes classes: listes, ensembles et cartes.

Les listes - tableaux dynamiques - vous permettent d'ajouter / supprimer des éléments. Beaucoup n'assurent pas l'ordre des éléments ajoutés, mais garantissent qu'il n'y a pas d'éléments en double. Les cartes ou les tableaux associatifs fonctionnent avec des paires clé-valeur, et la valeur clé est unique - il ne peut pas y avoir 2 paires avec les mêmes clés dans la carte.

Pour les listes, les ensembles et les cartes, il existe de nombreuses implémentations, chacune étant optimisée pour une opération spécifique. Par exemple, les listes sont implémentées par les classes ArrayList et LinkedList, ArrayList offrant de meilleures performances lors de l'accès à un élément arbitraire, et LinkedList est plus efficace lors de l'insertion / suppression d'éléments au milieu de la liste.

Seuls les objets Java complets peuvent être stockés dans des collections (en fait, des références à des objets), il est donc impossible de créer directement une collection de primitives (int, char, byte, etc.). Dans ce cas, les classes wrapper appropriées doivent être utilisées:
PrimitifCours d'emballage
octetOctet
courtCourt
charPersonnage
intEntier
longueLong
flotterFlotter
doubleDouble
booléenBooléen

Heureusement, lors de la programmation en Java, il n'est pas nécessaire de suivre la coïncidence exacte du type primitif et de son "wrapper". Si la méthode reçoit un argument, par exemple, de type Integer, alors il peut être passé le type int. Et vice versa, lorsque le type int est requis, vous pouvez utiliser Integer en toute sécurité. Cela a été rendu possible grâce au mécanisme intégré Java pour l'emballage / le déballage des primitives.

Parmi les moments désagréables, il convient de mentionner que la bibliothèque Java standard contient d'anciennes classes de collection qui ont été implémentées sans succès dans les premières versions de Java et qui ne doivent pas être utilisées dans de nouveaux programmes. Ce sont les classes Enumeration, Vector, Stack, Dictionary, Hashtable, Properties.

Généralisations


Les collections sont couramment utilisées comme types de données génériques. L'essence des généralisations dans ce cas est que nous spécifions le type principal de la collection, par exemple, ArrayList, et entre crochets spécifions le type de paramètre, qui dans ce cas détermine le type d'éléments stockés dans la liste:

 List<Integer> list = new ArrayList<Integer>(); 

Cela permet au compilateur de suivre la tentative d'ajout d'un objet d'un type autre que le paramètre de type spécifié:

 List<Integer> list = new ArrayList<Integer>(); //  ! list.add("First"); 

Il est très important que le paramètre-type soit effacé lors de l'exécution du programme, et il n'y a pas de différence entre, par exemple, un objet de la classe
 ArrayList <Integer> 
et objet de classe
 ArrayList <String>. 
Par conséquent, il n'y a aucun moyen de découvrir le type d'éléments de collection pendant l'exécution du programme:

 public boolean containsInteger(List list) { //  ! if (list instanceof List<Integer>) { return true; } return false; } 

Une solution partielle peut être l'approche suivante: prendre le premier élément de la collection et déterminer son type:

 public boolean containsInteger(List list) { if (!list.isEmpty() && list.get(0) instanceof Integer) { return true; } return false; } 

Mais cette approche ne fonctionnera pas si la liste est vide.

À cet égard, les généralisations Java sont nettement inférieures aux généralisations C ++. Les généralisations Java servent en fait à «couper» certaines des erreurs potentielles au stade de la compilation.

Itérer sur tous les éléments d'un tableau ou d'une collection


Lors de la programmation en C, vous devez souvent parcourir tous les éléments du tableau:

 for (int i = 0; i < SIZE; i++) { /* ... */ } 

Pour faire une erreur ici, c'est plus simple, il suffit de spécifier la mauvaise taille du tableau SIZE ou de mettre "<=" au lieu de "<".

En Java, en plus de la forme «habituelle» de l'instruction for, il existe une forme pour itérer sur tous les éléments d'un tableau ou d'une collection (souvent appelée foreach dans d'autres langages):

 List<Integer> list = new ArrayList<>(); // ... for (Integer i : list) { // ... } 

Ici, nous garantissons d'itérer sur tous les éléments de la liste, les erreurs inhérentes à la forme «habituelle» de l'instruction for sont éliminées.

Collections diverses


Étant donné que tous les objets sont hérités de l'objet racine, Java a une opportunité intéressante de créer des listes avec différents types réels d'éléments:

 List list = new ArrayList<>(); list.add(new String("First")); list.add(new Integer(2)); list.add(new Double(3.0));         instanceof: for (Object o : list) { if (o instanceof String) { // ... } else if (o instanceof Integer) { // ... } else if (o instanceof Double) { // ... } } 

Transferts


En comparant C / C ++ et Java, il est impossible de ne pas remarquer combien d'énumérations plus fonctionnelles sont implémentées en Java. Ici, l'énumération est une classe à part entière, et les éléments d'énumération sont des objets de cette classe. Cela permet à un élément d'énumération de mettre plusieurs champs de tout type en correspondance:

 enum Colors { //     -   . RED ((byte)0xFF, (byte)0x00, (byte)0x00), GREEN ((byte)0x00, (byte)0xFF, (byte)0x00), BLUE ((byte)0x00, (byte)0x00, (byte)0xFF), WHITE ((byte)0xFF, (byte)0xFF, (byte)0xFF), BLACK ((byte)0x00, (byte)0x00, (byte)0x00); //  . private byte r, g, b; //  . private Colors(byte r, byte g, byte b) { this.r = r; this.g = g; this.b = b; } //  . public double getLuma() { return 0.2126 * r + 0.7152 * g + 0.0722 * b; } } 

En tant que classe à part entière, une énumération peut avoir des méthodes et, à l'aide d'un constructeur privé, vous pouvez définir les valeurs de champ des éléments d'énumération individuels.

Il y a une occasion régulière d'obtenir une représentation sous forme de chaîne d'un élément d'énumération, un numéro de série, ainsi qu'un tableau de tous les éléments:

 Colors color = Colors.BLACK; String str = color.toString(); // "BLACK" int i = color.ordinal(); // 4 Colors[] array = Colors.values(); // [RED, GREEN, BLUE, WHITE, BLACK] 

Et vice versa - par la représentation sous forme de chaîne, vous pouvez obtenir un élément d'énumération, et également appeler ses méthodes:

 Colors red = Colors.valueOf("RED"); // Colors.RED Double redLuma = red.getLuma(); // 0.2126 * 255 

Naturellement, les énumérations peuvent être utilisées dans les constructions de casse de commutation.

Conclusions


Bien sûr, les langages C et Java sont conçus pour résoudre des problèmes complètement différents. Mais, si l'on compare néanmoins le processus de développement logiciel dans ces deux langages, alors, selon les impressions subjectives de l'auteur, le langage Java surpasse significativement C dans la commodité et la rapidité d'écriture des programmes. L'environnement de développement (IDE) joue un rôle important dans la commodité. L'auteur a travaillé avec IntelliJ IDEA IDE. Lors de la programmation en Java, vous n’avez pas constamment à «avoir peur» de faire une erreur - souvent l’environnement de développement vous dira ce qui doit être corrigé, et parfois il le fera pour vous. Si une erreur d'exécution s'est produite, le type d'erreur et le lieu de son occurrence dans le code source sont toujours indiqués dans le journal - la lutte contre de telles erreurs devient triviale. Un programmeur C n'a pas besoin de faire des efforts inhumains pour passer à Java, et tout cela parce que la syntaxe du langage a légèrement changé.

Si cette expérience sera intéressante pour les lecteurs, dans le prochain article, nous parlerons de l'expérience d'utilisation du mécanisme JNI (exécution de code C / C ++ natif à partir d'une application Java). Le mécanisme JNI est indispensable lorsque vous souhaitez contrôler la résolution d'écran, le module Bluetooth et dans d'autres cas lorsque les capacités des services et gestionnaires Android ne sont pas suffisantes.

Source: https://habr.com/ru/post/fr412775/


All Articles