Structures de données théoriques et leur application en JavaScript. P1. Des couples

«Les mauvais programmeurs pensent au code. Les bons programmeurs pensent aux structures de données et à leurs relations », Linus Torvalds, créateur de Linux.
Nous considérons comme un axiome que très souvent la solution à tout problème de programmation se résume à choisir la bonne structure de données. Cet axiome peut être prouvé, mais c'est long et l'article en parle un peu plus. Aujourd'hui, nous allons parler de l'une des structures théoriques les plus simples, sur la paire.

Une paire est une structure de données qui stocke deux éléments de données en soi, ce qui implique que nous voulons en quelque sorte les combiner logiquement en un seul. Il résout précisément ce problème, c'est-à-dire s'il y a des éléments de données et que bnous devons les présenter sous la forme b, alors nous pouvons implémenter cette conception par paires.

Si vous pensez que vous n'avez jamais rencontré ou ne rencontrerez pas quelque chose comme ça, alors ce n'est pas le cas.

Voici l'exemple le plus simple d'une propriété calculée de la documentation VUE:

var vm = new Vue({
  el: '#demo',
  data: {
    firstName: 'Foo',
    lastName: 'Bar'
  },
  computed: {
    fullName: function () {
      return this.firstName + ' ' + this.lastName
    }
  }
})

Du point de vue de la théorie, fullNamec'est un couple. Et il existe d'innombrables exemples de ce type dans la technologie et le monde réel, que nous modélisons souvent avec du code de programme.

Vous pouvez remarquer à juste titre que nous pouvons combiner logiquement n'importe quel nombre d'éléments et vous aurez raison. Cela nous amène à un concept structurel plus complexe de données composites , dont l'un est un couple . Nous traiterons les données composites un peu plus tard, nous allons maintenant nous concentrer sur la paire.

La théorie nous dit que les paires ont les propriétés suivantes:

  • , .. - , , . , ( - . , )
  • . . . , , , , , .

    :

    pair = { a, b }
    

    Si dans le code appelant, nous travaillerons directement avec la paire dans ce style: pair.apuis en modifiant l'implémentation de la paire, nous serons obligés de réécrire le code appelant partout où apparaît l'appel à la paire. Ce n'est pas génial!

    Un couple est une abstraction (même un niveau bas), il serait donc faux de travailler directement avec ses composants. Lorsque vous travaillez avec les composants d'une abstraction, nous devons utiliser l'interface d'abstraction, sinon le code se transformera en chaos (il deviendra plus difficile à lire, plus facile à faire une erreur, mais surtout plus difficile à changer, car un changement devra être effectué dans différentes parties du code).


Les paires peuvent être implémentées de manières très différentes. Dans n'importe quel langage de programmation, il existe plusieurs opportunités pour implémenter cette structure de données.
Par exemple, vous pouvez implémenter une paire pour des types de données arbitraires:

//       
//      
const pair = (first, second) => (elementType) => {
  switch(elementType) {
    case 'first':
     return first;
   case 'second':
     return second;
  }
};
//    ,      ,
//    
const getFirstElement = (pair) => (pair('first'));
const getSecondElement = (pair) => (pair('second'));

Cette façon de travailler s'appelle envoyer des messages. Curieusement, mais c'est cette façon d'interaction entre les entités qui était autrefois appelée OOP avant C / C ++.

Faut-il utiliser le design switch ? Bien sûr, pas nécessairement. Ce sont les détails de l'implémentation technique, et il peut y avoir un grand nombre d'implémentations!

Le plus important est de rendre le couple mutable!

Par exemple, vous pouvez implémenter la même fonctionnalité à l'aide de Map

//     
//  ,       
const pair = (first, second) => (
  new Map([
    ['first', first],
    ['second', second],
  ])
);
//    
const getFirst = (pair) => (pair.get('first'));
const getSecond = (pair) => (pair.get('second'));

Notez que la première implémentation peut facilement être remplacée par la seconde. et du deuxième au premier! Le code d'appel ne changera pas. C'est un avantage important des abstractions. Nous pouvons facilement changer l'implémentation de l'abstraction dans le programme, mais le code travaillant avec cette abstraction ne connaîtra pas ces changements. Nous n'aurons pas besoin d'éditer le code qui fonctionne avec ces abstractions si nous voulons changer l'implémentation des abstractions elles-mêmes. Ceci est très important car économise de l'argent aux clients et augmente les bonus des développeurs.

Soit dit en passant, disons que nous ne connaissons pas l'existence de Mapov dans js, mais nous pouvons travailler avec des objets.

//       
//   
const pair = (first, second) => (
  Object.freeze({
    'first': first,
    'second': second,
  })
);
//    ,       
const getFirst = (pair) => (pair.first);
const getSecond = (pair) => (pair.second);

Comme vous pouvez le deviner facilement, les deux implémentations précédentes peuvent également être remplacées par un troisième et rien ne changera de ce ( En fait, il y a une différence. Les cartes ne jette pas des exceptions lorsque vous essayez de propriétés modifier directement, mais ils jettent « objets gelés » TypeError: Cannot assign to read only property. Cependant, dans le contexte de cet article important. ).

Pourquoi avons-nous besoin de connaissances sur les structures de données théoriques?


Si nous regardons le travail d'un programmeur à vol d'oiseau, nous verrons qu'il est essentiellement impliqué dans cela. qui crée des outils qui manipulent certains ensembles de données. Par conséquent, nous devons constamment sélectionner une sorte de structure de stockage pour les données et trouver des moyens de les utiliser ou de donner à l'ensemble de données chaotique une structure spécifique. Comprendre les structures de données typiques nous permet de posséder un ensemble de solutions prêtes à l'emploi pour différentes tâches typiques et de choisir simplement la manière la plus pratique pour une tâche spécifique. Analysons

un exemple:

«Implémenter un mécanisme pour travailler avec des fractions. Les fractions doivent être stockées dans le format habituel. ceux. sous forme de 1/2. Il est également nécessaire de mettre en œuvre des opérations de base (addition, soustraction, multiplication, division). La normalisation doit être assurée. »

Donnons un sens à l'état du problème! Nous devons implémenter une abstraction pour une entité mathématique, il est donc raisonnable de se référer à la source d'origine . Bien sûr, si des programmeurs expérimentés lisent ce texte, ils doivent avoir immédiatement imaginé la solution en code sans lire l'article sur les fractions en mathématiques, mais nous supposerons. que nous imaginons à peine comment les fractions fonctionnent et illustrons tout le cours du raisonnement.

Le matériel de Wikipedia nous dit que les fractions sont ordinaires (1/4) et décimales (0,1). De toute évidence, en fonction de l'état du problème, nous devons travailler avec des fractions dans le format de présentation habituel.

Nous voyons également que la fraction ordinaire est une union logique de deuxnombres (numérateur et dénominateur), c'est le signal le plus sûr de la nécessité d'utiliser une paire comme structure de données pour cette tâche.

Dans le code, nous pourrions décrire cette structure comme suit:

//        ,
//  ,    
//          ,
//     
const makeRational = (num, denom) => (
  new Map([
    ['num', num],
    ['denom', denom],
  ])
);

//  ,    
//   ,      ,    
//    
const getNumer = (rat) => (rat.get('num'));
const getDenom = (rat) => (rat.get('denom'));

Ensuite, nous devons analyser la situation avec des fractions d'un tel plan 8/4, 6/9. L'état de la tâche indique la nécessité de prévoir une normalisation. La normalisation d'une fraction est la suppression du plus grand diviseur commun (GCD).

Nous implémentons une fonction pour rechercher deux nombres GCD:

//Gcd     
const getGcd = (a, b) => ((a % b) ? getGcd(b, a % b) : Math.abs(b));

Si le texte de la fonction n'est pas clair pour vous, alors je vous conseille de lire sur la récursivité .

Pour normaliser la fraction, nous devons diviser son numérateur et son dénominateur par GCD. Nous écrivons la fonction de normalisation:

const normalize = (n1, n2) => {
  const commonDivisor = getGcd(n1, n2);
  return [n1 / commonDivisor, n2 / commonDivisor];
};

Il serait logique de mettre la normalisation dans le constructeur makeRational pour toujours normaliser les données lors de la création de la fraction.

const makeRational = (num, denom) => {
  const [normalizeNum, normalizeDenom] = normalize(num, denom);
  return new Map([
    ['num', normalizeNum],
    ['denom', normalizeDenom],
  ]);
};

Ensuite, nous allons travailler sur l'interface des opérations.

1) Addition

Pour ajouter deux fractions ordinaires, vous devez les amener à un dénominateur commun. Ajoutez ensuite les numérateurs et laissez le dénominateur inchangé.

//..       ,
//       ,
//         
const add = (rat1, rat2) => (
  makeRational(
    getNumer(rat1) * getDenom(rat2) + getNumer(rat2) * getDenom(rat1),
    getDenom(rat1) * getDenom(rat2),
  ));

2) Soustraction

Pour obtenir la différence des fractions, elles doivent également être réduites à un dénominateur commun, puis soustraire les numérateurs, le dénominateur doit rester inchangé.

//    
//     
const sub = (rat1, rat2) => (
  makeRational(
    getNumer(rat1) * getDenom(rat2) - getNumer(rat2) * getDenom(rat1),
    getDenom(rat1) * getDenom(rat2),
  ));

3) Multiplication

Pour multiplier deux fractions ordinaires, vous devez multiplier leurs numérateurs et dénominateurs.

const multi = (rat1, rat2) => (
  makeRational(
    getNumer(rat1) * getNumer(rat2),
    getDenom(rat1) * getDenom(rat2),
  ));


4) Division

Afin de diviser une fraction ordinaire en une autre, vous devez multiplier la première fraction par la fraction inverse de la seconde. L'inverse est appelé une fraction dont le numérateur est égal au dénominateur de l'original, et le dénominateur est le numérateur de l'original.

const div = (rat1, rat2) => (
  makeRational(
    getNumer(rat1) * getDenom(rat2),
    getDenom(rat1) * getNumer(rat2),
  ));

Comme vous pouvez le voir, dans chaque opération, nous créons une nouvelle entité. Ceci est une conséquence de la règle d'immuabilité. Notez que dans les opérations mathématiques ne changent pas le nombre initial de chiffres, et créer de nouvelles: 1 + 2 = 3.

Devrait faire attention. que nous pouvons changer l'implémentation makeRationalà n'importe quel autre, mais le code appelant n'en sera pas informé et continuera à fonctionner. Ceci est une conséquence du fait que nous avons correctement implémenté l'abstraction et travaillé avec ses composants via l'interface, et non directement.

Il est également important que l'utilisateur puisse obtenir ses données de la manière habituelle, nous introduisons donc une fonction supplémentaire.

const ratToString = (rat) => `${getNumer(rat)}/${getDenom(rat)}`;

Je vais joindre une liste de tests:

describe('normalize', () => {
  test('should work', () => {
    expect(normalize(21, 6)).toEqual([7, 2]);
    expect(normalize(2, 3)).toEqual([2, 3]);
  });
});


describe('rational', () => {
  test('getters', () => {
    const rat1 = makeRational(3, 9);
    expect(getNumer(rat1)).toBe(1);
    expect(getDenom(rat1)).toBe(3);

    const rat3 = makeRational(-4, 16);
    expect(getNumer(rat3)).toBe(-1);
    expect(getDenom(rat3)).toBe(4);
  });

  test('add&sub', () => {
    const rat1 = makeRational(3, 9);
    const rat2 = makeRational(10, 3);
    expect(add(rat1, rat2)).toEqual(makeRational(11, 3));
    expect(sub(rat1, rat2)).toEqual(makeRational(-3, 1));

    const rat4 = makeRational(12, 5);
    const rat3 = makeRational(-4, 16);
    expect(add(rat3, rat4)).toEqual(makeRational(43, 20));
    expect(sub(rat3, rat4)).toEqual(makeRational(-53, 20));

    const rat5 = makeRational(1, 15);
    const rat6 = makeRational(4, 25);
    expect(add(rat5, rat6)).toEqual(makeRational(17, 75));
    expect(sub(rat5, rat6)).toEqual(makeRational(-7, 75));
  });

  test('multi&div', () => {
    const rat1 = makeRational(1, 2);
    const rat2 = makeRational(2, 3);
    expect(multi(rat1, rat2)).toEqual(makeRational(2, 6));

    const rat3 = makeRational(1, 3);
    const rat4 = makeRational(1, 2);
    expect(div(rat3, rat4)).toEqual(makeRational(2, 3));
  });

  test('ratToString', () => {
    const rat1 = makeRational(3, 9);
    const rat3 = makeRational(-4, 16);
    expect(ratToString(rat1)).toBe('1/3');
    expect(ratToString(rat3)).toBe('-1/4');
  });
});

Conclusion


Le texte s'est avéré assez volumineux, mais j'espère qu'il est clair. Résumer:

  1. Nous pouvons utiliser la structure de données de vapeur chaque fois que nous devons combiner logiquement deux valeurs indépendantes
  2. Le couple doit être immunisé
  3. Le couple doit fournir une interface pour travailler avec leurs composants

Je m'excuse à l'avance si l'article semble surchargé à quelqu'un. Je me concentre principalement sur des développeurs peu expérimentés, j'essaie donc d'écrire de manière à ce que la logique de mon raisonnement et le choix de l'une ou l'autre solution soient extrêmement clairs.

Si l'article intéresse la communauté. alors la direction des structures de données typiques continuera. Le sujet suivant est les listes associées.

All Articles