# 49. Lazy Evaluation with itertools

Here are 10 Python code snippets demonstrating lazy evaluation with the `itertools` module. Lazy evaluation means that the data is processed on demand, which is useful for large datasets, as it avoids loading everything into memory at once.

***

**1. Using `itertools.count` for Infinite Sequences**

Generating an infinite sequence of numbers starting from a specified number using lazy evaluation.

```python
import itertools

# Infinite sequence of numbers starting from 0
counter = itertools.count(start=0, step=1)

# Get the first 10 numbers from the infinite sequence
for i in itertools.islice(counter, 10):
    print(i)
```

This generates an infinite sequence of numbers, but we only evaluate the first 10 numbers using `islice`.

***

**2. Using `itertools.cycle` for Repeated Sequences**

Repeating a sequence infinitely with lazy evaluation.

```python
import itertools

# Infinite cycle of the list ['A', 'B', 'C']
cycle = itertools.cycle(['A', 'B', 'C'])

# Get the first 10 elements from the infinite cycle
for i in itertools.islice(cycle, 10):
    print(i)
```

`cycle` generates an infinite loop over a sequence, and `islice` helps extract a finite number of elements.

***

**3. Using `itertools.repeat` for Repeating an Element**

Repeating a single element a specified number of times lazily.

```python
import itertools

# Repeat 'Hello' 5 times
for word in itertools.repeat('Hello', 5):
    print(word)
```

`repeat` will lazily repeat the element the specified number of times, without storing all repeated elements in memory.

***

**4. Using `itertools.chain` to Combine Iterables**

Combining multiple iterables lazily without creating a large combined list.

```python
import itertools

iterable1 = [1, 2, 3]
iterable2 = [4, 5, 6]

# Chain two iterables together lazily
combined = itertools.chain(iterable1, iterable2)

for item in combined:
    print(item)
```

`chain` allows us to process the elements of both lists lazily, one by one.

***

**5. Using `itertools.islice` to Slice an Iterable Lazily**

Slicing an iterable lazily using `islice` without consuming memory for the entire sequence.

```python
import itertools

data = range(100)

# Lazily slice the first 10 elements
sliced_data = itertools.islice(data, 10)

for item in sliced_data:
    print(item)
```

This returns the first 10 elements from the `range(100)` lazily, without creating a large list in memory.

***

**6. Using `itertools.starmap` for Applying Functions to Iterable Elements**

Using `starmap` to lazily apply a function to elements of an iterable of tuples.

```python
import itertools

data = [(2, 3), (4, 5), (6, 7)]

# Lazily apply multiplication function to each tuple
result = itertools.starmap(lambda x, y: x * y, data)

for item in result:
    print(item)
```

`starmap` lazily applies the function to each tuple in the iterable.

***

**7. Using `itertools.combinations` for Generating Combinations**

Generating combinations of elements lazily, without evaluating the entire set at once.

```python
import itertools

data = [1, 2, 3, 4]

# Get all 2-combinations of the data lazily
combinations = itertools.combinations(data, 2)

for item in combinations:
    print(item)
```

This generates combinations of the input data lazily, yielding one combination at a time.

***

**8. Using `itertools.permutations` for Generating Permutations**

Generating all permutations of elements lazily.

```python
import itertools

data = [1, 2, 3]

# Get all permutations of the data lazily
permutations = itertools.permutations(data)

for item in permutations:
    print(item)
```

`permutations` generates permutations lazily, and you can use `islice` if you need only a subset.

***

**9. Using `itertools.product` for Cartesian Product**

Generating the Cartesian product of multiple iterables lazily.

```python
import itertools

iterable1 = [1, 2]
iterable2 = ['A', 'B']

# Get the Cartesian product lazily
product = itertools.product(iterable1, iterable2)

for item in product:
    print(item)
```

`product` computes the Cartesian product lazily, generating pairs without creating a large structure.

***

**10. Using `itertools.combinations_with_replacement` for Combinations with Replacement**

Generating combinations with replacement lazily.

```python
import itertools

data = [1, 2, 3]

# Get 2-combinations with replacement lazily
combinations_with_replacement = itertools.combinations_with_replacement(data, 2)

for item in combinations_with_replacement:
    print(item)
```

This generates combinations with replacement lazily, yielding each combination one by one.

***

These examples illustrate how to leverage `itertools` for efficient lazy evaluation in Python. By using these iterators, you can handle large datasets or perform computationally expensive operations without consuming too much memory.
