В этой статье вы узнаете, как добавлять собственные методы и свойства во встроенные классы JavaScript/TypeScript, используя наследование на основе прототипов и классов и методы расширения C#.

Мы собираемся создать три пользовательских метода, которые можно применять к любому типу коллекции, массива, набора или карты:

  • isEmpty
    Этот метод ищет длину/размер коллекции и возвращаетtrue или false, если коллекция пуста или нет.
  • insert
    Этот метод помещает новый элемент в коллекцию и возвращаетсуществующую коллекцию.
  • нажмите
    Этот метод принимает функцию обратного вызова (из console.log в пользовательскую функцию), которая может делать практически все, и возвращатьсуществующую коллекцию

Это будет работать аналогично Методам расширения в C#. Любой потребитель коллекции сможет использовать эти методы, а также методы по умолчанию.

Недостатком оригинальной реализации является то, что разные типы коллекций используют разные методы для взаимодействия с ними. Нас интересуют следующие вещи:

  • Поместить новый элемент в коллекцию (массив: push(), набор: add(), карта: set())
  • Проверьте размер коллекции (массив: .length, набор, карта: .size).

Таким образом, вместо того, чтобы помнить, какое свойство относится к какой коллекции, мы создадим пользовательские унифицированные методы, которые будут работать для любой из них.

Еще одна важная деталь — тип возвращаемого значения. Если наш пользовательский метод возвращает существующую коллекцию, это означает, что мы можем добавить к ней другие методы из этой коллекции:

const numbers = [1, 2, 3, 4, 5];

numbers
  .map(number => number * 5)
  .tap()
  // array continues
  .filter(...)

Но если метод возвращает другое значение, мы больше не можем добавлять к нему метод коллекции.

const numbers = [1, 2, 3, 4, 5];

numbers
  .filter(number => number > 100)
  .isEmpty()
  // true or false

Это очень легко реализовать, и мы увидим, как это выглядит с различными парадигмами, такими как прототип и наследование на основе классов, а также на разных языках программирования.

JavaScript

Наследование на основе прототипа

Наследование на основе прототипов — это механизм объектно-ориентированного программирования в JavaScript, который позволяет объектам наследовать свойства и методы других объектов. В JavaScript у каждого объекта есть внутреннее свойство, называемое его прототипом, которое является ссылкой на другой объект, служащий его прототипом.

Прототип похож на дерево, в котором существуют все свойства и методы определенного объекта. Например, если мы создаем массив, мы можем развернуть свойство прототипа, чтобы увидеть все методы и свойства, доступные для массивов.

Чтобы добавить новый метод в этот прототип, мы сделаем что-то вроде этого:

Class.prototype.nameOfTheMethod = function() {
  // do something
}

Важно отметить, что мы используем функцию оператор, а не функцию массива. Но почему?
Потому что мы будем использовать ключевое слово this внутри функции, а this будет ссылаться на потребителя метода.

Array.prototype.nameOfTheMethod = function() {
  // do something
  console.log(this) // array
}

Мы собираемся использовать ту же формулу, что и выше, для расширения каждого типа коллекции (массив, набор, карта) каждым из наших пользовательских методов (isEmpty, insert, tap).

# пусто

Этот метод проверит длину/размер коллекции и сообщит нам, пуста она или нет.

Array.prototype.isEmpty = function () {
  return this.length === 0;
};

Set.prototype.isEmpty = function () {
  return this.size === 0;
};

Map.prototype.isEmpty = function () {
  return this.size === 0;
};

# вставка

Этот метод поместит новый элемент в коллекцию.

Array.prototype.insert = function (value) {
  this.push(value);
  return this;
};

Set.prototype.insert = function (value) {
  this.add(value);
  return this;
};

Map.prototype.insert = function (key, value) {
  this.set(key, value);
  return this;
};

Это та часть, где я сказал, что у нас будет унифицированное решение. Потому что, как мы видим:

  • массивы используют push() для добавления нового элемента,
  • наборы используют add() для добавления нового элемента,
  • карты используют set()для добавления нового элемента

и теперь все три могут использовать insert(), чтобы сделать одно и то же для каждого.

# нажмите

Метод tap просматривает каждый элемент массива и вызывает нашу функцию обратного вызова. Эта функция обратного вызова может быть функцией диспетчера действий, console.log/warn/error или любой пользовательской функцией.

Array.prototype.tap = function (callback) {
  this.forEach(item => callback(item))
  return this;
};

Set.prototype.tap = function (callback) {
  this.forEach(item => callback(item))
  return this;
};

Map.prototype.tap = function (callback) {
  for (const [key, value] of this.entries()) {
    callback(key, value);
  }
  return this;
};

// this.entries() method returns an iterable of 
// key, value pairs for every entry in the Map.

Вариант использования:

const numbers = [1, 2, 3, 4, 5];

numbers
  .tap((number) => {
    // some logic
  })

Теперь давайте протестируем каждый метод. Поскольку мы расширили исходный прототип, мы можем вызывать наши новые методы непосредственно в экземплярах.

const myArr = [];
console.log(myArr.isEmpty()); // True

myArr.insert('Hello');
myArr.tap(console.log); // Hello
console.log(myArr.isEmpty()); // False

//

const mySet = new Set();
console.log(mySet.isEmpty()); // True

mySet.insert('Hello');
mySet.tap(console.log); // Hello
console.log(mySet.isEmpty()); // False

//

const myMap = new Map();
console.log(myMap.isEmpty()); // True

myMap.insert('Hello', 'World');
myMap.tap(console.log); // Hello World
console.log(myMap.isEmpty()); // False

Наследование на основе классов

Основная предпосылка будет выглядеть так. Наш пользовательский класс расширит базовый (супер) класс.

class SubClass extends SuperClass {
  constructor() {
    super();
  }
  method1() {
    this.doSomething // where this the instance of the class
  }
  method2() {
    //
  }
}

При расширении таких классов также важно вызывать super()внутри конструктора производного (под)класса. Это необходимо, поскольку производный класс наследует все свойства и методы своего родительского класса, а родительский класс может иметь собственную логику конструктора, которую необходимо выполнять в дополнение к логике конструктора производного класса.

Мы собираемся повторить этот процесс для каждого из наших целевых классов:

  • Массив (расширенный массив)
  • Набор (Расширенный набор)
  • Карта (расширенная карта)

# Расширенный массив

class ExtendedArray extends Array {
  constructor() {
    super();
  }
  isEmpty() {
    return this.length === 0;
  }
  insert(value) {
    this.push(value);
    return this;
  }
  tap(callback) {
    this.forEach(item => callback(item))
    return this;
  }
}

# Расширенный набор

class ExtendedSet extends Set {
  constructor() {
    super();
  }
  isEmpty() {
    return this.size === 0;
  }
  insert(value) {
    this.add(value);
    return this;
  }
  tap(callback) {
    this.forEach(item => callback(item))
    return this;
  }
}

# Расширенная карта

class ExtendedMap extends Map {
  constructor() {
    super();
  }
  isEmpty() {
    return this.size === 0;
  }
  insert(key, value) {
    this.set(key, value);
    return this;
  }
  tap(callback) {
    for (const [key, value] of this.entries()) {
      callback(key, value);
    }
    return this;
  }
}

Чтобы проверить это, нам нужно создать новый экземпляр каждого из наших пользовательских классов, а не работать с базовыми классами (что мы делали ранее).

Мы поместим новый элемент в коллекцию с помощью нашего метода insert, проверим коллекцию с помощью метода tap, а затем проверим, пуста ли коллекция с помощью isEmptyметод.

const myArr = new ExtendedArray();
console.log(myArr.isEmpty()); // True

myArr.insert('Hello')
myArr.tap(console.log); // Hello
console.log(myArr.isEmpty()); // False

//

const mySet = new ExtendedSet();
console.log(mySet.isEmpty()); // True

mySet.insert('Hello')
mySet.tap(console.log); // Hello
console.log(mySet.isEmpty()); // False

//

const myMap = new ExtendedMap();
console.log(myMap.isEmpty()); // True

myMap.insert('Hello', 'World')
myMap.tap(console.log); // Hello World
console.log(myMap.isEmpty()); // False

И это сработало. Теперь мы можем использовать эти недавно созданные методы всякий раз, когда мы работаем с этими типами коллекций.
Давайте узнаем, как объединить это и в TypeScript.

Машинопись

На стороне TypeScript все будет в основном похоже на JavaScript с несколькими дополнительными шагами.

Наследование на основе классов

Чтобы применить аналогичную логику на основе классов в мире TypeScript, нам нужно добавить общие типы в классы TypeScript.

# Расширенный массив

class ExtendedArray<T> extends Array<T> { 
  constructor() {  // where T is type of data included in an array,
    super(); // e.g. string, number, custom class, etc.
  }
  isEmpty() {
    return this.length === 0;
  }
  insert(value: T) {
    this.push(value);
    return this;
  }
  tap(callback: (item: T) => void) {
    this.forEach(item => callback(item))
    return this;
  }
}

# Расширенный набор

class ExtendedSet<T> extends Set<T> {
  constructor() { // where T is type of data included in the Set
    super();
  }
  isEmpty() {
    return this.size === 0;
  }
  insert(value: T) {
    this.add(value)
    return this;
  }
  tap(callback: (item: T) => void) {
    this.forEach(item => callback(item))
    return this;
  }
}

# Расширенная карта

class ExtendedMap<K, V> extends Map<K, V> {
  constructor() { // where K, V are generic types used when inserting data
    super(); // e.g. (string, string), (number, string), etc.
  }
  isEmpty() {
    return this.size === 0;
  }
  insert(key: K, value: V) {
    this.set(key, value)
    return this;
  }
  tap(callback: (key: K, value: V) => void) {
    for (const [key, value] of this.entries()) {
      callback(key, value);
    }
    return this;
  }
}

Чтобы убедиться, что это снова работает, мы собираемся протестировать его таким же образом.

const myArr = new ExtendedArray<string>(); // where T is string
console.log(myArr.isEmpty()); // True

myArr.insert('Hello')
myArr.tap(console.log); // Hello
console.log(myArr.isEmpty()); // False

//

const mySet = new ExtendedSet<string>(); // where T is string
console.log(mySet.isEmpty()); // True

mySet.insert('Hello')
mySet.tap(console.log); // Hello
console.log(mySet.isEmpty()); // False

//

const myMap = new ExtendedMap<string, string>(); // where K,V are strings
console.log(myMap.isEmpty()); // True

myMap.insert('Hello', 'World')
myMap.tap(console.log); // Hello World
console.log(myMap.isEmpty()); // False

Наследование на основе прототипа TypeScript

Чтобы прототипы работали с TypeScript, нам нужно добавить дополнительные функции. Вот почему.

Если мы попытаемся расширить прототип Array так же, как в JavaScript, компилятор TypeScript сообщит, что метод isEmpty не существует для типа массива.

Array.prototype.isEmpty = function () {
  return this.length === 0;
};

// Property 'isEmpty' does not exist on type 'any[]'.ts(2339)

Чтобы решить эту проблему, нам нужно создать файл объявления типа (d.ts), где мы укажем правила для типов, которые мы будем использовать. По сути, мы создадим метод isEmpty для каждого типа.
Узнайте больше о файлах объявления типов.

// index.d.ts file
export {};

declare global {
  interface Array<T> { // where T is once again type included in an array
    isEmpty(): boolean;
    insert(value: T): this
    tap(callback: (item: T) => void): this
  // callback type is function that takes argument and returns void
  }
}

declare global {
  interface Set<T> {
    isEmpty(): boolean;
    insert(value: T): this
  // returning 'this' means we can append methods after it
    tap(callback: (item: T) => void): this
  }
}

declare global {
  interface Map<K, V> {
    isEmpty(): boolean;
    insert(key: K, value: V): this
    tap(callback: (key: K, value: V) => void): this
  // here callback takes two arguments to print key and value
  }
}

Имея это в корне нашего проекта, мы можем создать файл index.ts и расширить каждый прототип. Обратите внимание, что мы не импортируем файл d.ts.

// index.ts or any other .ts file

Array.prototype.isEmpty = function () {
  return this.length === 0;
};

Array.prototype.insert = function<T>(value: T) {
  this.push(value);
  return this;
};

Array.prototype.tap = function<T>(callback: (item: T) => void) {
  this.forEach((item: T) => callback(item))
  return this;
};

//

Set.prototype.isEmpty = function () {
  return this.size === 0;
};

Set.prototype.insert = function<T>(value: T) {
  this.add(value);
  return this;
};

Set.prototype.tap = function<T>(callback: (item: T) => void) {
  this.forEach((item: T) => callback(item))
  return this;
};

//

Map.prototype.isEmpty = function () {
  return this.size === 0;
};

Map.prototype.insert = function<K, V>(key: K, value: V) {
  this.set(key, value);
  return this;
};

Map.prototype.tap = function<K, V>(callback: (key: K, value: V) => void) {
  for (const [key, value] of this.entries()) {
    callback(key, value);
  }
  return this;
};

Проверив это, мы должны получить те же результаты.

const myArr = [];
console.log(myArr.isEmpty()); // True

myArr.insert('Hello');
myArr.tap(console.log); // Hello
console.log(myArr.isEmpty()); // False

//

const mySet = new Set();
console.log(mySet.isEmpty()); // True

mySet.insert('Hello');
mySet.tap(console.log); // Hello
console.log(mySet.isEmpty()); // False

//

const myMap = new Map();
console.log(myMap.isEmpty()); // True

myMap.insert('Hello', 'World');
myMap.tap(console.log); // Hello World
console.log(myMap.isEmpty()); // False

Абстрагирование сторонних пакетов с помощью расширений

Иногда мы хотим добавить функциональность в код из сторонней библиотеки в кодовой базе и часто ее использовать, но мы не хотим изучать все методы и свойства, предоставляемые библиотекой.

По сути, мы могли бы создать абстракцию для используемой нами библиотеки, чтобы потребитель/разработчик мог использовать метод, не зная, что он делает под капотом и какие библиотеки вызывает.

Давайте настроим в проекте известную библиотеку дат JavaScript Day.js.

> npm init -y
...
> npm install dayjs

Мы будем использовать подключаемый модуль относительное время, встроенный в Day.js, чтобы получить смещение между желаемой и текущей датой.

const dayjs = require('dayjs');
const relativeTime = require('dayjs/plugin/relativeTime');
dayjs.extend(relativeTime);

Мы расширим класс JavaScript Date с помощью собственного метода (getDateOffset), который абстрагирует вызов относительного времени в Day.js.

Date.prototype.getDateOffset = function() {
  return dayjs().to(dayjs(this));
}

По сути, это добавление дополнительной функции к классу Date в JavaScript.
Давайте протестируем наш метод.

const dateInThePast = new Date();
dateInThePast.setDate(dateInThePast.getDate() - 10);
console.log(dateInThePast.getDateOffset()) // 10 days ago

var dateInTheFuture = new Date();
dateInTheFuture.setDate(dateInTheFuture.getDate() + 10);
console.log(dateInTheFuture.getDateOffset()); // in 10 days

Чтобы реализовать этот трюк на C#, обязательно используйте пакет Nugget Humanizer.
Если вы хотите узнать больше о Day.js, обязательно прочитайте мою статью.



Методы расширения С#

Мы несколько раз упомянули C# в статье, так что мы могли бы также добавить некоторые сведения и здесь.

Чтобы расширить основные классы, как мы сделали с прототипом в JavaScript, мы будем использовать методы расширения C#. Основная идея аналогична: мы создаем собственный класс с собственным методом (расширением), который будет выполнять работу, которую мы ему скажем.

public static class Utils
{
 public static string TheMethod (this string TheText)
 {
  return TheText.something();
 }

Имейте в виду несколько вещей:

  • класс и метод должны быть статическими
  • первый параметр метода расширения указывает тип, над которым он будет работать. Это потребитель расширения.
  • первому параметру предшествует ключевое слово this. Именно это сообщает C#, что этот метод является расширением предоставленного типа (в данном случае строки).

После этого мы импортируем наш класс Utils в файл, где мы хотим использовать расширение, и применяем его;

using Utils;
...
string name = "Player 1";

name.TheMethod(); // watch the magic happen

Теперь давайте применим этот шаблон к нашим трем пользовательским расширениям.

Важно отметить, что C#, как и раньше, поддерживает наследование на основе классов.

# пусто

Начнем с создания метода расширения. Прелесть коллекций C# в том, что все они реализуют интерфейс IEnumerable. Возможно, вы уже сталкивались с чем-то подобным.

Если мы создадим метод, который возвращает IEnumarable, не будет иметь большого значения, возвращает ли метод Array, List или HashSet (набор JavaScript), в любом из этих случаев компилятор не будет жаловаться.

 public IEnumerable<string> TheMethod() {
   return new string[0];
 }
 
 public IEnumerable<string> TheMethod2() {
   return new List<string>();
 }
 
 public IEnumerable<string> TheMethod3() {
   return new HashSet<string>();
 }
 
 public IEnumerable<string> TheMethod4() {
   return new Stack<string>();
 }
 
 public IEnumerable<string> TheMethod5() {
   return new Queue<string>();
 }

Он также работает со словарями (карта JavaScript), пока мы продолжаем использовать KeyValuePair‹K, V›, потому что IEnumerable исключает один тип (оболочку).

 public IEnumerable<KeyValuePair<string, string>> TheMethod() {
   return new Dictionary<string, string>();
 }

Вернемся к нашему примеру. Метод isEmpty будет работать с коллекциями, поэтому параметр, передаваемый нашему методу расширения, будет иметь тип IEnumerable‹T›.
Затем мы вызываем метод .Any() для нужной коллекции. Это возвращает true или false независимо от того, пуста коллекция или нет.

public static class Utils
{
 public static bool IsEmpty<T>(this IEnumerable<T> collection)
 {
  return collection != null && !collection.Any(); 
  // .Any() method is available on all IEnumerables
 }
}

Давайте проверим это.

using System;
using System.Collections.Generic;
using System.Linq;
using Utils;

public class Program
{
 public static void Main()
 {
     string[] myArr = new string[0];
     Console.WriteLine(myArr.IsEmpty()); // True
    
     myArr = new string[5]; // array of Nulls
     Console.WriteLine(myArr.IsEmpty()); // False

      //

     var myList = new List<string>();
     Console.WriteLine(myList.IsEmpty()); // True
    
     myList.Add("Hello");
     Console.WriteLine(myList.IsEmpty()); // False
       
      //
    
     var myDict = new Dictionary<string, string>();
     Console.WriteLine(myDict.IsEmpty()); // True
    
     myDict.Add("Hello", "World");
     Console.WriteLine(myDict.IsEmpty()); // False
 }
}

Пройдено с честью.

# вставлять

Для этого метода мы будем использовать классы Stack и List.

Здесь мы не будем использовать IEnumerable, поскольку мы не можем вставлять данные в IEnumerable. Вместо этого мы будем использовать метод create and override, который может работать с List‹T› и Stack‹T›.

public static class Utils
{
 
    public static List<T> Insert<T>(this List<T> list, T item)
    {
        list.Add(item); // adds item to the list
        return list;
    }

    public static Stack<T> Insert<T>(this Stack<T> stack, T item)
    {
        stack.Push(item); // pushes item to the stack
        return stack;
    }
}

Теперь потребитель любого класса может использовать метод insert, не зная лежащей в его основе реализации.

  var existingStories = new List<string>();
  existingStories.Insert("Destructuring JS");
  existingStories.Insert("Day.js");
  existingStories.Insert("CMD");

  Console.WriteLine(string.Join(", ", existingStories));

  var upcoming = new Stack<string>();
  upcoming.Insert("Express JS");
  upcoming.Insert("Angular");
  upcoming.Insert("Rx.js");
  
  Console.WriteLine(string.Join(", ", upcoming));

# кран

Чтобы создать функцию обратного вызова на C#, мы воспользуемся делегатом
Action‹T›. Здесь мы будем использовать List и HashSet (что эквивалентно коллекции Set в Java/JavaScript).

public static class Utils
{
    public static IEnumerable<T> Tap<T>
    (
      this IEnumerable<T> collection, 
      Action<T> callback
    )
    {
        foreach (T item in collection)
        {
            callback(item);
        }
        return collection;
    }
}

Теперь давайте проверим это

using System;
using System.Collections.Generic;
using Utils;
     
public class Program
{
 public static void Main()
 {
  
  HashSet<int> mySet = new HashSet<int> { 1, 2, 3 };
  mySet.Tap(Console.WriteLine); // 1, 2, 3
  
  List<int> myList = new List<int> { 1, 2, 3 };
  myList.Tap(Console.WriteLine); // 1, 2, 3
 }
}

Это можно применить и к словарям.

public static class Utils
{

    public static Dictionary<TKey, TValue> Tap<TKey, TValue>
    (
      this Dictionary<TKey, TValue> dictionary, 
      Action<TKey, TValue> callback
    )
    {
        foreach (KeyValuePair<TKey, TValue> kvp in dictionary)
        {
            callback(kvp.Key, kvp.Value);
        }
        return dictionary;
    }
}
using System;
using System.Collections.Generic;
using Utils;
     
public class Program
{
 public static void Main()
 {
  Dictionary<string, int> myDictionary = new Dictionary<string, int> {
   { "foo", 1 },
   { "bar", 2 },
   { "baz", 3 }
  };

  myDictionary.Tap((key, value) => 
    Console.WriteLine($"Key: {key}, Value: {value}"));

  /*
    Key: foo, Value: 1
    Key: bar, Value: 2
    Key: baz, Value: 3
  */
 }
}

Красота создания расширений

Если я до сих пор не убедил вас использовать расширения, это должно поставить точку. Используя методы, которые мы создали выше, мы создадим пустой массив и добавим в него элементы.

const colleciton = new Array();
colleciton
  .insert(1)
  .insert(5)
  .insert(3)
  .insert(5)
  .tap(console.log);

// 1, 5, 3, 5

Теперь давайте поменяем класс Array на класс Set.

const colleciton = new Set();
colleciton
  .insert(1)
  .insert(5)
  .insert(3)
  .insert(5)
  .tap(console.log);

// 1, 5, 3

И все работает, как и ожидалось, с нулевым изменением кода.
Разница в выводе заключается в том, что коллекция Set возвращает только уникальные элементы.

Сложности работы с расширениями

Давайте поговорим о потенциальных проблемах с созданием пользовательских расширений.

#1

Первое, что нужно знать, это то, что нам нужно, чтобы все потребители расширения были на борту с методом расширения, который они вызывают. Например, если мы используем List‹T› в C# и я хочу объединить его со Stack‹T›, мы можем использовать только те методы в расширениях, которые доступны в обоих.

И если у них нет общего языка, то мы можем реализовать один метод для каждого класса ‹T›, как мы сделали выше для метода вставки.

# 2

Будущие языковые изменения.
Если мы добавим пользовательский метод в код, а тот же метод будет представлен командой основного языка (JS/C#) в функции, это может создать двусмысленность, которая приведет к повреждению кода.

# 3

Другая опасность создания пользовательских прототипов заключается в том, что мы можем сломать код, основанный на поведении встроенных объектов по умолчанию. Например, если мы изменим объект String.prototype, чтобы добавить пользовательский метод, это может повлиять на любой код, основанный на поведении объекта String по умолчанию, и потенциально сломать его.

Аналогично с методами расширения в C#. Если имя метода расширения конфликтует с существующим свойством, это может привести к непредвиденному поведению или ошибкам.

Краткое содержание

Расширения — это интересный способ введения новых функций, абстрактной реализации и создания унифицированных решений, но при неосторожном использовании они могут вызвать проблемы.

Надеюсь, вы сегодня узнали что-то новое. Если вы хотите увидеть больше кросс-технологических статей, обязательно дайте мне знать в комментариях.

Увидимся в следующем! 👋