Theoretische Datenstrukturen und ihre Anwendung in JavaScript. P1. Paare

„Schlechte Programmierer denken über Code nach. Gute Programmierer denken über Datenstrukturen und ihre Beziehungen nach “, erklärt Linus Torvalds, Entwickler von Linux.
Wir gehen davon aus, dass die Lösung eines Programmierproblems sehr oft in der Auswahl der richtigen Datenstruktur besteht. Dieses Axiom kann bewiesen werden, aber es ist eine lange Zeit und der Artikel handelt ein wenig von einem anderen. Heute werden wir über eine der einfachsten theoretischen Strukturen sprechen, über das Paar.

Ein Paar ist eine Datenstruktur, die zwei beliebige Datenelemente in sich speichert, was bedeutet, dass wir sie irgendwie logisch zu einem kombinieren möchten. Es löst genau dieses Problem, d.h. Wenn es einige Datenelemente gibt und bwir sie im Formular präsentieren müssen b, können wir dieses Design paarweise implementieren.

Wenn Sie denken, dass Sie so etwas noch nie erlebt haben oder nicht begegnen werden, ist dies nicht der Fall.

Hier ist das einfachste Beispiel einer berechneten Eigenschaft aus der VUE-Dokumentation:

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

Aus theoretischer Sicht ist fullNamedies ein Paar. Und es gibt unzählige solcher Beispiele in der Technologie und in der realen Welt, die wir oft mit Programmcode modellieren.

Sie können zu Recht feststellen, dass wir eine beliebige Anzahl von Elementen logisch kombinieren können, und Sie haben Recht. Dies bringt uns zu einem komplexeren Strukturkonzept zusammengesetzter Daten , von denen eines ein Paar ist . Wir werden uns etwas später mit den zusammengesetzten Daten befassen, jetzt konzentrieren wir uns auf das Paar.

Die Theorie sagt uns, dass Paare die folgenden Eigenschaften haben:

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

    :

    pair = { a, b }
    

    Wenn wir im aufrufenden Code direkt mit dem Paar in diesem Stil arbeiten: Wenn wir pair.adann die Implementierung des Paares ändern, müssen wir den aufrufenden Code überall dort neu schreiben, wo der Aufruf des Paares erscheint. Das ist nicht toll!

    Ein Paar ist eine Abstraktion (sogar eine niedrige Ebene), daher wäre es falsch, direkt mit ihren Komponenten zu arbeiten. Wenn wir mit den Komponenten einer Abstraktion arbeiten, müssen wir die Abstraktionsschnittstelle verwenden, sonst wird der Code zu Chaos (es wird schwieriger zu lesen, leichter Fehler zu machen, aber vor allem schwieriger zu ändern, da eine Änderung in verschiedenen Teilen des Codes vorgenommen werden muss).


Paare können auf sehr unterschiedliche Weise implementiert werden. In jeder Programmiersprache gibt es mehr als eine Möglichkeit, diese Datenstruktur zu implementieren.
Sie können beispielsweise ein Paar für beliebige Datentypen implementieren:

//       
//      
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'));

Diese Arbeitsweise wird als Senden von Nachrichten bezeichnet. Seltsamerweise, aber es war diese Art der Interaktion zwischen Entitäten, die vor C / C ++ einmal OOP genannt wurde.

Müssen wir das Design verwenden switch ? Natürlich nicht unbedingt. Dies sind die Details der technischen Implementierung, und es kann sehr viele Implementierungen geben!

Das Wichtigste ist, das Paar veränderlich zu machen!

Beispielsweise können Sie dieselbe Funktionalität mit Map implementieren

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

Beachten Sie, dass die erste Implementierung leicht durch die zweite ersetzt werden kann. und der zweite zum ersten! Der aufrufende Code wird nicht geändert. Dies ist ein wichtiger Vorteil von Abstraktionen. Wir können die Implementierung der Abstraktion im Programm leicht ändern, aber der Code, der mit dieser Abstraktion arbeitet, kennt diese Änderungen nicht. Wir müssen den Code, der mit diesen Abstraktionen funktioniert, nicht bearbeiten, wenn wir die Implementierung der Abstraktionen selbst ändern möchten. Das ist sehr wichtig, weil spart Kundengeld und erhöht Entwicklerboni.

Nehmen wir übrigens an, wir wissen nichts über die Existenz von Mapov in js, aber wir können mit Objekten arbeiten.

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

Wie Sie leicht erraten können, können beide vorherigen Implementierungen auch durch eine dritte ersetzt werden, und daran ändert sich nichts ( Tatsächlich gibt es einen Unterschied. Karten lösen keine Ausnahmen aus, wenn sie versuchen, Eigenschaften direkt zu ändern, aber sie werfen "eingefrorene Objekte" aus TypeError: Cannot assign to read only property. Im Kontext dieses Artikels wichtig. ).

Warum brauchen wir Kenntnisse über theoretische Datenstrukturen?


Wenn wir die Arbeit eines Programmierers aus der Vogelperspektive betrachten, werden wir sehen, dass er sich im Wesentlichen damit beschäftigt. Dadurch werden Tools erstellt, mit denen bestimmte Datensätze bearbeitet werden können. Daher müssen wir ständig eine Art Speicherstruktur für die Daten auswählen und Wege finden, um mit ihnen zu arbeiten oder dem chaotischen Datensatz eine bestimmte Struktur zu geben. Das Verständnis der typischen Datenstrukturen ermöglicht es uns, eine Reihe vorgefertigter Lösungen für verschiedene typische Aufgaben zu besitzen und einfach den bequemsten Weg für eine bestimmte Aufgabe zu wählen. Analysieren

wir ein Beispiel:

„Implementieren Sie einen Mechanismus für die Arbeit mit Brüchen. Brüche sollten im üblichen Format gespeichert werden. jene. in Form von 1/2. Es ist auch notwendig, grundlegende Operationen (Addition, Subtraktion, Multiplikation, Division) zu implementieren. Normalisierung muss bereitgestellt werden. “

Lassen Sie uns den Zustand des Problems verstehen! Wir müssen eine Abstraktion für eine mathematische Entität implementieren, daher ist es sinnvoll, auf die ursprüngliche Quelle zu verweisen . Wenn erfahrene Programmierer diesen Text lesen, müssen sie sich die Lösung im Code sofort vorgestellt haben, ohne den Artikel über Brüche in der Mathematik zu lesen, aber wir gehen davon aus. dass wir uns schwach vorstellen, wie Brüche funktionieren, und den gesamten Verlauf des Denkens veranschaulichen.

Wikipedia-Material sagt uns, dass Brüche gewöhnlich (1/4) und dezimal (0,1) sind. Aufgrund des Problemzustands müssen wir natürlich mit Brüchen im üblichen Präsentationsformat arbeiten.

Wir sehen auch, dass der gewöhnliche Bruch eine logische Vereinigung von zwei istZahlen (Zähler und Nenner), dies ist das sicherste Signal für die Notwendigkeit, ein Paar als Datenstruktur für diese Aufgabe zu verwenden.

Im Code könnten wir diese Struktur wie folgt beschreiben:

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

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

Als nächstes sollten wir die Situation mit Bruchteilen eines solchen Plans 8/4, 6/9 analysieren. Der Zustand der Aufgabe sagt über die Notwendigkeit aus, für eine Normalisierung zu sorgen. Die Normalisierung einer Fraktion ist die Entfernung des größten gemeinsamen Teilers (GCD).

Wir implementieren eine Funktion zur Suche nach zwei GCD-Zahlen:

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

Wenn Ihnen der Text der Funktion nicht klar ist, empfehle ich Ihnen, über die Rekursion zu lesen .

Um den Bruch zu normalisieren, müssen wir seinen Zähler und Nenner durch GCD teilen. Wir schreiben die Normalisierungsfunktion:

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

Es wäre logisch, die Normalisierung in den makeRational-Konstruktor einzufügen, um die Daten beim Erstellen des Bruchs immer zu normalisieren.

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

Als Nächstes arbeiten wir an der Betriebsoberfläche.

1) Addition

Um zwei gewöhnliche Brüche zu addieren, sollten Sie sie auf einen gemeinsamen Nenner bringen. Fügen Sie dann die Zähler hinzu und lassen Sie den Nenner unverändert.

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

2) Subtraktion

Um die Differenz der Brüche zu erhalten, müssen sie auch auf einen gemeinsamen Nenner reduziert werden, und dann sollten die Zähler subtrahiert werden, der Nenner sollte unverändert bleiben.

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

3) Multiplikation

Um zwei gewöhnliche Brüche zu multiplizieren, müssen Sie ihre Zähler und Nenner multiplizieren.

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


4) Division

Um einen gewöhnlichen Bruch in einen anderen zu teilen, müssen Sie den ersten Bruch mit dem Bruchteil des zweiten multiplizieren. Die Umkehrung ist ein Bruch, dessen Zähler gleich dem Nenner des Originals ist, und der Nenner ist der Zähler des Originals.

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

Wie Sie sehen können, erstellen wir in jeder Operation eine neue Entität. Dies ist eine Folge der Unveränderlichkeitsregel. Beachten Sie, dass Sie in mathematischen Operationen die ursprüngliche Anzahl von Zahlen nicht ändern und neue erstellen : 1 + 2 = 3.

Sollte aufpassen. dass wir die Implementierung makeRationalin eine andere ändern können, der aufrufende Code jedoch nichts davon weiß und weiterhin funktioniert. Dies ist eine Folge der Tatsache, dass wir die Abstraktion korrekt implementiert haben und mit ihren Komponenten über die Schnittstelle und nicht direkt arbeiten.

Es ist auch wichtig, dass der Benutzer seine Daten auf die übliche Weise abrufen kann, daher führen wir eine zusätzliche Funktion ein.

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

Ich werde eine Liste der Tests beifügen:

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');
  });
});

Fazit


Der Text erwies sich als ziemlich umfangreich, aber ich hoffe, es ist klar. Zusammenfassen:

  1. Wir können die Dampfdatenstruktur immer dann verwenden, wenn wir zwei nicht miteinander verbundene Werte logisch kombinieren müssen
  2. Das Paar muss immun sein
  3. Das Paar muss eine Schnittstelle für die Arbeit mit seinen Komponenten bereitstellen

Ich entschuldige mich im Voraus, wenn der Artikel jemandem überladen erschien. Ich konzentriere mich hauptsächlich auf nicht sehr erfahrene Entwickler, deshalb versuche ich, so zu schreiben, dass die Logik meiner Argumentation und die Wahl dieser oder jener Lösung äußerst klar sind.

Wenn der Artikel für die Community von Interesse ist. dann wird die Richtung typischer Datenstrukturen fortgesetzt. Das nächste Thema sind Themenlisten.

All Articles