Перейти к содержимому

Чем отличается итератор от генератора python

  • автор:

Итерируемый объект, итератор и генератор

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

Итераторы

Для начала вспомним, что из себя представляет паттерн «Итератор(Iterator)».
Назначение:

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

Существуют два вида итераторов, внешний и внутренний.
Внешний итератор — это классический (pull-based) итератор, когда процессом обхода явно управляет клиент путем вызова метода Next.
Внутренний итератор — это push-based-итератор, которому передается callback функция, и он сам уведомляет клиента о получении следующего элемента.

Классическая диаграмма паттерна “Итератор”, как она описана в небезызвестной книги «банды четырех»:

Aggregate — составной объект, по которому может перемещаться итератор;
Iterator — определяет интерфейс итератора;
ConcreteAggregate — конкретная реализация агрегата;
ConcreteIterator — конкретная реализация итератора для определенного агрегата;
Client — использует объект Aggregate и итератор для его обхода.

Пробуем реализовать на Python классический итератор

class Aggregate(abc.ABC): @abc.abstractmethod def iterator(self): """ Возвращает итератор """ pass class Iterator(abc.ABC): def __init__(self, collection, cursor): self._collection = collection self._cursor = cursor @abc.abstractmethod def first(self): """ Возвращает итератор к началу агрегата. Так же называют reset """ pass @abc.abstractmethod def next(self): """ Переходит на следующий элемент агрегата. Вызывает ошибку StopIteration, если достигнут конец последовательности. """ pass @abc.abstractmethod def current(self): """ Возвращает текущий элемент """ pass 

Конкретная реализация итератора для списка:

class ListIterator(Iterator): def __init__(self, collection, cursor): """ :param collection: список :param cursor: индекс с которого начнется перебор коллекции. так же должна быть проверка -1 >= cursor < len(collection) """ super().__init__(collection, cursor) def first(self): """ Начальное значение курсора -1. Так как в нашей реализации сначала необходимо вызвать next который сдвинет курсор на 1. """ self._cursor = -1 def next(self): """ Если курсор указывает на послений элемент, то вызываем StopIteration, иначе сдвигаем курсор на 1 """ if self._cursor + 1 >= len(self._collection): raise StopIteration() self._cursor += 1 def current(self): """ Возвращаяем текущий элемент """ return self._collection[self._cursor] 

Конкретная реализация агрегата:

class ListCollection(Aggregate): def __init__(self, collection): self._collection = list(collection) def iterator(self): return ListIterator(self._collection, -1) 

Теперь мы можем создать объект коллекции и обойти все ее элементы с помощью итератора:

collection = (1, 2, 5, 6, 8) aggregate = ListCollection(collection) itr = aggregate.iterator() # обход коллекции while True: try: itr.next() except StopIteration: break print(itr.current()) 

А так как мы реализовали метод first, который сбрасывает итератор в начальное состояние, то можно воспользоваться этим же итератором еще раз:

# возвращаем итератор в исходное состояние itr.first() while True: try: itr.next() except StopIteration: break print(itr.current()) 

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

Протокол итерирования в Python

В книге «банды четырех» о реализации итератора написано:

Минимальный интерфейс класса Iterator состоит из операций First, Next, IsDone и CurrentItem. Но если очень хочется, то этот интерфейс можно упростить, объединив операции Next, IsDone и CurrentItem в одну, которая будет переходить к следующему объекту и возвращать его. Если обход завершен, то эта операция вернет специальное значения(например, 0), обозначающее конец итерации.

Именно так и реализовано в Python, но вместо специального значения, о конце итерации говорит StopIteration. Проще просить прощения, чем разрешения.

Сначала важно определиться с терминами.

Рассмотрим итерируемый объект (Iterable). В стандартной библиотеке он объявлен как абстрактный класс collections.abc.Iterable:

class Iterable(metaclass=ABCMeta): __slots__ = () @abstractmethod def __iter__(self): while False: yield None @classmethod def __subclasshook__(cls, C): if cls is Iterable: return _check_methods(C, "__iter__") return NotImplemented 

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

class SomeIterable1(collections.abc.Iterable): def __iter__(self): pass class SomeIterable2: def __iter__(self): pass print(isinstance(SomeIterable1(), collections.abc.Iterable)) # True print(isinstance(SomeIterable2(), collections.abc.Iterable)) # True 

Но есть один момент, это функция iter(). Именно эту функцией использует например цикл for для получения итератора. Функция iter() в первую очередь для получения итератора из объекта, вызывает его метод __iter__. Если метод не реализован, то она проверяет наличие метода __getitem__ и если он реализован, то на его основе создается итератор. __getitem__ должен принимать индекс с нуля. Если не реализован ни один из этих методов, тогда будет вызвано исключение TypeError.

from string import ascii_letters class SomeIterable3: def __getitem__(self, key): return ascii_letters[key] for item in SomeIterable3(): print(item) 

Итого, итерируемый объект — это любой объект, от которого встроенная функция iter() может получить итератор. Последовательности(abc.Sequence) всегда итерируемые, поскольку они реализуют метод __getitem__

Теперь посмотрим, что с итераторами в Python. Они представлены абстрактным классом collections.abc.Iterator:

class Iterator(Iterable): __slots__ = () @abstractmethod def __next__(self): 'Return the next item from the iterator. When exhausted, raise StopIteration' raise StopIteration def __iter__(self): return self @classmethod def __subclasshook__(cls, C): if cls is Iterator: return _check_methods(C, '__iter__', '__next__') return NotImplemented 

__next__ Возвращает следующий доступный элемент и вызывает исключение StopIteration, когда элементов не осталось.
__iter__ Возвращает self. Это позволяет использовать итератор там, где ожидается итерируемых объект, например for.
__subclasshook__ Проверяет наличие у класса метода __iter__ и __next__

Итого, итератор в python — это любой объект, реализующий метод __next__ без аргументов, который должен вернуть следующий элемент или ошибку StopIteration. Также он реализует метод __iter__ и поэтому сам является итерируемым объектом.

Таким образом можно реализовать итерируемый объект на основе списка и его итератор:

class ListIterator(collections.abc.Iterator): def __init__(self, collection, cursor): self._collection = collection self._cursor = cursor def __next__(self): if self._cursor + 1 >= len(self._collection): raise StopIteration() self._cursor += 1 return self._collection[self._cursor] class ListCollection(collections.abc.Iterable): def __init__(self, collection): self._collection = collection def __iter__(self): return ListIterator(self._collection, -1) 
collection = [1, 2, 5, 6, 8] aggregate = ListCollection(collection) for item in aggregate: print(item) print("*" * 50) itr = iter(aggregate) while True: try: print(next(itr)) except StopIteration: break 

Функция next() вызывает метод __next__. Ей можно передать второй аргумент который она будет возвращать по окончанию итерации вместо ошибки StopIteration.

itr = iter(aggregate) while True: item = next(itr, None) if item is None: break print(item) 

Прежде чем переходить к генераторам, рассмотрим еще одну возможность встроенной функции iter(). Ее можно вызывать с двумя аргументами, что позволит создать из вызываемого объекта(функция или класс с реализованным методом __call__) итератор. Первый аргумент должен быть вызываемым объектом, а второй — неким ограничителем. Вызываемый объект вызывается на каждой итерации и итерирование завершается, когда возбуждается исключение StopIteration или возвращается значения ограничителя.

Например, из функции которая произвольно возвращает 1-6, можно сделать итератор, который будет возвращать значения пока не «выпадет» 6:

from random import randint def d6(): return randint(1, 6) for roll in iter(d6, 6): print(roll) 

Другие примеры

Небольшой класс ProgrammingLanguages, у которого есть кортеж c языками программирования, конструктор принимает начальное значения индекса по названию языка и функция __call__ которая перебирает кортеж.

class ProgrammingLanguages: _name = ("Python", "Golang", "C#", "C", "C++", "Java", "SQL", "JS") def __init__(self, first=None): self.index = (-1 if first is None else ProgrammingLanguages._name.index(first) - 1) def __call__(self): self.index += 1 if self.index < len(ProgrammingLanguages._name): return ProgrammingLanguages._name[self.index] raise StopIteration 

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

for lang in iter(ProgrammingLanguages("C#"), None): print(lang) 
pl = ProgrammingLanguages() for lang in iter(pl, "C"): print(lang) 

Еще один пример:

# читаем файл до пустой строки with open('mydata.txt') as fp: for line in iter(fp.readline, ''): print(line) 

Генераторы

С точки зрения реализации, генератор в Python — это языковая конструкция, которую можно реализовать двумя способами: как функция с ключевым словом yield или как генераторное выражение. В результате вызова функции или вычисления выражения, получаем объект-генератор типа types.GeneratorType.

В объекте-генераторе определены методы __next__ и __iter__, то есть реализован протокол итератора, с этой точки зрения, в Python любой генератор является итератором.
Концептуально, итератор — это механизм поэлементного обхода данных, а генератор позволяет отложено создавать результат при итерации. Генератор может создавать результат на основе какого то алгоритма или брать элементы из источника данных(коллекция, файлы, сетевое подключения и пр) и изменять их.

Ярким пример являются функции range и enumerate:

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

Yield

Для начало напишем простой генератор не используя объект-генератор. Это генератор чисел Фибоначчи:

class FibonacciGenerator: def __init__(self): self.prev = 0 self.cur = 1 def __next__(self): result = self.prev self.prev, self.cur = self.cur, self.prev + self.cur return result def __iter__(self): return self for i in FibonacciGenerator(): print(i) if i > 100: break 

Но используя ключевое слово yield можно сильно упростить реализацию:

def fibonacci(): prev, cur = 0, 1 while True: yield prev prev, cur = cur, prev + cur for i in fibonacci(): print(i) if i > 100: break 

Любая функция в Python, в теле которой встречается ключевое слово yield, называется генераторной функцией — при вызове она возвращает объект-генератор.
Объект-генератор реализует интерфейс итератора, соответственно с этим объектом можно работать, как с любым другим итерируемым объектом.

fib = fibonacci() print(next(fib)) # 0 print(next(fib)) # 1 for num, fib in enumerate(fibonacci()): print(': '.format(num, fib)) if num > 9: break # 0: 0 # 1: 1 # 2: 1 . 

Рассмотрим работу yield:

def gen_fun(): print('block 1') yield 1 print('block 2') yield 2 print('end') for i in gen_fun(): print(i) # block 1 # 1 # block 2 # 2 # end 
  1. при вызове функции gen_fun создается объект-генератор
  2. for вызывает iter() с этим объектом и получает итератор этого генератора
  3. в цикле вызывает функция next() с этим итератором пока не будет получено исключение StopIteration
  4. при каждом вызове next выполнение в функции начинается с того места где было завершено в последний раз и продолжается до следующего yield
def gen_fun_1(): print('block 1') return 1 def gen_fun_2(): print('block 2') return 2 def gen_fun_3(): print('end') def gen_fun_end(): raise StopIteration 

Создается стейт-машина в которой при каждом вызове __next__ меняется состояния и в зависимости от него вызывается тот или иной кусок кода. Если в функции yield в цикле, то соответственно состояние стейт-машины зацикливается пока не будет выполнено условие.

Свой вариант range:

def cool_range(start, stop, inc): x = start while x < stop: yield x x += inc for n in cool_range(1, 5, 0.5): print(n) # 1 # 1.5 # . # 4.5 print(list(cool_range(0, 2, 0.5))) # [0, 0.5, 1.0, 1.5] 

Генераторное выражение (generator expression)

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

В языках программирования есть такие понятия, как ленивые/отложенные вычисления(lazy evaluation) и жадные вычисления(eager/greedy evaluation). Генераторы можно считать отложенным вычислением, в этом смысле списковое включение(list comprehension) очень похожи на генераторное выражение, но являются разными подходами.

(i for i in range(10000000)) [i for i in range(10000000)] 

Первый вариант работает схожим с нашей функцией cool_range образом и может генерировать без проблем любой диапазон. А вот второй вариант создаст сразу целый список, со всеми вытекающими от сюда проблемами.

Yield from

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

Функция похожая на itertools.chain:

def chain(*iterables): for it in iterables: for i in it: yield i g = chain([1, 2, 3], , '. ') print(list(g)) # [1, 2, 3, 'A', 'B', 'C', '.', '.', '.'] 

Но вложенные циклы можно убрать, добавив конструкцию yield from:

def chain(*iterables): for it in iterables: yield from it g = chain([1, 2, 3], , '. ') print(list(g)) # [1, 2, 3, 'A', 'B', 'C', '.', '.', '.'] 

Основная польза yield from в создании прямого канала между внутренним генератором и клиентом внешнего генератора. Но это уже больше тема про сопрограммы(coroutines), которые заслуживают отдельной статьи. Там же можно обсудить методы генератора: close(), throw() и send().

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

from collections import Iterable def flatten(items, ignore_types=(str, bytes)): """ str, bytes - являются итерируемыми объектами, но их хотим возвращать целыми """ for x in items: if isinstance(x, Iterable) and not isinstance(x, ignore_types): yield from flatten(x) else: yield x items = [1, 2, [3, 4, [5, 6], 7], 8, ('A', )] for x in flatten(items): print(x) # 1 # 2 # 3 # 4 # 5 # 6 # 7 # 8 # A # C # B 

AlexKorablev.ru

AlexKorablev.ru

Александр Кораблев о разработке ПО, ИТ-индустрии и Python.

В чем разница между итератором и генератором?

Опубликовано 29 February 2016 в Python

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

Итератор - более общая концепция. Это объект, у которого определены два метода __next__ и __iter__.

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

def squares(start, stop): for i in range(start, stop): yield i * i generator = squares(a, b) 

Либо использованием так называемых generator expression. На вроде такого:

generator = (i*i for i in range(a, b)) 

Если посмотреть dir(generator) и в первом и во втором случае, то обнаружим, что оба варианта - итераторы (определены функции __next__ и __iter__).

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

В примерах и объяснениях я использовал Python 3. Для второй версии языка есть незначительные отличия.

---
Возник вопрос? Мне всегда можно написать в Twitter: avkorablev

Понравилась статья? Поделись с друзьями!

Итераторы и генераторы

В предыдущих разделах мы встречали различные итерируемые объекты. Такие объекты можно использовать в цикле for , к ним можно применять оператор in . Разберемся в механизме итерирования. Классы стандартных контейнеров имеют метод __iter__ , возвращающий итератор:

l = [1, 2, 3] s = set(1, 2, 3) d = k: v for v, k in enumerate(['a', 'b', 'c'])> all(map(lambda x: hasattr(x, '__iter__'), [l, s, d])) # True type(l.__iter__()) # type(iter(l)) # itl = iter(l) for x in itl: print(x, end=' ') # 1 2 3 for x in itl: print(x, end=' ') # этот цикл не запустится, поскольку итератор позволяет # перебрать объекты только один раз its = iter(s) type(its) # # для получения следующего значения можно использовать функцию next. # Функция next вызывается при переборе значений в цикле for next(its) # 1 next(its) # 2 next(its) # 3 

Вызов метода __iter__ в данном случае аналогичен использованию функции iter . В обоих случаях мы получаем итератор. В итераторе должен быть определен метод __next__ , который возвращает следующее значение. Функция next вызывает метод __next__ .

Функция iter также вернет итератор, если вместо метода __iter__ реализован метод __getitem__ , позволяющий доступаться к элементам контейнера по индексу.

Чтобы сделать тип итерируемым, достаточно реализовать метод __iter__ или метод __getitem__ . Чтобы создать тип-итератор, необходимо определить в нем метод __next__ и метод __iter__ . Последний должен возвращать сам объект ( self ). Таким образом, итераторы сами являются итерируемыми объектами.

Генераторы

Другой встречавшийся нам итерируемый объект — результат вызова функции range :

import sys for x in range(5): print(x, end=' ') # 1 2 3 4 5 rng = range(10**6) type(rng) # sys.getsizeof(rng) # 48 — размер объекта в байтах l = list(rng) sys.getsizeof(l) # 9000120 

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

def my_range(start, stop=None, step=None): if step is None: step = 1 if stop is None: start, stop = 0, start v = start while v  stop: yield v v += step myrng = my_range(5) type(myrng) # sys.getsizeof(myrng) # 128 for x in myrng: print(x, end=' ') # 0 1 2 3 4 

Размер нашего генератора больше, чем объекта range , но он также не зависит от значений аргументов функции my_range . Ключевым элементом функции-генератора my_range является строка

yield v 

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

Создадим более интересный генератор, который вычисляет последовательность Рекамана:

def rekaman(stop): n = 0 prev = 0 visited = set() while n  stop: if n == 0: yield 0 prev = 0 elif prev - n > 0 and prev - n not in visited: yield prev - n prev = prev - n else: yield prev + n prev = prev + n n += 1 visited.add(prev) for r in rekaman(100): print(r, end=' ') # 0 1 3 6 2 7 13 20 12 21 11 22 10 23 9 24 8 25 43 62 42 63 41 18 42 # 17 43 16 44 15 45 14 46 79 113 78 114 77 39 78 38 79 37 80 36 81 35 # 82 34 83 33 84 32 85 31 86 30 87 29 88 28 89 27 90 26 91 157 224 # 156 225 155 226 154 227 153 228 152 75 153 74 154 73 155 72 156 71 # 157 70 158 69 159 68 160 67 161 66 162 65 163 64 

Функции-генераторы являются удобным и гибким инструментом языка python. Иметь этот инструмент в арсенале очень полезно.

Альтернативный способ создания генераторов предоставляют генераторные выражения:

l1 = [x**2 for x in range(100)] # списковое включение g1 = (x**2 for x in range(100)) # генераторное выражение type(l1) # type(g1) # 

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

g = (x for x in range(20) if x % 3) for val in g: print(val, end=' ') # 1 2 4 5 7 8 10 11 13 14 16 17 19 

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

Функция map также возвращает итератор (генератор):

l1 = [x**2 for x in range(100)] g1 = (x**2 for x in range(100)) m1 = map(lambda x: x**2, range(100)) type(l1) # type(g1) # type(m1) # sys.getsizeof(l1) # 920 sys.getsizeof(g1) # 128 sys.getsizeof(m1) # 64 

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

m2 = list(map(lambda x: x**2, range(100))) 

С помощью генераторов удобно создавать итераторы. Вернемся к примеру из одного из предыдущих разделов, в котором мы реализовали класс релятивистских векторов:

from typing import NamedTuple class FourVector(NamedTuple): t: float r: list fv = FourVector(1, [0.3, 0.4, 0.0]) for x in fv: print(x, end=' ') # 1 [0.3, 0.4, 0.0] 

Объекты класса FourVector является итерируемыми, поскольку класс наследуется от типа NamedTuple . Давайте изменим правило итерирования:

import itertools class FourVector(NamedTuple): t: float r: list def __iter__(self): if isinstance(self.r, list): return itertools.chain([self.t], self.r) return (x for x in [self.t, self.r]) fv1 = FourVector(1, [0.3, 0.4, 0.0]) fv2 = FourVector(1, 0.5) for x in fv1: print(x, end=' ') # 1 0.3 0.4 0.0 for x in fv2: print(x, end=' ') # 1 0.5 

Мы не могли использовать спиское включение вместо генераторного выражения в методе __iter__ , поскольку, в отличие от типа generator , тип list не является итератором. Функция itertools.chain принимает несколько итераторов или итерируемых коллекций и создаёт генератор, который последовательно проходит по всем их элементам.

Резюме

Итерируемый объект должен иметь реализацию хотя бы одного из методов __iter__ и __getitem__ . Объект-итератор должен иметь реализацию метода __next__ .

Генераторы являются инструментом для вычисления элементов последовательностей “на лету” и позволяют избежать хранения всех элементов последовательности в памяти. Генераторные выражения позволяют создавать генераторы с простой логикой. Генераторы удобно использовать при реализации итераторов.

Источники

  • The Python Wiki: Generators
  • Как работает yield
  • How to make an iterator in Python
  • PEP 289 — Generator Expressions
  • Сопрограммы в Python

В чем отличие итератора от генераторного выражения?

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

Отслеживать
задан 13 мар 2023 в 13:18
Anonymous Wizard Anonymous Wizard
117 8 8 бронзовых знаков
в том, что генератор не занимает память на все элементы, а выдает их по одному
13 мар 2023 в 13:19

Попробуйте сделать миллиард элементов, и вы очень быстро заметите, что совсем не одинаково. "причем тут список" - при том, что вы создаёте целый список в первом варианте

13 мар 2023 в 13:25
@AnonymousWizard не должен, он считает размер не списка, а ссылки на список
13 мар 2023 в 13:26
@AnonymousWizard не экономит, пока готовый объект существует и жрёт собой память
13 мар 2023 в 13:34

Однако стоит отметить, что итерирование по списку может быть быстрее, чем итерирование по генераторному выражению (но не стоит забывать, что создание списка тоже занимает время, так что где какой способ будет работать быстрее - вопрос неоднозначный)

13 мар 2023 в 13:34

1 ответ 1

Сортировка: Сброс на вариант по умолчанию

Итератор - любой объект, которые реализует метод __iter__ . Генератор - объект который вычисляет значение по мере необходимости, т.е. каждый раз когда его просят. Любой генератор это итератор, но не наоборот.

В вашем примере если заменить 10000 на 10**100 , то первый вариант займёт всю память и полностью остановит программу. Второй пример будет работать нормально, потому что он не будет считать все значения сразу, а только когда понадобится.

Отслеживать
ответ дан 13 мар 2023 в 13:35
19.7k 6 6 золотых знаков 22 22 серебряных знака 56 56 бронзовых знаков

  • python
  • python-3.x
  • итераторы
  • генераторы
    Важное на Мете
Похожие

Подписаться на ленту

Лента вопроса

Для подписки на ленту скопируйте и вставьте эту ссылку в вашу программу для чтения RSS.

Дизайн сайта / логотип © 2024 Stack Exchange Inc; пользовательские материалы лицензированы в соответствии с CC BY-SA . rev 2024.1.30.4069

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *