Русский
Русский
English
Статистика
Реклама

Генераторы

Эпическая сага про маленький custom hook для React (генераторы, sagas, rxjs) часть 2

11.12.2020 12:12:55 | Автор: admin

Часть первая

О генераторах

Генераторы - это новый вид функций, который появился в ES6. О них написано немало статей и приведено множество теоретических примеров. Что касается меня, то прояснить суть генераторов и способ их использования помогла книга You don't know JS, часть async & performance. Из всех книг по JS, которые я изучал, эта наиболее упакована полезной информацией без воды.

Представим, что генератор (функция в объявлении, которой есть *) - это некое электрическое устройство с дистанционным пультом управления. После создания и монтирования генератора (объявления функции) нужно его "крутануть" (выполнить эту функцию), чтобы он вращался на холостых оборотах и "запитал" пульт управления собой (при выполнении функции-генератора возвращает итератор). На этом пульте управления две кнопки: Пуск (вызвать первый раз метод next итератора) и Next (последующие вызовы метода next итератора). Далее с этим пультом управления можно носиться по всей электростанции (по нашему приложению) и когда понадобиться электрическая энергия (некие значения из функции-генератора) нажимать на пульте кнопку next (выполнять метод next() генератора). Генератор производит нужное количество электроэнергии (возвращает некое значение через yield) и опять переходит в холостой режим (функция-генератор ждёт следующего вызова next от итератора). Цикл продолжается, пока генератор может производить электричество (имеются операторы yield) или он не остановится (в функции-генераторе встретится return).

И во всей этой аналогии ключевой момент - это пульт управления (итератор). Его можно передавать в разные части приложения и в нужный момент "забирать" значения из генератора. Для полноты картины, на пульте управления можно добавить неограниченное количество кнопок для запуска генератора в определенных режимах (передача параметров в метод next(любые параметры) итератора), но для реализации хука достаточно и двух кнопок.

Вариант 4. Генератор без промисов

Этот вариант приводится для наглядности, т.к. в полную силу генераторы работают совместно с промисами (механизм async/await). Но этот вариант рабочий и имеет право на существование в определенных простых ситуациях.

Создаю в хуке переменную для хранения ссылки на итератор (ячейка для пульта управления генератором)

const iteratorRef = useRef(null);

Нужно изменить логику хука. При выполнении обработчика события будет запускаться генератор. В данном случае нет никакого обмена данными между генератором и кодом, выполняющим метод next() итератора (генератор просто делает один оборот при нажатии кнопки next). Выглядит это так:

const updateCounter = () => {  iteratorRef.current.next();};const checkImageLoading = (url) => {  const imageChecker = new Image();  imageChecker.addEventListener("load", updateCounter);  imageChecker.addEventListener("error", updateCounter);  imageChecker.src = url;};

При запуске генератор расставляет обработчики событий и затем запускает цикл по сборке ответов из этих обработчиков. Каждому обработчику был вручён пульт и было объяснено, что, как только придёт событие, нужно нажать кнопку next на пульте. Когда приходит событие, обработчик добросовестно жмёт на кнопку и тем самым "прокручивает генератор". В процессе оборота происходит dispatch нужного действия и генератор опять переходит в холостой режим, ожидая следующего сигнала от пульта. Ниже приведён код самого генератора:

function* main() {  for (let i = 0; i < imgArray.length; i++) {    checkImageLoading(imgArray[i].src);  }  for (let i = 0; i < imgArray.length; i++) {    yield true;    dispatch({      type: ACTIONS.SET_COUNTER,      data: stateRef.current.counter + stateRef.current.counterStep    });  }}

Конечно при монтировании хука нужно "крутануть" генератор, чтобы он запитал пульт (вернул итератор в iteratorRef. И после этого нажать кнопку Пуск (выполнить метод next итератора первый раз).

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

Исходный код хука Генератор без промисов
import { useReducer, useEffect, useLayoutEffect, useRef } from "react";import { reducer, initialState, ACTIONS } from "./state";const PRELOADER_SELECTOR = ".preloader__wrapper";const PRELOADER_COUNTER_SELECTOR = ".preloader__counter";const usePreloader = () => {  const [state, dispatch] = useReducer(reducer, initialState);  const stateRef = useRef(state);  const iteratorRef = useRef(null);  const preloaderEl = document.querySelector(PRELOADER_SELECTOR);  const counterEl = document.querySelector(PRELOADER_COUNTER_SELECTOR);  const updateCounter = () => {    iteratorRef.current.next();  };  const checkImageLoading = (url) => {    const imageChecker = new Image();    imageChecker.addEventListener("load", updateCounter);    imageChecker.addEventListener("error", updateCounter);    imageChecker.src = url;  };  useEffect(() => {    const imgArray = document.querySelectorAll("img");    if (imgArray.length > 0) {      dispatch({        type: ACTIONS.SET_COUNTER_STEP,        data: Math.floor(100 / imgArray.length) + 1      });    }    function* main() {      for (let i = 0; i < imgArray.length; i++) {        checkImageLoading(imgArray[i].src);      }      for (let i = 0; i < imgArray.length; i++) {        yield true;        dispatch({          type: ACTIONS.SET_COUNTER,          data: stateRef.current.counter + stateRef.current.counterStep        });      }    }    iteratorRef.current = main();    iteratorRef.current.next();  }, []);  useLayoutEffect(() => {    stateRef.current = state;    if (counterEl) {      stateRef.current.counter < 100        ? (counterEl.innerHTML = `${stateRef.current.counter}%`)        : hidePreloader(preloaderEl);    }  }, [state]);  return;};const hidePreloader = (preloaderEl) => {  preloaderEl.remove();};export default usePreloader;

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

Вариант 5. Генератор с промисами

Как вы уже догадались генератор будет возвращать промис. Таким образом обработчикам событий не нужно будет вызывать метод next итератора (не нужно будет отдавать им пульт управления и давать инструкции по его использованию). Просто промисифицируем колбэк (мы сами будем знать когда отработал обработчик и без его ведома).

Код генератор изменится следующим образом:

const getImageLoading = async function* (imagesArray) {  for (const img of imagesArray) {    yield new Promise((resolve, reject) => {      const imageChecker = new Image();      imageChecker.addEventListener("load", () => resolve(true));      imageChecker.addEventListener("error", () => resolve(true));      imageChecker.src = img.url;    });  }};

А вызывающий код будет выглядеть так:

for await (const response of getImageLoading(imgArray)) {  dispatch({    type: ACTIONS.SET_COUNTER,    data: stateRef.current.counter + stateRef.current.counterStep  });}

Основную работу по сравнению с предыдущим вариантом выполняет цикл for await ... of. Пульт управления генератором находится у него и он автоматически нажимает кнопку Пуск и Next.

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

Исходный код хука Генератор с промисами
import { useReducer, useEffect, useRef } from "react";import { reducer, initialState, ACTIONS } from "./state";const PRELOADER_SELECTOR = ".preloader__wrapper";const PRELOADER_COUNTER_SELECTOR = ".preloader__counter";const usePreloader = () => {  const [state, dispatch] = useReducer(reducer, initialState);  const stateRef = useRef(state);  const preloaderEl = document.querySelector(PRELOADER_SELECTOR);  const counterEl = document.querySelector(PRELOADER_COUNTER_SELECTOR);  useEffect(() => {    async function imageLoading() {      const imgArray = document.querySelectorAll("img");      if (imgArray.length > 0) {        dispatch({          type: ACTIONS.SET_COUNTER_STEP,          data: Math.floor(100 / imgArray.length) + 1        });          for await (const response of getImageLoading(imgArray)) {          dispatch({            type: ACTIONS.SET_COUNTER,            data: stateRef.current.counter + stateRef.current.counterStep          });        }      }    }    imageLoading();  }, []);  useEffect(() => {    stateRef.current = state;    if (counterEl) {      stateRef.current.counter < 100        ? (counterEl.innerHTML = `${stateRef.current.counter}%`)        : hidePreloader(preloaderEl);    }  }, [state]);  return;};const getImageLoading = async function* (imagesArray) {  for (const img of imagesArray) {    yield new Promise((resolve, reject) => {      const imageChecker = new Image();      imageChecker.addEventListener("load", () => resolve(true));      imageChecker.addEventListener("error", () => resolve(true));      imageChecker.src = img.url;    });  }};const hidePreloader = (preloaderEl) => {  preloaderEl.remove();};export default usePreloader;

Итого:

В этой части статьи показано:

  • как использовать useRef для хранения и использования нужных значений на протяжении всего времени жизни компонента (некий аналог глобальных переменных для компонента)

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

  • как управлять потоком событий, обработчики которых промисифицированы, с помощью генераторов и цикла for await ... of

Ссылка напесочницу

Ссылка нарепозиторий

Продолжение следует... redux-saga...

Подробнее..

Перевод Итерируемые объекты и итераторы углублённое руководство по JavaScript

18.12.2020 20:18:20 | Автор: admin

Эта статья представляет собой углублённое введение в итерируемые объекты (iterables) и итераторы (iterators) в JavaScript. Моя главная мотивация к её написанию заключалась в подготовке к изучению генераторов. По сути, я планировал позднее поэкспериментировать с комбинированием генераторов и хуками React. Если вам это интересно, то следите за моим Twitter или YouTube!

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

Введение


Как вы заметили, мы обсуждаем итерируемые объекты и итераторы. Это взаимосвязанные, но разные концепции, так что при чтении статьи обращайте внимание, о какой из них идёт речь в конкретном случае.

Начнём с итерируемых объектов. Что это такое? Это нечто, что может быть итерировано, например:

for (let element of iterable) {    // do something with an element}

Обратите внимание, что здесь мы рассматриваем только циклы for ... of, которые появились в ES6. А циклы for ... in это более старая конструкция, к которой мы совсем не будем обращаться в этой статье.

Теперь вы могли подумать: Ладно, эта итерируемая переменная просто массив! Верно, массивы являются итерируемыми. Но сейчас в нативном JavaScript существуют и другие структуры, которые можно использовать в цикле for ... of. То есть помимо массивов есть и иные итерируемые объекты.

Например, мы можем итерировать Map, появившиеся в ES6:

const ourMap = new Map();ourMap.set(1, 'a');ourMap.set(2, 'b');ourMap.set(3, 'c');for (let element of ourMap) {    console.log(element);}

Этот код выведет на экран:

[1, 'a'][2, 'b'][3, 'c']

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

То, что мы смогли использовать цикл for ... of для итерирования Map, доказывает, что Mapы являются итерируемыми. Повторюсь: в циклах for ... of могут использоваться только итерируемые объекты. То есть, если что-то работает с этим циклом, то оно является итерируемым объектом.

Забавно, что конструктор Map опционально принимает итерируемые объекты пар ключ-значение. То есть это альтернативный способ конструирования такого же Map:

const ourMap = new Map([    [1, 'a'],    [2, 'b'],    [3, 'c'],]);

А поскольку Map является итерируемым объектом, мы можем очень легко создавать его копии:

const copyOfOurMap = new Map(ourMap);

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

Итак, мы увидели два примера итерируемых объектов массив и ES6 Map. Но мы пока не знаем, как они обрели возможность быть итерируемыми. Ответ прост: существуют ассоциированные с ними итераторы. Будьте внимательны: итераторы, не итерируемые.

Каким образом итератор ассоциирован с итерируемым объектом? Просто итерируемый объект должен содержать функцию в своём свойстве Symbol.iterator. При её вызове функция должна возвращать итератор для этого объекта.

Например, можно извлечь итератор массива:

const ourArray = [1, 2, 3];const iterator = ourArray[Symbol.iterator]();console.log(iterator);

Этот код выводит в консоль Object [Array Iterator] {}. Теперь мы знаем, что у массива есть ассоциированный итератор, который является каким-то объектом.

А что такое итератор?

Всё просто. Итератор объект, содержащий метод next. При вызове этого метода он должен возвращать:

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

Давайте протестируем это, вызвав метод next у итератора нашего массива:

const result = iterator.next();console.log(result);

В консоли увидим объект { value: 1, done: false }. Первый элемент созданного нами массива 1, и здесь она появилась в качестве значения. Также мы получили информацию, что итератор ещё не завершился, то есть мы можем пока вызывать функцию next и получать какие-то значения. Давайте попробуем! Вызовем next ещё два раза:

console.log(iterator.next());console.log(iterator.next());

Получили один за другим { value: 2, done: false } и { value: 3, done: false }.

В нашем массиве только три элемента. Что будет, если снова вызвать next?

console.log(iterator.next());

На этот раз мы увидим { value: undefined, done: true }. Это говорит о том, что итератор завершён. Нет смысла снова вызывать next. Если это делать, от раз за разом мы будем получать объект { value: undefined, done: true }. done: true означает остановку итерирования.

Теперь можно понять, что делает for ... of под капотом:

  • первый метод [Symbol.iterator]() вызывается для получения итератора;
  • метод next циклически вызывается применительно к итератору, пока мы не получим done: true;
  • после каждого вызова next в теле цикла используется свойство value.

Напишем всё это в коде:

const iterator = ourArray[Symbol.iterator]();let result = iterator.next();while (!result.done) {    const element = result.value;    // do some something with element    result = iterator.next();}

Этот код эквивалентен такому:

for (let element of ourArray) {    // do something with element}

Можете убедиться в этом, например, вставив console.log(element) вместо комментария // do something with element.

Создаём собственный итератор


Теперь мы знаем, что такое итерируемые объекты и итераторы. Возникает вопрос: Можно ли написать собственные экземпляры?

Безусловно!

В итераторах нет ничего таинственного. Это лишь объекты с методом next, ведущие себя особым образом. Мы уже выяснили, какие нативные значения в JS являются итерируемыми. Объекты среди них не упоминали. Действительно, они не итерируются нативно. Рассмотрим такой объект:

const ourObject = {    1: 'a',    2: 'b',    3: 'c'};

Если его итерировать с помощью for (let element of ourObject), то получим ошибку object is not iterable.

Давайте напишем собственные итераторы, сделав такой объект итерируемым!

Для этого придётся пропатчить прототип Object своим методом [Symbol.iterator](). Поскольку патчинг прототипа плохая практика, создадим свой класс, расширив Object:

class IterableObject extends Object {    constructor(object) {        super();        Object.assign(this, object);    }}

Конструктор нашего класса берёт обычный объект и копирует его свойства в итерируемый объект (хотя на самом деле он ещё не является итерируемым!).

Создадим итерируемый объект:

const iterableObject = new IterableObject({    1: 'a',    2: 'b',    3: 'c'})

Чтобы сделать класс IterableObject действительно итерируемым, нам нужен метод [Symbol.iterator](). Добавим его.

class IterableObject extends Object {    constructor(object) {        super();        Object.assign(this, object);    }    [Symbol.iterator]() {    }}

Теперь можно писать настоящий итератор!

Мы уже знаем, что это должен быть объект с методом next. С этого и начнём.

class IterableObject extends Object {    // same as before    [Symbol.iterator]() {        return {            next() {}        }    }}

После каждого вызова next нужно возвращать объект вида { value, done }. Сделаем его с выдуманными значениями.

class IterableObject extends Object {    // same as before    [Symbol.iterator]() {        return {            next() {                return {                    value: undefined,                    done: false                }            }        }    }}

Учитывая такой итерируемый объект:

const iterableObject = new IterableObject({    1: 'a',    2: 'b',    3: 'c'})

мы будем выводить пары ключ-значение, аналогично тому, как делает итерирование ES6 Map:

['1', 'a']['2', 'b']['3', 'c']

В нашем итераторе в значении property мы будем хранить массив [key, valueForThatKey]. Обратите внимание, что по сравнению с предыдущими этапами это наше собственное решение. Если бы мы хотели написать итератор, возвращающий только ключи или только значения свойств, то без проблем могли бы это сделать. Просто сейчас решили возвращать пары ключ-значение.

Нам понадобится массив вида [key, valueForThatKey]. Проще всего получить с помощью метода Object.entries. Можем использовать его прямо передо созданием объекта итератора в методе [Symbol.iterator]():

class IterableObject extends Object {    // same as before    [Symbol.iterator]() {        // we made an addition here        const entries = Object.entries(this);        return {            next() {                return {                    value: undefined,                    done: false                }            }        }    }}

Возвращаемый в методе итератор благодаря JavaScript-замыканию получит доступ к переменной entries.

Также нам нужна переменная состояния. Она будет говорить нам, какая пара ключ-значение должна быть возвращена при следующем вызове next. Добавим её:

class IterableObject extends Object {    // same as before    [Symbol.iterator]() {        const entries = Object.entries(this);        // we made an addition here        let index = 0;        return {            next() {                return {                    value: undefined,                    done: false                }            }        }    }}

Обратите внимание, что мы объявили переменную index с let, потому что знаем, что планируем обновлять её значение после каждого вызова next.

Теперь мы готовы вернуть фактическое значение в методе next:

class IterableObject extends Object {    // same as before    [Symbol.iterator]() {        const entries = Object.entries(this);        let index = 0;        return {            next() {                return {                    // we made a change here                    value: entries[index],                    done: false                }            }        }    }}

Это было просто. Мы лишь использовали переменные entries и index для доступа к правильной паре ключ-значение из массива entries.

Теперь нужно разобраться со свойством done, потому что сейчас оно всегда будет false. Можно сделать ещё одну переменную кроме entries и index, и обновлять её после каждого вызова next. Но есть способ ещё проще. Будем проверять, вышел ли index за пределы массива entries:

class IterableObject extends Object {    // same as before    [Symbol.iterator]() {        const entries = Object.entries(this);        let index = 0;        return {            next() {                return {                    value: entries[index],                    // we made a change here                    done: index >= entries.length                }            }        }    }}

Наш итератор завершается, когда переменная index равна или больше длины entries. Например, если у entries длина 3, то он содержит значения под индексами 0, 1 и 2. И когда переменная index равна или больше 3, это означает, что значений больше не осталось. Мы закончили.

Этот код почти работает. Осталось добавить только одно.

Переменная index начинается со значения 0, но мы её не обновляем! Тут всё не так просто. Нам нужно обновлять переменную после того, как мы вернули { value, done }. Но когда мы его вернули, метод next немедленно останавливается, даже если есть какой-то код после выражения return. Но мы можем создать объект { value, done }, хранить его в переменной, обновлять index и только потом возвращать объект:

class IterableObject extends Object {    // same as before    [Symbol.iterator]() {        const entries = Object.entries(this);        let index = 0;        return {            next() {                const result = {                    value: entries[index],                    done: index >= entries.length                };                index++;                return result;            }        }    }}

После наших изменений класс IterableObject выглядит так:

class IterableObject extends Object {    constructor(object) {        super();        Object.assign(this, object);    }    [Symbol.iterator]() {        const entries = Object.entries(this);        let index = 0;        return {            next() {                const result = {                    value: entries[index],                    done: index >= entries.length                };                index++;                return result;            }        }    }}

Код прекрасно работает, но он стал довольно запутанным. Это потому, что здесь показан более умный, но менее очевидный способ обновления index после создания объекта result. Мы можем просто инициализировать index со значением -1! И хотя он обновляется до возвращения объекта из next, всё будет прекрасно работать, потому что первое обновление заменит -1 на 0.

Так и сделаем:

class IterableObject extends Object {    // same as before    [Symbol.iterator]() {        const entries = Object.entries(this);        let index = -1;        return {            next() {                index++;                return {                    value: entries[index],                    done: index >= entries.length                }            }        }    }}

Как видите, теперь нам не нужно жонглировать очерёдностью создания объекта result и обновления index. В ходе второго вызова index будет обновлён до 1, а мы вернём другой результат, и т. д. Всё работает так, как мы и хотели, а код выглядит гораздо проще.

Но как нам проверить корректность работы? Можно вручную запустить метод [Symbol.iterator]() для создания экземпляра итератора, а затем напрямую проверить результаты вызовов next. Но можно поступить гораздо проще! Выше было сказано, что любой итерируемый объект можно вставить в цикл for ... of. Давайте так и сделаем, попутно журналируя значения, возвращаемые нашим итерируемым объектом:

const iterableObject = new IterableObject({    1: 'a',    2: 'b',    3: 'c'});for (let element of iterableObject) {    console.log(element);}

Работает! Вот что выводится в консоли:

[ '1', 'a' ][ '2', 'b' ][ '3', 'c' ]

Круто! Мы начали с объекта, который нельзя было использовать в циклах for ... of, потому что нативно они не содержат встроенных итераторов. Но мы создали свой IterableObject, который имеет ассоциированный самописный итератор.

Надеюсь, что теперь вы видите потенциал итерируемых объектов и итераторов. Это механизм, позволяющий создавать собственные структуры данных для совместной работы с функциями JS вроде циклов for ... of, причём они работают точно так же, как нативные структуры! Это очень полезная возможность, которая в определённых ситуациях позволяет сильно упростить код, особенно если вы планируете часто итерировать свои структуры данных.

Кроме того, мы можем настраивать, что именно должны возвращать такие итерации. Сейчас наш итератор возвращает пары ключ-значение. А если нам нужны только значения? Легко, просто перепишем итератор:

class IterableObject extends Object {    // same as before    [Symbol.iterator]() {        // changed `entries` to `values`        const values = Object.values(this);        let index = -1;        return {            next() {                index++;                return {                    // changed `entries` to `values`                    value: values[index],                    // changed `entries` to `values`                    done: index >= values.length                }            }        }    }}

И всё! Если теперь запустить цикл for ... of, то увидим в консоли:

abc

Мы вернули одни лишь значения объектов. Всё это доказывает гибкость самописных итераторов. Вы можете заставить их возвращать что угодно.

Итераторы как итерируемые объекты


Люди очень часто путают итераторы и итерируемые объекты. Это ошибка, и я старался аккуратно разделить эти два понятия. Подозреваю, мне известна причина, по которой люди так часто их путают.

Оказывается, что итераторы иногда являются итерируемыми объектами!

Что это означает? Как вы помните, итерируемый объект это объект, с которым ассоциирован итератор. В каждом нативном JavaScript-итераторе есть метод [Symbol.iterator](), возвращающий ещё один итератор! А это делает первый итератор итерируемым объектом.

Проверить это можно, если взять итератор, возвращённый из массива, и вызвать применительно к нему [Symbol.iterator]():

const ourArray = [1, 2, 3];const iterator = ourArray[Symbol.iterator]();const secondIterator = iterator[Symbol.iterator]();console.log(secondIterator);

После запуска этого кода вы увидите Object [Array Iterator] {}. То есть итератор не только содержит другой итератор, ассоциированный с ним, так это ещё и тоже массив.

Если сравнить оба итератора с помощью ===, то окажется, что они совершенно одинаковы:

const iterator = ourArray[Symbol.iterator]();const secondIterator = iterator[Symbol.iterator]();// logs `true`console.log(iterator === secondIterator);

Поначалу вам может показаться странным поведение итератора, который является собственным итератором. Но это очень полезная фича. Вы не можете воткнуть голый итератор в цикл for ... of, он принимает только итерируемый объект объект с методом [Symbol.iterator]().

Однако ситуация, когда итератор является собственным итератором (а следовательно итерируемым объектом), скрывает проблему. Поскольку нативные JS-итераторы содержат методы [Symbol.iterator](), вы можете не задумываясь передавать их напрямую в циклы for ... of.

В результате этот фрагмент:

const ourArray = [1, 2, 3];for (let element of ourArray) {    console.log(element);}

и этот:

const ourArray = [1, 2, 3];const iterator = ourArray[Symbol.iterator]();for (let element of iterator) {    console.log(element);}

работают без проблем и делают одно и то же. Но зачем кому-то напрямую использовать подобные итераторы в циклах for ... of? Иногда это просто неизбежно.

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

И было бы очень неудобно, если бы наличие голого итератор означало, что вы не можете использовать его в for ... of. Конечно, можно сделать это вручную с помощью метода next и, к примеру, цикла while, но мы видели, для этого приходится писать довольно много кода, причём повторяющегося.

Решение простое: если вы хотите избежать шаблонного кода и использовать итератор в цикле for ... of, придётся делать итератор итерируемым объектом.

С другой стороны, мы также довольно часто получаем итераторы из других методов, не только из [Symbol.iterator](). К примеру, ES6 Map содержит методы entries, values и keys. Все они возвращают итераторы.

Если бы нативные JS-итераторы не были ещё и итерируемыми объектами, вы не могли бы использовать эти методы напрямую в циклах for ... of, вот так:

for (let element of map.entries()) {    console.log(element);}for (let element of map.values()) {    console.log(element);}for (let element of map.keys()) {    console.log(element);}

Этот код работает, потому что итераторы, возвращённые методами, являются и итерируемыми объектами. В противном случае пришлось бы, скажем, обёртывать результат вызова map.entries() в какой-нибудь дурацкий итерируемый объект. К счастью, нам это делать не нужно.

Считается хорошей практикой делать свои итераторы-итерируемые объекты. Особенно если они будут возвращены из иных методов, а не из [Symbol.iterator](). Сделать итератор итерируемым объектом очень просто. Покажу на примере итератора IterableObject:

class IterableObject extends Object {    // same as before    [Symbol.iterator]() {        // same as before        return {            next() {                // same as before            },            [Symbol.iterator]() {                return this;            }        }    }}

Мы создали метод [Symbol.iterator]() под методом next. Сделали этот итератор своим собственным итератором, просто возвращая this, то есть он возвращает сам себя. Выше мы уже видели, как ведёт себя итератор-массив. Этого достаточно, чтобы наш итератор работал в циклах for ... of даже напрямую.

Состояние итератора


Теперь должно быть очевидно, что у каждого итератора есть ассоциированное с ним состояние. Например, в итераторе IterableObject мы хранили состояние переменную index в виде замыкания. И обновляли её после каждого этапа итерации.

А что будет после завершения процесса итерации? Итератор становится бесполезен и можно (следует!) удалить его. В том, что это происходит, можно убедиться даже на примере нативных JS-объектов. Возьмём итератор массива и попробуем дважды запустить его в цикле for ... of.

const ourArray = [1, 2, 3];const iterator = ourArray[Symbol.iterator]();for (let element of iterator) {    console.log(element);}for (let element of iterator) {    console.log(element);}

Вы могли ожидать, что в консоли дважды отобразятся числа 1, 2 и 3. Но результат будет такой:

123

Почему?

Давайте вручную вызовем next после завершения цикла:

const ourArray = [1, 2, 3];const iterator = ourArray[Symbol.iterator]();for (let element of iterator) {    console.log(element);}console.log(iterator.next());

Последний журнал выводит в консоли { value: undefined, done: true }.

Вот оно что. После завершения цикла итератор переходит в состояние done. Теперь он всегда будет возвращать объект { value: undefined, done: true }.

Есть ли способ сбросить состояние итератора, чтобы второй раз использовать его в for ... of? В некоторых случаях возможно, но смысла в этом нет. Поэтому [Symbol.iterator] является методом, а не просто свойством. Можно вызвать метод снова и получить другой итератор:

const ourArray = [1, 2, 3];const iterator = ourArray[Symbol.iterator]();for (let element of iterator) {    console.log(element);}const secondIterator = ourArray[Symbol.iterator]();for (let element of secondIterator) {    console.log(element);}

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

const ourArray = [1, 2, 3];for (let element of ourArray) {    console.log(element);}for (let element of ourArray) {    console.log(element);}

Все циклы for ... of используют разные итераторы! После завершения итератор и цикла этот итератор больше уже не используется.

Итераторы и массивы


Поскольку мы используем итераторы (хоть и не напрямую) в циклах for ... of, они могут выглядеть обманчиво похожими на массивы. Но есть два важных отличия. Итератор и массив используют концепции жадных и ленивых значений. Когда вы создаёте массив, то в любой конкретный момент времени у него есть определённая длина, а его значения уже инициализированы. Конечно, вы можете создать массив вообще без значений, но речь о другом. Я хочу сказать, что невозможно создать массив, инициализирующий свои значения только после того, как вы обратитесь к ним, записав array[someIndex]. Быть может, возможно обойти этого с помощью прокси или иных ухищрений, но по умолчанию массивы в JavaScript так себя не ведут.

И когда говорят, что у массива есть длина, имеют в виду, что эта длина конечна. В JavaScript не бывает бесконечных массивов.

Эти два качества указывают на жадность массивов.

А итераторы ленивые.

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

Начнём с бесконечного итератора. Звучит пугающе, но создать его очень просто: итератор начинается с 0 и на каждом этапе возвращает следующее число в последовательности. Вечно.

const counterIterator = {    integer: -1,    next() {        this.integer++;        return { value: this.integer, done: false };    },    [Symbol.iterator]() {        return this;    }}

И всё! Мы начали со свойства integer, равного -1. При каждом вызове next мы увеличиваем его на 1 и возвращаем в объекте в качестве value. Обратите внимание, что мы снова воспользовались вышеупомянутой хитростью: начали с -1, чтобы в первый раз вернуть 0.

Также взгляните на свойство done. Оно всегда будет false. Этот итератор не кончается!

Кроме того, мы сделали итератор итерируемым объектом, дав ему простую реализацию [Symbol.iterator]().

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

Теперь попробуем этот итератор в цикле for ... of. Нужно лишь не забыть остановить цикл в какой-то момент, иначе код будет исполняться вечно.

for (let element of counterIterator) {    if (element > 5) {        break;    }        console.log(element);}

После запуска мы увидим в консоли:

012345

Мы действительно создали бесконечный итератор, возвращающий столько чисел, сколько пожелаете. И сделать его было очень легко!

Теперь напишем итератор, который не создаёт значения, пока они не будут запрошены.

Ну мы уже его сделали!

Вы заметили, что в любой конкретный момент counterIterator хранит только одно число свойства integer? Это последнее число, возвращённое при вызове next. И это та самая ленивость. Итератор потенциально может вернуть любое число (точнее, положительное целое). Но он создаёт их только тогда, когда они нужны: при вызове метода next.

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

Чем больше объект (или чем дольше он создаётся), тем больше выгода.

Другие способы использования итераторов


Пока что мы потребляли итераторы только в цикле for ... of или вручную, с помощью метода next. Но это не единственные способы.

Мы уже видели, что конструктор Map принимает итерируемые объекты в качестве аргумента. Вы также можете с помощью метода Array.from легко преобразовать итерируемый объект в массив. Но будьте осторожны! Как я говорил, ленивость итератора иногда может быть большим преимуществом. Преобразование в массив лишает ленивости. Все значения, возвращаемые итератором, начинают инициализироваться немедленно, а затем помещаются в массив. Это означает, что если мы попробуем преобразовать бесконечный counterIterator в массив, то это приведёт к катастрофе. Array.from будет исполняться вечно без возвращения результата. Так что перед преобразованием итерируемого объекта/итератора в массив нужно убедиться в безопасности операции.

Любопытно, что итерируемые объекты также хорошо сочетаются со spread-оператором (...). Помните, что это работает аналогично Array.from, когда все значения итератора генерируются сразу. Например, с помощью spread-оператора можно создать свою версию Array.from. Просто применим оператор к итерируемому объекту, а затем положим значения в массив:

const arrayFromIterator = [...iterable];

Также можно получить из итерируемого объекта все значения и применить их к функции:

someFunction(...iterable);

Заключение


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

Перевод Углублённое руководство по JavaScript генераторы. Часть 1, основы

26.01.2021 14:17:08 | Автор: admin

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

Я не исхожу из того, что вы хоть что-то знаете о генераторах. Но вам требуется хорошо разбираться в итераторах и итерируемых объектах в JavaScript. Если вы с ними не знакомы или плаваете в теме, то сначала углублённо изучите их. Если же вы владеете этими знаниями, то можно погружаться в мир генераторов. Это очень странный мир, в котором многое совершенно не похоже на то, что вы используете в обычном JS-коде. При этом сам механизм очень прост, и даже после прочтения этой статьи вы сможете уверенно использовать генераторы. Приступим!

Мотивация


А зачем мне вообще учиться использовать генераторы? спросите вы. Очень честный вопрос. В самом деле, генераторы пока ещё довольно экзотическая фича, во многих кодовых базах они применяются редко. Но есть проблемы, которые с помощью генераторов решаются на удивление элегантно. В следующей статье я покажу подобный пример. И после того, как мы освоим генераторы, попробуем объединить их с React, чтобы получить код, который значительно превосходит тот, что основан на хуках. Надеюсь, это вдохновит вас на поиск своих сценариев применения генераторов.

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

Полагаю, в мире React самым популярным является пакет redux-saga, это промежуточное ПО для Redux, позволяющее писать код с побочными эффектами, который к тому же очень удобочитаем и прекрасно тестируется (а это редкость!).

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

Введение


Если бы мне пришлось объяснять суть генераторов одним предложением, я бы написал так: Это синтаксический сахар для создания итераторов. Конечно, такое описание и вовсе не охватывает природу и возможности генераторов. Но всё же близко к правде.

Давайте возьмём простую функцию, возвращающую число:

function getNumber() {    return 5;}

Если её типизировать с помощью TypeScript, то мы бы сказали, что она возвращает числовой тип:

function getNumber(): number {    return 5;}

Чтобы превратить функцию в генератор, после ключевого слова function нужно добавить знак *:

function* getNumber(): number {    return 5;}

Но если бы мы и правда делали это на TypeScript, то компилятор начал бы жаловаться, потому что функция-генератор возвращает не просто значение, которое было возвращено в её теле.

Она возвращает итератор!

Если мы изменим типизацию так:

function* getNumber(): Iterator<number> {    return 5;}

то компилятор TypeScript проглотит код без вопросов. Но это TypeScript. А теперь давайте посмотрим, вернёт ли function* итератор в чистом JavaScript. Например, применительно к тому, что вернул генератор, попробуем вызвать такой метод:

const probablyIterator = getNumber();console.log(probablyIterator.next());

Не только работает, но и выводит в консоль { value: 5, done: true }. На самом деле очень разумное поведение. В некотором смысле функция является итерабельной, возвращает всего одно значение и завершается.

А можно ли вернуть из генератора несколько значений? Вероятно, первым делом вы подумали о нескольких возвращениях:

function* getNumber() {    return 1;    return 2;    return 3;}

Для того, кто привык к обычным функциям, это выглядит богохульством. Но я предупреждал, что мы вошли в совершенно иной мир! Здесь всё возможно.

Однако этот вариант не работает. Выполним код:

const iterator = getNumber();console.log(iterator.next());console.log(iterator.next());console.log(iterator.next());

И получим результат:

{ value: 1, done: true }{ value: undefined, done: true }{ value: undefined, done: true }

Получили только первое значение, а затем итератор застрял в состоянии done. Любопытно, что мы можем лишь однократно обратиться к возвращённому значению, потому что последующие вызовы next возвращают лишь undefined.

И такое поведение тоже совершенно верное. Оно подчиняется основному правилу для всех функций: return всегда останавливает исполнение тела функции, даже если после return ещё есть какой-нибудь код. Это верно и для функций-генераторов.

Но всё же есть способ вернуть из нашего генератора несколько значений. Для этого предназначено ключевое слово yield:

function* getNumber() {    yield 1;    yield 2;    yield 3;}

Снова выполним код:

const iterator = getNumber();console.log(iterator.next());console.log(iterator.next());console.log(iterator.next());

Получилось!

{ value: 1, done: false }{ value: 2, done: false }{ value: 3, done: false }

То есть извлечение значений из генератора позволяет создать итератор, который возвращает несколько значений.

А что будет, если после этого ещё несколько раз вызвать next? Функция поведёт себя как обычный итератор, постоянно возвращая объект { value: undefined, done: true }.

Теперь обратите внимание, что последней строкой генератора тоже является yield. Изменится ли что-то, если поменять её на return?

function* getNumber() {    yield 1;    yield 2;    return 3; // note that we used a `return` here!}const iterator = getNumber();console.log(iterator.next());console.log(iterator.next());console.log(iterator.next());

Получаем:

{ value: 1, done: false }{ value: 2, done: false }{ value: 3, done: true }  // now done is true here!

Любопытно. Делает всё то же самое, но свойство done становится true на один шаг раньше. Быть может, вы помните, что свойство done возвращаемого объекта определяет, должен ли продолжаться цикл for ... of.

Посмотрим, как ведут себя обе версии генератора getNumber с циклами for ... of.

Сначала запустим версию с тремя извлечениями:

function* getNumber() {    yield 1;    yield 2;    yield 3;}const iterator = getNumber();for (let element of iterator) {    console.log(element);}

Получаем:

123

Так и должен себя вести итератор.

Теперь запустим генератор с двумя извлечениями и одним возвращением:

function* getNumber() {    yield 1;    yield 2;    return 3; // only this line changed}const iterator = getNumber();for (let element of iterator) {    console.log(element);}

Получаем:

12

Очень интересно. Если подумать, то именно так ведут себя итераторы с циклом for ... of. Свойство done решает, должен ли выполняться следующий этап итерации.

Посмотрите, как в статье об итерируемых объектах мы эмулировали цикл for ... of с while:

let result = iterator.next();while (!result.done) {    const element = result.value;    console.log(element);    result = iterator.next();}

В этом коде если вы при вызове iterator.next() получите объект { value: 3, done: true }, то число 3 тоже не появится в консоли. Причина в том, что перед вызовом console.log(element) идёт условие !result.done. А поскольку для объекта { value: 3, done: true } это условие имеет значение false, тело while не будет выполнено для числа 3.

И циклы for ... of работают точно так же.

То есть правило простое: хотите, чтобы появилось значение из цикла for ... of? Применяйте yield! Хотите вернуть значение из генератора, но не включать его в итерацию for ... of? Применяйте return!

Поток управления в генераторах


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

function* getNumber(beWeird) {    yield 1;    if(beWeird) {        yield -100;    } else {        yield 2;    }    yield 3;}

Вызов getNumber(false) создаст итератор, возвращающий числа 1, 2, 3. А вызов getNumber(true) создаст итератор, возвращающий числа 1, -100, 3.

Кроме того, в генераторах можно даже использовать циклы! Именно в этом проявляется их сила.

В статье об итерируемых объектах мы создали бесконечный итератор, который генерировал числа 0, 1, 2, 3, и вплоть до бесконечности. Это было не слишком сложно, но и код получился не самым удобочитаемым. Теперь же мы можем сделать генератор всего в несколько простых строк:

function* counterGenerator() {    let index = 0;    while(true) {        yield index;        index++;    }}

Сначала задаём index значение 0, а затем бесконечно исполняем цикл while(true). В нём мы извлекаем текущий index, а потом просто увеличиваем его на единицу. И следующим шагом извлекаем новое значение.

Изумительно просто, верно? Именно этот пример поразил меня, когда я начал изучать генераторы. Надеюсь, что вас он впечатлил не меньше.

Посмотрите, как далеко мы ушли: мы все привыкли к функциям, которые возвращают только одно значение, а теперь пишем функцию, которая возвращает практически вечно!

Отправка значений в генератор


Выше мы узнали, что можно с помощью генераторов создавать обычные итераторы. Но возвращаемый итератор выглядит странно. Он позволяет нам передавать значения обратно в генератор!

Расширим наш предыдущий пример:

function* getNumber() {    const first = yield 1;    const second = yield 2;    const third = yield 3;}

Здесь мы по прежнему просто извлекаем из генератора числа, а также присваиваем переменным то, что вычисляют их выражения yield <numbеr>. Очевидно, что сейчас эти переменные никак не используются. Ради иллюстрации мы будем их просто журналировать, но вы можете делать с ними что угодно.

Добавим в начало функции дополнительный журнал:

function* getNumber() {    console.log('start');    const first = yield 1;    console.log(first);    const second = yield 2;    console.log(second);    const third = yield 3;    console.log(third);}

А затем будем исполнять этот генератор несколько раз. Рекомендую скопировать этот код куда-нибудь или открыть статью в другой вкладке. Вам будет гораздо проще понять, что происходит, если по мере запуска примеров вы сможете как можно чаще возвращаться к этому генератору!

Запустим новый генератор:

for (let element of getNumber()) {    console.log(element);}What we get is:start1undefined2undefined3undefined

Надеюсь, вам понятно, какой журнал относится к генератору, а какой к циклу for ... of. Вот ответы:

start          <- generator1              <- loopundefined      <- generator2              <- loopundefined      <- generator3              <- loopundefined      <- generator

Очевидно, что результатами выражений yield <numbеr> являются просто undefined. Но можно это изменить! Для этого придётся убрать цикл for ... of и использовать итератор вручную.

Вызовем четыре раза метод next из итератора, чтобы получить три числа и последний объект с переменной done в значении true. И будем журналировать все результаты вызовов next.

const iterator = getNumber();console.log(iterator.next());console.log(iterator.next());console.log(iterator.next());console.log(iterator.next());

После выполнения этого кода (с тем же генератором) мы получим:

start{ value: 1, done: false }undefined{ value: 2, done: false }undefined{ value: 3, done: false }undefined{ value: undefined, done: true }

Здесь мало что поменялось: значения undefined никуда не делись. Мы лишь заменили числа из цикла for ... of на журналирование всех объектов из вызовов next.

Генераторы разумно используют гибкость интерфейса итератора. Ведь у него должен быть метод next, возвращающий объект вида { done, value }. Но никто не говорил, что этот метод не может принимать какие-нибудь аргументы! Он будет по прежнему удовлетворять интерфейсу, пока возвращает объект ожидаемого вида!

Давайте передадим несколько строк в вызовы next:

const iterator = getNumber();console.log(iterator.next('a'));console.log(iterator.next('b'));console.log(iterator.next('c'));console.log(iterator.next('d'));

После исполнения мы видим в консоли ещё что-то кроме undefined:

start{ value: 1, done: false }b                                <- no more undefined{ value: 2, done: false }c                                <- no more undefined{ value: 3, done: false }d                                <- no more undefined{ value: undefined, done: true }

Возможно, результат вас удивил. Ведь первой переданной в next буквой была a, а здесь мы видим только b, c и d. Но если разобрать выполнение пошагово, то всё станет понятно.

Вызов next заставляет генератор выполняться, пока он не дойдёт до вызова yield <some vаlue>. Тогда будет возвращена часть <sоme value> из вызова next (в качестве значения объекта { value, done }). С этого момента генератор просто ждёт следующего вызова next. Значение, переданное в этот другой вызов next, станет тем значением, которое вычислит выражение yield <sоmething>.

Разберём всё по шагам.

Когда вы в первый раз вызвали next, он просто начал исполнять функцию-генератор. В нашем случае это означает, что будет исполнено console.log('start').

const iterator = getNumber();iterator.next('a');results in the following:start

В генераторе после console.log('start') мы доходим до выражения yield 1. Число 1 будет возвращено из первого вызова next, который мы только что сделали. Чтобы проверить это, можете обернуть вызов next в console.log:

const iterator = getNumber();console.log(iterator.next('a'));

Вот что мы получили:

start{ value: 1, done: false }

Как раз единицу мы и извлекли из генератора.

Сейчас генератор приостановлен. Даже выражение, в котором мы дошли до yield const first = yield 1; не было выполнено целиком. Ведь генератор пока не знает, какое значение должно быть у yield 1. Дадим ему это значение с помощью следующего вызова next:

const iterator = getNumber();console.log(iterator.next('a'));iterator.next('b');

Получили:

start{ value: 1, done: false }b

То есть генератор продолжил исполнение и заменил yield 1 на значение, которое мы передали в вызов next строку b.

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

const iterator = getNumber();console.log(iterator.next('a'));iterator.next('this is some other string, which we created for tutorial purposes');

Это даст такой результат (надеюсь, теперь вам понятно, почему):

start{ value: 1, done: false }this is some other string, which we created for tutorial purposes

Именно вы здесь решаете, что должно вычислить выражение yield 1.

Наше первое выражение yield использует значение, предоставленное во втором вызове next. Это крайне важно для понимания работы генераторов.

Дойдя до yield <sоme value>, генератор говорит: я верну <sоme value> в текущем вызове next, а в следующем вызове next дай мне в качестве аргумента то, что я должен заменить на yield <sоme value>. И это означает, что переданный в первый вызов next аргумент никогда не будет использован генератором. Его просто некуда предоставить, так что уберём его из примера:

const iterator = getNumber();console.log(iterator.next()); // no need to pass anything on the first `next` calliterator.next('b');

После второго вызова next генератор продолжил исполнение кода, пока не дошёл до другого выражения yield yield 2. Поэтому число 2 возвращено в качестве значения из этого вызова next.

То есть этот код:

const iterator = getNumber();console.log(iterator.next());console.log(iterator.next('b'));

выводит:

start{ value: 1, done: false }b{ value: 2, done: false }

Что тут происходит? Генератор не знает, какое значение нужно получить при вычислении yield 2 в выражении const second = yield 2;. Поэтому он просто ждёт, пока вы не передадите новое значение в вызов next:

const iterator = getNumber();console.log(iterator.next());console.log(iterator.next('b'));iterator.next('c');

Теперь мы получили:

start{ value: 1, done: false }b{ value: 2, done: false }c

То есть после третьего вызова next код генератора возобновляет исполнение, пока не доходит до yield 3. И число 3 возвращается в качестве значения из этого вызова:

const iterator = getNumber();console.log(iterator.next());console.log(iterator.next('b'));console.log(iterator.next('c')); // we've added console.log here

Получили:

start{ value: 1, done: false }b{ value: 2, done: false }c{ value: 3, done: false }

Теперь генератор приостановлен на выражении const third = yield 3;. Мы знаем, как снова его запустить:

const iterator = getNumber();console.log(iterator.next());console.log(iterator.next('b'));console.log(iterator.next('c'));iterator.next('d'); // we've added another next call

Получили:

start{ value: 1, done: false }b{ value: 2, done: false }c{ value: 3, done: false }d

И поскольку генератор не содержит других выражений yield, то и не возвращает других значений. Он выполняется вплоть до своего завершения. Поэтому последний объект { done, value } из вызова next не содержит значения и уведомляет о завершении итератора.

Этот код:

const iterator = getNumber();console.log(iterator.next());console.log(iterator.next('b'));console.log(iterator.next('c'));console.log(iterator.next('d')); // we've added console.log here

выводит:

start{ value: 1, done: false }b{ value: 2, done: false }c{ value: 3, done: false }d{ value: undefined, done: true }

И всё! Если вы запутались, то прогоните примеры самостоятельно. Можете помочь себе, пошагово добавляя успешные вызовы next и console.log. Постарайтесь также всегда контролировать, в какой строке генератора вы сейчас находитесь. Помните! Нужно разбирать генератор пошагово, чтобы точно разобраться в ситуации! Не ограничивайтесь чтением статьи, прогоните пример самостоятельно столько раз, сколько потребуется для полного понимания происходящего!

Заключение


Мы изучили основы работы генераторов. Узнали, как их создавать, как использовать ключевое слово yield и генераторы.

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

Перевод Углублённое руководство по JavaScript генераторы. Часть 2, простой пример использования

27.01.2021 20:23:33 | Автор: admin

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

Пусть у нас есть такая функция:

function maybeAddNumbers() {    const a = maybeGetNumberA();    const b = maybeGetNumberB();    return a + b;}

Функции maybeGetNumberA и maybeGetNumberB возвращают числа, но иногда могут вернуть null или undefined. Об этом говорит слово maybe в их названиях. Если такое происходит, не нужно пытаться складывать эти значения (например, число и null), лучше сразу остановиться и вернуть, скажем, null. Именно null, а не какое-нибудь непредсказуемое значение, получившиеся при сложении null/undefined с числом или другим null/undefined.

Так что нужно проверять, что числа действительно определены:

function maybeAddNumbers() {    const a = maybeGetNumberA();    const b = maybeGetNumberB();    if (a === null || a === undefined || b === null || b === undefined) {        return null;    }    return a + b;}

Всё работает, но если a является null или undefined, то нет смысла вызывать функцию maybeGetNumberB. Мы же знаем, что в любом случае будет возвращён null.

Перепишем функцию:

function maybeAddNumbers() {    const a = maybeGetNumberA();    if (a === null || a === undefined) {        return null;    }    const b = maybeGetNumberB();    if (b === null || b === undefined) {        return null;    }    return a + b;}

Так. Вместо трёх простых строк кода мы быстро раздули до 10 строк (не считая пустых). И в функции теперь применяются if, через которые нужно продраться, чтобы понять, что делает функция. А это лишь учебный пример! Представьте настоящую кодовую базу с гораздо более сложной логикой, в которой такие проверки станут ещё сложнее. Вот бы применить тут генераторы и упростить код.

Взгляните:

function* maybeAddNumbers() {    const a = yield maybeGetNumberA();    const b = yield maybeGetNumberB();    return a + b;}

Что если бы мы могли позволить выражению yield <sоmething> проверять, является ли <sоmething> настоящим значением, а не null или undefined? Если оно окажется не числом, то мы просто остановимся и вернём null, как и в предыдущей версии кода.

То есть можно написать код, который выглядит так, словно он работает только с настоящими, определёнными значениями. Проверять это и выполнять соответствующие действия может для вас генератор! Волшебство, верно? И это не только возможно, но ещё и легко написать!

Конечно, у самих генераторов нет такой функциональности. Они просто возвращают итераторы, и при желании вы можете вставлять обратно в генераторы какие-нибудь значения. Так что нам нужно написать обёртку, пусть это будет runMaybe.

Вместо прямого вызова функции:

const result = maybeAddNumbers();

будем вызывать её в качестве аргумента обёртки:

const result = runMaybe(maybeAddNumbers());

Это шаблон встречается в генераторах очень часто. Сами по себе они мало что умеют, но с помощью самописных обёрток вы можете придавать генераторам нужное поведение! Именно это нам сейчас нужно.

runMaybe функция, принимающая один аргумент: итератор, созданный генератором:

function runMaybe(iterator) {}

Запустим этот итератор в цикле while. Для этого нужно вызвать итератор в первый раз и запустить проверку его свойства done:

function runMaybe(iterator) {    let result = iterator.next();    while(!result.done) {    }}

Внутри цикла у нас есть две возможности. Если result.value является null или undefined, то нужно немедленно остановить итерацию и вернуть null. Так и сделаем:

function runMaybe(iterator) {    let result = iterator.next();    while(!result.done) {        if (result.value === null || result.value === undefined) {            return null;        }    }}

Здесь мы с помощью return сразу же останавливаем итерацию и возвращаем из обёртки null. Но если result.value является числом, то нужно вернуть в генератор. Например, если в yield maybeGetNumberA()функция maybeGetNumberA() является числом, то нужно заменить yield maybeGetNumberA() значением этого числа. Поясню: допустим результатом вычисления maybeGetNumberA() будет 5, тогда мы заменим const a = yield maybeGetNumberA(); на const a = 5;. Как видите, нам не нужно менять извлечённое значение, достаточно передать его обратно в генератор.

Мы помним, что можно заменить yield <sоmething> каким-нибудь значением, передав его в качестве аргумента методу next в итераторе:

function runMaybe(iterator) {    let result = iterator.next();    while(!result.done) {        if (result.value === null || result.value === undefined) {            return null;        }        // we are passing result.value back        // to the generator        result = iterator.next(result.value)    }}

Как видите, новый результат теперь снова сохраняется в переменной result. Это возможно потому, что мы специально объявили result с помощью let.

Теперь если при извлечении значения генератор обнаруживает null/undefined, мы просто возвращаем null из обёртки runMaybe.

Осталось добавить что-нибудь ещё, чтобы процесс итерации завершался без обнаружения null/undefined. Ведь если мы получим два числа, то нужно вернуть из обёртки их сумму!

Генератор maybeAddNumbers завершается выражением return. Мы понимаем, что наличие return <sоmething> в генераторе заставляет его возвращать из вызова next объект { value: <sоmething>, done: true }. Когда это случается, цикл while останавливается, потому что свойство done получает значение true. Но последнее возвращённое значение (в нашем конкретном случае это a + b) всё ещё будет храниться в свойстве result.value! И мы сможем просто вернуть его:

function runMaybe(iterator) {    let result = iterator.next();    while(!result.done) {        if (result.value === null || result.value === undefined) {            return null;        }        result = iterator.next(result.value)    }    // just return the last value    // after the iterator is done    return result.value;}

И это всё!

Создадим функции maybeGetNumberA и maybeGetNumberB, и пусть они возвращают сначала настоящие числа:

const maybeGetNumberA = () => 5;const maybeGetNumberB = () => 10;

Запустим код и журналируем результат:

function* maybeAddNumbers() {    const a = yield maybeGetNumberA();    const b = yield maybeGetNumberB();    return a + b;}const result = runMaybe(maybeAddNumbers());console.log(result);

Как и ожидалось, в консоли появится число 15.

Теперь заменим одно из слагаемых на null:

const maybeGetNumberA = () => null;const maybeGetNumberB = () => 10;

При выполнении кода получим null!

Однако нам важно убедиться, что функция maybeGetNumberB не вызывается, если maybeGetNumberA возвращает null/undefined. Давайте снова проверим успешность вычисления. Для этого просто добавим во вторую функцию console.log:

const maybeGetNumberA = () => null;const maybeGetNumberB = () => {    console.log('B');    return 10;}

Если мы верно написали обёртку runMaybe, то при выполнении этого кода буква B не появится в консоли.

И действительно, при выполнении кода мы увидим просто null. Это означает, что обёртка действительно останавливает генератор, как только обнаруживает null/undefined.

Код работает, как задумано: выдаёт null при любой комбинации:

const maybeGetNumberA = () => undefined;const maybeGetNumberB = () => 10;const maybeGetNumberA = () => 5;const maybeGetNumberB = () => null;const maybeGetNumberA = () => undefined;const maybeGetNumberB = () => null;

И так далее.

Но польза этого примера кроется не в исполнении этого конкретного кода. Она кроется в факте, что мы создали универсальную обёртку, которая может работать с любым генератором, способным извлекать значения null/undefined.

Напишем более сложную функцию:

function* maybeAddFiveNumbers() {    const a = yield maybeGetNumberA();    const b = yield maybeGetNumberB();    const c = yield maybeGetNumberC();    const d = yield maybeGetNumberD();    const e = yield maybeGetNumberE();        return a + b + c + d + e;}

Можно безо всяких проблем выполнить его в нашей обёртке runMaybe! По сути, обёртке даже не важно, что наши функции возвращают числа. Ведь мы в ней не упоминали числовой тип. Так что вы можете использовать в генераторе любые значения числа, строки, объекты, массивы, более сложные структуры данных, и он будет работать с нашей обёрткой!

Именно это вдохновляет разработчиков. Генераторы позволяют добавлять в код свою функциональность, которая выглядит очень обычной (конечно, не считая вызовов yield). Нужно лишь создать обёртку, которая особым образом итерирует генератор. Таким образом обёртка добавляет генератору нужную функциональность, которая может быть любо! Генераторы обладают практически безграничными возможностями, всё дело лишь в нашем воображении.
Подробнее..

Устройство CPython. Доклад Яндекса

22.07.2020 10:16:05 | Автор: admin
Мы публикуем конспект вступительной лекции видеокурса Бэкенд-разработка на Python. В ней Егор Овчаренко egorovcharenko, тимлид в Яндекс.Такси, рассказал о внутреннем устройстве интерпретатора CPython.


Если кратко, какой у нас будет план? Сначала мы поговорим о том, почему будем изучать именно Python. Затем посмотрим, как работает интерпретатор CPython более глубоко, как он управляет памятью, как устроена система типов в Python, на словари, генераторы и исключения. Я думаю, это займет примерно час.


Почему Python?



* insights.stackoverflow.com/survey/2019
** очень субъективно
*** интерпретация исследования
**** интерпретация исследования


Давайте начнем. Почему Python? На слайде есть сравнение нескольких языков, которые сейчас используются в бэкенд-разработке. Но если кратко, в чем преимущество Python? На нем можно быстро писать код. Это, конечно, очень субъективно люди, которые круто пишут на C++ или Go, могут с этим поспорить. Но в среднем писать на Python быстрее.

В чем минусы? Первый и, наверное, основной минус Python медленнее. Он может быть медленнее других языков в 30 раз, вот исследование на эту тему. Но его скорость зависит от задачи. Есть два класса задач:

CPU bound, задачи, зависящие от процессора, ограниченные по CPU.

I/O bound, задачи, ограниченные вводом-выводом: или по сети, или в базах данных.

Если вы решаете задачу CPU bound, то да, Python окажется медленнее. Если I/O bound, а это большой класс задач, то для понимания скорости выполнения вам надо запускать бенчмарки. И, возможно, сравнивая Python с другими языками, вы даже не заметите разницы в производительности.

Кроме того, Python обладает динамической типизацией: интерпретатор в момент компиляции не проверяет типы. В версии 3.5 появились type hints, позволяющие статически указывать типы, но они не очень строгие. То есть некоторые ошибки вы будете отлавливать уже в продакшене, а не на этапе компиляции. У других популярных языков для бэкенда Java, C#, C++, Go типизация статическая: если вы в коде передаете не тот объект, который нужно, компилятор вам об этом сообщит.

Если чуть более приземленно, как используется Python в продуктовой разработке Такси? Мы движемся в сторону микросервисной архитектуры. У нас уже 160 микросервисов, именно продуктовых 35, 15 из них на Python, 20 на плюсах. То есть мы сейчас пишем или только на Python, или на плюсах.

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

Сейчас хочется поговорить про интерпретатор. Как работает CPython?

Устройство интерпретатора


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

1. Оптимизация под высокую нагрузку. Представьте, что у вас есть сервис на Python. Он работает, нагрузка невысокая. Но однажды вам приходит задача написать ручку, готовую к высокой нагрузке. От этого не уйти, не переписывать же весь сервис на C++. Итак, вам нужно оптимизировать сервис под высокую нагрузку. Понимание того, как работает интерпретатор, может в этом помочь.

2. Отладка сложных случаев. Допустим, сервис работает, но в нем начинает утекать память. У нас в Яндекс.Такси такой случай был буквально недавно. Каждый час сервис выедал 8 ГБ памяти и падал. Надо разбираться. Дело в языке, в Python. Требуется знание, как работает управление памятью в Python.

3. Это пригодится, если вы будете писать сложные библиотеки или сложный код.

4. И вообще считается хорошим тоном знать инструмент, с которым вы работаете, на более глубоком уровне, а не просто как пользователь. В Яндексе это ценится.

5. Про это задают вопросы на собеседованиях, но дело даже не в этом, а в вашем общем IT-кругозоре.



Давайте кратко вспомним, какие бывают виды трансляторов. У нас бывают компиляторы, а бывают интерпретаторы. Компилятор, как вы, наверное, знаете, это штука, которая переводит ваш исходный код сразу в машинный. Интерпретатор скорее переводит сначала в байт-код, а потом уже его исполняет. Python интерпретируемый язык.

Байт-код некий промежуточный код, который получается из исходного. Он не привязан к платформе и выполняется на некой виртуальной машине. Почему виртуальная? Это не настоящая машина, а некая абстракция.



Каких типов бывают виртуальные машины? Регистровые и стековые. Но здесь надо запомнить не это, а то, что Python стековая машина. Дальше мы посмотрим, как работает стек.

И еще одна оговорка: здесь мы будем говорить только о CPython. CPython референсная имплементация Python, написанная, как можно догадаться, на C. Используется как синоним: когда мы говорим о Python, мы обычно говорим о CPython.

Но также есть другие интерпретаторы. Есть PyPy, который использует JIT-компиляцию и ускоряет где-то в пять раз. Используется редко. Я, честно говоря, не встречал. Есть JPython, есть IronPython, который переводит байт-код для Java Virtual Machine и для дотнетовской машины. Это out of scope сегодняшней лекции честно говоря, я с ним не сталкивался. Поэтому давайте посмотрим на CPython.



Посмотрим, что происходит. У вас есть исходник, строчка, вы хотите ее выполнить. Что делает интерпретатор? Строка это просто набор символов. Чтобы сделать с ним нечто осмысленное, сначала код переводится в лексемы. Лексема некий сгруппированный набор символов, идентификатор, число или какая-то итерация. Собственно, интерпретатор переводит код в лексемы.



Дальше из этих лексем строится Abstract Syntax Tree, AST. Тоже пока не заморачивайтесь, это просто некие деревья, в узлах которых у вас операции. Допустим, в нашем случае есть BinOp, бинарная операция. Операция возведение в степень, операнды: число, которое возводим, и степень, в которую возводим.

Дальше по этим деревьям уже можно строить какой-то код. Я пропускаю много шагов, есть шаг оптимизации, другие шаги. Потом эти синтаксические деревья переводятся уже в байт-код.

Здесь посмотрим поподробнее. Байт-код это, как говорит нам это название, код, состоящий из байтов. А в Python начиная с 3.6 байт-код это два байта.



Первый байт это сам оператор, называется opcode. Второй байт это аргумент oparg. Он выглядит как у нас сверху. То есть какая-то последовательность байт. Но в Python есть модуль dis, от слова Disassembler, с помощью которого мы можем посмотреть более человекочитаемое представление.

Как оно выглядит? Есть номер строчки исходника самая левая единичка. Вторая колонка это адрес. Как я говорил, байт-код в Python 3.6 занимает два байта, поэтому у нас все адреса четные и мы видим 0, 2, 4

Load.name, Load.const это уже сами опции кода, то есть коды тех операций, которые Python должен выполнить. 0, 0, 1, 1 это oparg, то есть аргументы этих операций. Дальше посмотрим, как они выполняются.

(...) Давайте посмотрим, как в Python происходит выполнение байт-кода, какие для этого есть структуры.



Если не знаете С, не страшно. Сноски даны для общего понимания.

В Python есть две структуры, которые нам помогают выполнять байт-код. Первая CodeObject, вы видите ее краткое содержание. На самом деле структура больше. Это код без контекста. Значит, эта структура содержит, собственно, байт-код, который мы только что видели. Она содержит в себе названия переменных, использующиеся в этой функции, если функция содержит ссылки на константы, названия констант, что-то еще.



Следующая структура FrameObject. Это уже контекст выполнения, та структура, которая уже содержит в себе значение переменных; ссылки на глобальные переменные; стек выполнения, о котором поговорим чуть позже, и много другой информации. Допустим, номер выполнения инструкции.

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



Что такое основной цикл интерпретатора, как выполняется байт-код? Вы видели, у нас был список этих opcode с oparg. Как это все выполняется? В Python, как в любом интерпретаторе, есть цикл, который выполняет этот байт-код. То есть на вход в него поступает фрейм, и Python просто по порядку идет по байт-коду, смотрит, что это за oparg, и переходит к его обработчику с помощью огромного switch. Здесь для примера приведен только один opcode. Для примера, у нас здесь есть binary subtract, бинарное вычитание, допустим, A-B у нас выполнится в этом месте.

Давайте расскажу, как работает binary subtract. Очень просто, это один из самых простых кодов. Функция TOP берет из стека самое верхнее значение, берет тоже с самого верхнего, не просто удаляет его из стека, и потом вызывается функция PyNumber_Subtract. Результат: слэш функция SET_TOP помещается обратно на стек. Если про стек не понятно, дальше будет пример.



Очень кратко о GIL. GIL это мьютекс, который есть в Python на уровне процесса и который в основной цикл интерпретатора делает take этого мьютекса. И только после этого начинает выполнять байт-код. Это сделано для того, чтобы в один момент времени только один поток выполнял байт-код, чтобы защитить внутреннее устройство интерпретатора.

Допустим, забегая чуть дальше, что у всех объектов в Python есть количество ссылок на них. И если два потока будут менять это количество ссылок, то интерпретатор сломается. Поэтому есть GIL.

Вам об этом будут рассказывать в лекции по асинхронному программированию. Как это для вас может быть важно? Многопоточность не используется, потому что даже если вы сделаете несколько потоков, то в общем случае у вас будет выполняться только один из них, байт-код будет выполняться в одном из потоков. Поэтому либо используйте мультипроцессность, либо сишное расширение, либо что-то еще.



Краткий пример. Вы можете спокойно исследовать этот фрейм из Python. Есть модуль sys, у которого есть функция подчеркивания get_frame. Вы можете получить фрейм и посмотреть, какие переменные есть. Есть инструкция. Это больше для обучения, в реальной жизни я его не применял.

Давайте для понимания попробуем посмотреть, как работает стек виртуальной машины Python. У нас есть некий код, довольно простой, который непонятно что делает.



Слева код. Желтым выделена часть, которую мы сейчас рассматриваем. Во второй колонке у нас байт-код этого кусочка. В третьей колонке фреймы со стеками. То есть стек выполнения у каждого FrameObject свой.

Что делает Python? Идет просто по порядку, по байт-коду, по средней колонке, выполняет и работает со стеком.



У нас выполнился первый opcode, который называется LOAD_CONST. Он загружает константу. Мы пропустили часть, там создается CodeObject, и у нас где-то в константах был некий CodeObject. Python загрузил его на стек с помощью LOAD_CONST. У нас теперь на стеке в этом фрейме есть объект CodeObject. Можем идти дальше.



Потом Python выполняет opcode MAKE_FUNCTION. MAKE_FUNCTION, очевидно, делает функцию. Он ожидает, что на стеке у вас был CodeObject. Он производит некие действия, создает функцию и кладет функцию обратно на стек. Теперь у вас FUNCTION вместо CodeObject, который был на стеке фрейма. И теперь эту функцию нужно поместить в перемененную to_power, чтобы вы могли к ней обращаться.



Выполняется opcode STORE_NAME, он помещается в переменную to_power. На стеке у нас была функция, теперь это переменная to_power, вы можете к ней обращаться.

Дальше мы хотим напечатать 10 + значение этой функции.



Что делает Python? Это преобразовалось в байт-код. Первый opcode у нас LOAD_CONST. Мы загружаем десятку на стек. На стеке появилась десятка. Теперь надо выполнить to_power.



Функция выполняется следующим образом. Если она с позиционными аргументами остальное пока не будем смотреть, то сначала Python кладет саму функцию на стек. Потом он кладет все аргументы и вызывает CALL_FUNCTION с аргументом количества аргументов функции.



Загрузили на стек первый аргумент, это функция.



Загрузили на стек еще два аргумента 30 и 2. Теперь у нас на стеке функция и два аргумента. Верхушка стека у нас сверху. CALL_FUNCTION у нас ожидает. Мы говорим: CALL_FUNCTION (2), то есть у нас функция с двумя аргументами. CALL_FUNCTION ожидает, что у нас на стеке будет два аргумента, после них функция. У нас так и есть: 2, 30 и FUNCTION.

Выполняется opcode.



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

Стек у фрейма свой. Создался новый фрейм под свою функцию. Он пока пустой.



Дальше происходит выполнение. Тут уже попроще. Нам нужно возвести A в степень power. Мы загружаем на стек значение переменной A 30. Значение переменной power 2.



И выполняется opcode BINARY_POWER.



У нас возводится одно число в степень другого и кладется на стек обратно. Получилось 900 на стеке функции.

Следующий opcode RETURN_VALUE возвратит значение со стека в предыдущий фрейм.



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



Дальше все примерно так же. Происходит сложение.



(...) Давайте поговорим про типы и PyObject.

Типизация




Объект сишная структура, в которой есть два основных поля: первое количество ссылок на этот объект, второе тип объекта, естественно, ссылка на тип объекта.

Другие объекты наследуются от PyObject путем включения его в себя. То есть если мы посмотрим на float, число с плавающей точкой, структурка там PyFloatObject, то у него есть HEAD, который является структурой PyObject, и, дополнительно, данные, то есть double ob_fval, где хранится значение самого этого float.



А это уже тип объекта. Мы только что смотрели в PyObject тип, это структура, которая обозначает тип. По сути это тоже сишная структура, которая содержит в себе указатели на функции, реализующие поведение этого объекта. То есть там очень большая структура. У нее указаны функции, которые вызываются, если вы, допустим, хотите сложить два объекта этого типа. Или хотите вычесть, вызвать этот объект или создать его. Все, что вы можете сделать с типами, должно быть указано в этой структуре.



Для примера посмотрим на int, целые числа в Python. Тоже очень сокращенная версия. Что нам может быть интересно? У int есть tp_name. Видно, что есть tp_hash, мы можем получить hash int. Если мы вызовем hash от int, вызовется эта функция. tp_call у нас ноль, не определен, это значит, что мы не можем вызвать int. tp_str приведение к строке не определено. В Python есть функция str, которая может привести к строке.

На слайд это не попало, но вы все уже знаете, что int все-таки можно напечатать. Почему здесь ноль? Потому что есть еще tp_repr, в Python две функции проведения строки: str и repr. Более подробное приведение к строке. Оно на самом деле определено, просто на слайд не попало, и вызовется оно, если вы, собственно, будете приводить к строке.

В самом конце мы видим tp_new функцию, которая вызывается при создании этого объекта. tp_init у нас ноль. Все мы знаем, что int не изменяемый тип, immutable. После создания его изменять, инициализировать смысла нет, поэтому там нолик.



Для примера также посмотрим на Bool. Как кто-то, может быть, из вас знает, Bool в Python на самом деле наследуется от int. То есть вы можете Bool складывать, делить друг с другом. Этого делать, конечно, нельзя, но можно.

Мы видим, что есть tp_base указатель на базовый объект. Все помимо tp_base единственные вещи, которые были переопределены. То есть у него свое имя, своя функция представления, где как раз пишется не число, а true или false. Представление в виде Number, там переопределяются некоторые логические функции. Docstring своя и создание свое. Все остальное идет от int.



Очень кратко расскажу про списки. В Python список это динамический массив. А динамический массив это массив, который работает так: вы инициализируете область памяти заранее с какой-то размерностью. Туда добавляете элементы. Как только количество элементов превысит этот размер, вы его расширяете с неким запасом, то есть не на единичку, а на какое-то значение больше единички, чтобы была хорошая asin-точка.

В Python размер растет как 0, 4, 8, 16, 25, то есть по какой-то формуле, которая позволяет нам вставку сделать ассимптотически за константу. И можно посмотреть, есть выдержка из сишной функции вставки в список. То есть мы делаем resize. Если у нас не resize, мы выкидываем ошибку и присваиваем элемент. В Python это обычный динамический массив, реализованный на C.

(...) Давайте кратко поговорим про словари. Они в Python везде.

Словари


Мы все знаем, в объектах весь состав классов содержимтся в словарях. Очень многие вещи на них основаны. Словари в Python в хеш-таблице.



Если кратко, как работает хеш-таблица? Есть некие ключи, guido, timmy, barry. Мы хотим их положить в словарь, прогоняем каждый ключ через хеш-функцию. Получается хеш. Мы по этому хешу находим бакет. Бакет это просто номер в массиве элементов. Происходит конечное деление по модулю. Мы кладем этот элемент в этот бакет. Если бакет пустой, мы кладем туда элемент. Если бакет не пустой и там уже есть некий элемент, это коллизия и у нас выбирается следующий бакет, смотрится, свободный он или нет. И так до тех пор, пока мы не найдем свободный bucket.

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

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


Ссылка со слайда

Поэтому с версии 3.6 в Python сделали такую штуку. Слева можно посмотреть, как было раньше. У нас есть разреженный массив, где хранятся эти три элемента. С 3.6 они решили сделать такой разреженный массив обычным массивом, но при этом хранить индексы элементов бакета в отдельном массиве indices.

Если мы посмотрим массив индексов, то в первом бакете у нас None, во втором лежит элемент с индексом 1 из этого массива и т. д.

Это позволило, во-первых, сократить использование памяти, во-вторых, мы еще бесплатно получили из коробки упорядоченный массив. То есть мы добавляем элементы в этот массив, условно, обычным сишным append, и массив автоматически упорядочен.

Есть интересная оптимизация, которую Python использует. Чтобы работали эти хеш-таблицы, нам нужно иметь операцию сравнения элементов. Представьте, мы поместили в хеш-таблицу элемент, а потом хотим взять элемент. Берем хеш, идем в бакет. Видим: бакет полный, там что-то есть. Но тот ли это элемент, который нам нужен? Может быть, когда он помещался, возникла коллизия и элемент на самом деле поместился в другой bucket. Поэтому мы должны сравнивать ключи. Если ключ не тот, мы применяем тот же механизм поиска следующего бакета, который используется при разрешении коллизий. И идем дальше.


Ссылка со слайда

Поэтому нам нужно иметь функцию сравнения ключей. В общем случае функция сравнения объектов может быть очень дорогой. Поэтому используется такая оптимизация. Сначала мы сравниваем айдишники элементов. ID в CPython это, как вы знаете, положение в памяти.

Если айдишники совпадают, значит, это одинаковые объекты и, естественно, они равны. Тогда мы возвращаем True. Если нет, смотрим хеши. Хеш должен быть довольно быстрой операцией, если мы не переопределили как-то. Мы берем хеши от этих двух объектов, сравниваем. Если их хеши не равны, то объекты точно не равны, поэтому мы возвращаем False.

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

Небольшая интересная штука: нельзя ничего вставлять в ключи во время итерации. Это ошибка.



Под капотом в словаре есть такая переменная version, в которой хранится версия словаря. Когда вы изменяете словарь, version меняется, Python это понимает и кидает вам ошибку.



Для чего словари могут использоваться на более практическом примере? У нас в Такси есть заказы, а у заказов статусы, которые могут меняться. При изменении статуса вы должны выполнять некие действия: отправить смски, записывать заказы.

Эта логика у нас написана на Python. Чтобы не писать огромный if вида if статус заказа такой-то, сделай то-то, есть некий dict, в котором ключ это статус заказа. А к VALUE есть tuple, в котором содержатся все обработчики, которые надо выполнить при переходе в данный статус. Это распространенная практика, фактически замена сишного switch.



Еще несколько вещей по типам. Расскажу про immutable. Это неизменяемые типы данных, а mutable соответственно, изменяемые типы: дикты, классы, инстансы классов, листы и, может, что-то еще. Практически все остальное строки, обычные числа они immutable. Для чего нужны mutable-типы? Первое: они позволяют проще понимать код. То есть если вы в коде видите, что что-то tuple, вы понимаете, что дальше он не изменяется, и вам это позволяет проще читать код? понимать, что будет дальше. В tuple ds не можете набрать элементы. Вы это будете понимать, и это поможет при чтении вам и всем людям, которые будут читать код за вами.

Поэтому есть правило: если вы что-то не будете менять, лучше используйте неизменяемые типы. Также это приводит к ускорению работы. Есть две константы, которые как раз использует tuple: pit_tuple, tap_tuple, max и СС. В чем смысл? Для всех tuple размера до 20 используется определенный способ выделения памяти, который ускоряет это выделение. И таких объектов каждого типа может быть до двух тысяч, очень много. Это гораздо быстрее, чем листы, поэтому если будете использовать tuple, у вас все будет быстрее.

Также есть проверки во время выполнения. Очевидно, если вы пытаетесь в объект что-то запиндить, а он не поддерживает эту функцию, то будет ошибка, некое понимание того, что вы сделали что-то не то. Ключами в дикте могут быть только объекты, у которых есть хеш, которые не изменяются во время жизни. Этому определению удовлетворяют только immutable-объекты. Только они могут быть ключами дикта.



Как это выглядит в C? Пример. Слева tuple, справа обычный list. Тут, естественно, видны не все отличия, а только те, которые хотел показать. В list в поле tp_hash у нас NotImplemented, то есть хеша у list нет. В tuple некая функция, которая вам действительно вернет хеш. Это как раз то, почему tuple в том числе может быть ключом дикта, а list не может.

Следующее, что выделено, это функция присваивания элемента, sq_ass_item. В list она есть, в tuple нолик, то есть вы в tuple, естественно, ничего не можете присвоить.



Еще одна вещь. Python ничего не копирует, пока мы его не попросим. Об этом тоже надо помнить. Если вы что-то хотите скопировать, используйте, допустим, модуль copy, у которого есть функция copy.deepcopy. В чем отличие? copy копирует объект, если это контейнерный объект, типа списка на одном уровне. Все ссылки, которые были в этом объекте, вставляются в новый объект. А deepcopy рекурсивно копирует все объекты внутри этого контейнера и дальше.

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

(...) Дальше поговорим про менеджмент памяти.

Менеджмент памяти




Возьмем наш модуль sys. В нем есть функция, которая позволяет посмотреть, задействует ли он какую-нибудь память. Если вы запустите интерпретатор и посмотрите на статистику изменения памяти, то увидите, что у вас создано очень много объектов, в том числе маленьких. И это только те объекты, которые в данный момент созданы.

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



Из этого вытекает необходимость использовать свой менеджер памяти. Вкратце, как он работает? Python выделяет себе блоки памяти, которые называются арена, по 256 килобайт. Внутри он себе нарезает пулы по четыре килобайта, это размер страницы памяти. Внутри пулов у нас живут блоки разного размера, от 16 до 512 байт.

Когда мы пытаемся выделить объекту меньше 512 байт, Python выбирает каким-то своим способом блок, который подходит для этого объекта и размещает объект в этом блоке.

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



Освобождение памяти. Раньше мы видели структуру PyObject. У нее есть этот refcnt счетчик ссылок. Работает очень просто. Когда вы берете референс на этот объект, Python увеличивает счетчик ссылок. Как только у вас объект, референс пропадает на него, вы деалоцируете счетчик ссылок.

То, что выделено желтым. Если refcnt не равен нулю, значит, мы там что-то делаем. Если refcnt равен нулю, то мы сразу же деаллоцируем объект. Не ждем никакого сборщика мусора, ничего, а прямо в этот момент очищаем память.

Если вы встретите метод del, он просто удаляет привязку переменной к объекту. А метод __del__, который вы можете определить в классе, вызывается, когда объект уже действительно удаляется из памяти. Вы вызовете del у объекта, но при этом, если у него еще есть ссылки, объект никуда не удалится. И его Finalizer, __del__, не вызовется. Хотя они называются очень похоже.

Краткая демка о том, как можно посмотреть количество ссылок. Есть наш любимый модуль sys, в котором есть функция getrefcount. Вы можете посмотреть количество ссылок на объект.



Расскажу подробнее. Делается объект. От него берется количество ссылок. Интересная деталь: переменная A указывает на TaxiOrder. Вы берете количество ссылок, у вас напечатается 2. Казалось бы, почему? У нас же одна ссылка на объект. Но когда вы вызываете getrefcount, этот объект бандится на аргумент внутри функции. Поэтому у вас уже есть две ссылки на этот объект: первая переменная, вторая аргумент функции. Поэтому печатается 2.

Дальше тривиально. Мы присваиваем еще одну переменную объекту, получаем 3. Потом удаляем эту привязку, получаем 2. Потом удаляем все ссылки на этот объект, и при этом вызывается финализатор, который напечатает нашу строчку.



(...) Есть еще одна интересная особенность CPython, на которую нельзя закладываться и нигде в доках про это, кажется, не сказано. Целые числа используются часто. Было бы расточительно их каждый раз создавать заново. Поэтому самые частоиспользуемые числа, разработчики Python выбрали диапазон от 5 до 255, они Singleton. То есть они созданы один раз, лежат где-то в интерпретаторе, и когда вы пытаетесь их получить, то получаете ссылку на один и тот же объект. Мы взяли A и B, единички, напечатали их, сравнили их адреса. Получили True. И у нас, допустим, 105 ссылок на этот объект, просто потому что сейчас получилось столько.

Если возьмем какое-то число больше допустим, 1408, у нас эти объекты не равны и ссылок на них, соответственно, две. А фактически одна.



Мы чуть-чуть поговорили про выделение памяти, про освобождение. Теперь поговорим про сборщик мусора. Для чего он нужен? Казалось бы, у нас есть число ссылок. Как только никто на объект не ссылается, мы можем его удалять. Но у нас могут быть циклические ссылки. Объект может ссылаться, допустим, сам на себя. Или, как в примере, может быть два объекта, каждый ссылается на соседа. Это называется цикл. И тогд эти объекты никогда могут не отдать ссылку на другой объект. Но при этом они, допустим, недостижимы из другой части программы. Нам надо их удалить, потому что они недоступны, бесполезны, но ссылки у них есть. Ровно для этого существует модуль garbage collector. Он детектит циклы и удаляет эти объекты.

Как он работает? Сначала кратко расскажу про поколения, а потом про алгоритм.



Для оптимизации скорости garbage collector в Python он generational, то есть работает с помощью поколений. Есть три поколения. Зачем они нужны? Понятно, что те объекты, которые создались совсем недавно, с большей вероятностью нам не нужны, чем долгоживущие объекты. Допустим, в ходе функций у вас что-то создается. Скорее всего, при выходе из функции оно будет не нужно. То же самое с циклами, с временными переменными. Все эти объекты надо чистить чаще, чем те, которые живут давно.

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

По дефолту там 700, 10, 10. Что такое 700? Это количество созданий объектов минус количество удалений. Как только оно превышает 700, запускается сборка мусора в новом поколении. А 10, 10 это количество сборок мусора в предыдущем поколении, после которого нам надо запустить сборку мусора в текущем поколении.

То есть когда мы нулевое поколение очистим 10 раз, то запустим сборку в первом поколении. Очистив первое поколение 10 раз, запустим сборку во втором поколении. Соответственно, объекты перемещаются из поколения в поколение. Если выживают перемещаются в первое поколение. Если выжили при сборке мусора в первом поколении перемещаются во второе. Из второго поколения уже никуда не перемещаются, остаются там навсегда.



Как работает сборка мусора в Python? Допустим, мы запускаем сборку мусора в поколении 0. У нас есть некие объекты, у них циклы. Есть группа объектов слева, которые друг на друга ссылаются, и группа справа, тоже ссылается друг на друга. Важная деталь на них также есть ссылка из поколения 1. Как Python детектит циклы? Сначала у каждого объекта создается временная переменная и в нее записывается количество ссылок на этот объект. На слайде это отражено. У нас на объект сверху две ссылки. А вот на объект из поколения 1 кто-то ссылается снаружи. Python это запоминает. Потом (важно!) он проходит по каждому объекту внутри поколения и удаляет, декрементирует счетчик на число ссылок внутри этого поколения.



Вот что получилось. У объектов, которые ссылаются только друг на друга внутри поколения, эта переменная автоматически по построению стала равной нулю. Единичка только у объектов, на которые есть ссылки снаружи.

Что делает потом Python? Он, поскольку здесь единичка, понимает, что на эти объекты есть ссылка снаружи. И мы не можем удалить ни этот объект, ни этот, потому что иначе у нас получится невалидная ситуация. Поэтому Python переносит эти объекты в поколение 1, а все, что осталось в поколении 0, он удаляет, очищает. Про garbage collector все.



(...) Идем дальше. Очень кратко расскажу про генераторы.

Генераторы




Здесь, к сожалению, введения в генераторы не будет, но давайте попробую рассказать, что такое генератор. Это некая, условно говоря, функция, которая запоминает контекст своего выполнения с помощью слова yield. На этом месте она возвращает значение и запоминает контекст. Вы можете к ней потом вновь обращаться и получать значение, которое оно выдает.

Что с генераторами можно делать? Можно делать yield генератора, это вам вернет значения, запомнит контекст. Можно делать return для генератора. В этом случае у вас кинется эксепшен StopIteration, value внутри которого будет содержать значение, в данном случае Y.

Менее известный факт: вы можете отправить генератору некие значения. То есть вы вызываете у генератора метод send, и Z см. пример будет значением выражения yield, которое вызовет генератор. Если вы хотите генератором управлять, вы можете туда передавать значения.

Также вы можете кидать туда исключения. То же самое: берете generator object, делаете throw. Кидаете туда ошибку. У вас на месте последнего yield зарейзится ошибка. И close вы можете закрывать генератор. Тогда рейзится эксепшен GeneratorExit, и ожидается, что генератор больше ничего yieldить не будет.



Здесь я просто хотел рассказать про то, как это устроено в CPython. У вас в генераторе на самом деле хранится фрейм выполнения. И как мы помним, FrameObject содержит весь контекст. Из этого, кажется, понятно, как контекст сохраняется. То есть у вас в генераторе просто есть фрейм.



Когда вы выполняете функцию генератора, как Python понимает, что вам нужно не выполнить ее, а создать генератор? В CodeObject, который мы смотрели, есть флаги. И когда вы вызываете функцию, Python чекает ее флаги. Если есть флаг CO_GENERATOR, он понимает, что функцию не надо выполнять, а надо только создать генератор. И он его создает. Функция PyGen_NewWithQualName.



Как происходит выполнение? Из GENERATOR_FUNCTION генератор сначала вызывает GENERATOR_Object. Потом вы GENERATOR_Object можете уже с помощью next вызывать, получать следующее значение. Как происходит вызов next? Из генератора берется его фрейм, он запоминается в переменную F. И отправляется в основной цикл интерпретатора EvalFrameEx. У вас происходит выполнение, как в случае обычной функции. Мапкод YIELD_VALUE используется, чтобы вернуть, поставить на паузу выполнение генератора. Он запоминает весь контекст во фрейме и прекращает выполнение. Это была предпоследняя тема.

(...) Кратко вспомним, что такое исключения и как они используются в Python.

Исключения




Исключения способ обработки ошибочных ситуаций. У нас есть блок try. Мы можем в try записать те вещи, которые могут возбуждать исключения. Допустим, с помощью слова raise мы можем зарейзить ошибку. С помощью except можем ловить определенные типы исключений, в данном случае SomeError. С помощью except мы без выражения ловим все исключения вообще. Блок else используется реже, но он есть и выполнится, только если ни одного исключения не было возбуждено. Блок finally выполнится в любом случае.

Как работают исключения в CPython? Кроме стека выполнения, у каждого фрейма есть еще стек блоков. Лучше рассказать на примере.





Стек блоков это стек, в котором пишутся блоки. У каждого блока есть тип, Handler, обработчик. Handler это адрес байт-кода, на который надо перейти, чтобы обработать этот блок. Как все работает? Допустим, у нас есть некий код. Мы сделали блок try, у нас есть блок except, в котором мы ловим исключения RuntimeError, и блок finally, который должен быть в любом случае.

Это все вырождается вот в такой байт-код. В самом начале байт-кода на блоке try мы видим два два opcode SETUP_FINALLY с аргументами to 40 и to 12. Это адреса обработчиков. Когда выполняется SETUP_FINALLY, в стек блоков помещается блок, в котором написано: чтобы обработать меня, перейди в одном случае на 40-й адрес, в другом на 12-й.

12 ниже по стеку это except, строчка, где есть else RuntimeError. Значит, когда у нас будет исключение, мы будем смотреть стек блоков в поиске блока с типом SETUP_FINALLY. Найдем блок, в котором есть переход на адрес 12, перейдем туда. И там у нас происходит сравнение исключения с типом: мы проверяем, равен ли тип исключения RuntimeError или нет. Если равен мы выполняем его, если нет прыгаем куда-то в другое место.

FINALLY следующий блок в стеке блоков. Он у нас выполнится, если у нас будет еще какой-то exception. Тогда дальше пойдет поиск по этому стеку блоков, и мы дойдем до следующего блока SETUP_FINALLY. Там будет обработчик, который сообщает нам, например, адрес 40. Мы прыгаем на адрес 40 по коду видно, что это блок finally.



В CPython это работает очень просто. У нас все функции, которые могут возбуждать исключения, возвращают код значения. Если все хорошо, возвращается 0. Если это ошибка, возвращается -1 или NULL, в зависимости от типа функции.

Возьмем такую врезку на C. Мы видим, как происходит деление. И есть проверка, что если B равно нулю, а делить на ноль не хочется, то мы запоминаем исключение и возвращаем NULL. Значит, произошла ошибка. Следовательно, все остальные функции, которые находятся выше по стеку вызовов, тоже должны будут выкинуть NULL. Мы это увидим в основном цикле интерпретатора и прыгнем сюда.



Это раскрутка стека. Тут всё, как я говорил: мы просматриваем весь стек блоков и проверяем, что его тип равен SETUP_FINALLY. Если это так прыгаем по Handler, очень просто. На этом, собственно, и всё.

Ссылки


Интепретатор в целом:
docs.python.org/3/reference/executionmodel.html
github.com/python/cpython
leanpub.com/insidethepythonvirtualmachine/read

Управление памятью:
arctrix.com/nas/python/gc
rushter.com/blog/python-memory-managment
instagram-engineering.com/dismissing-python-garbage-collection-at-instagram-4dca40b29172
stackify.com/python-garbage-collection

Исключения:
bugs.python.org/issue17611
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru