dakujem/toru

取用 - 轻松处理可迭代集合。Lodash风格的流畅调用链,基于生成器的迭代原语,聚合,实用工具。

1.0 2024-08-21 09:17 UTC

This package is auto-updated.

Last update: 2024-09-26 14:54:28 UTC


README

Test Suite Coverage Status

Toru 是一个独立的可迭代集合工具,适用于简单的日常任务和高级优化。
其大多数功能基于原生的 生成器,以实现大数据集的效率。

💿 composer require dakujem/toru

📒 变更日志

Toru 提供了一些常见的

  • 迭代原语(例如 mapfiltertap),
  • 聚合(例如 reducesearchcount
  • 和实用函数(例如 chainvaluesOnlyslicelimit

... 使用 生成器 实现。

TL;DR

  • 在不转换为数组的情况下转换原生的 iterable 类型集合(迭代器和数组)的元素
  • 为每个映射器、过滤器、归约器或效果函数提供键(索引)
  • 启用流畅调用链(Lodash风格)
  • 延迟评估变换
  • 在不增加内存使用的情况下转换大数据集
  • 生成器的内存效率比原生数组函数更好
  • 比原生数组函数或直接在 foreach 块内部进行转换要慢

* iterable 是一个内置的编译时类型别名,用于 array|Traversable,包括所有数组和迭代器,因此它不是一个原生类型,从技术上讲。

流畅调用链 允许优雅的变换组合。

_dash($collection)
    ->filter(predicate: $filterFunction)
    ->map(values: $mapperFunction)
    ->chain($moreElements)
    ->valuesOnly();

Toru 通过利用生成器,在基于每个元素的基础上工作且不分配额外内存,从而实现了对大数据集的内存高效操作。

// no extra memory wasted on creating a filtered array
$filtered = Itera::filter(input: $hugeDataSet, predicate: $filterFunction);
foreach($filtered as $key => $value){ /* ... */ }

所有可调用参数始终 接收键 和值。
这是与原生函数(如 array_maparray_reducearray_walkarray_filter)相比的一个关键优势。

$addresses = [
    'john.doe@example.com' => 'John Doe',
    'jane.doe@example.com' => 'Jane Doe',
];
$recipients = Itera::map(
    $addresses, 
    fn($recipientName, $recipientAddress) => new Recipient($recipientAddress, $recipientName),
);
Mailer::send($mail, $recipients);

🥋
包名来自日语单词 "toru"(取用),可能意味着 "取"、"拾起" 或甚至 "收集"。

示例

任务:以 低内存占用 迭代多个大型数组(或其他可迭代集合)

use Dakujem\Toru\Itera;

// No memory wasted on creating a compound array. Especially true when the arrays are huge.
$all = Itera::chain($collection1, $collection2, [12,3,5], $otherCollection);

foreach ($all as $key => $element) {
    // do not repeat yourself
}

任务:过滤和映射一个集合,也指定新的键(重新索引)

use Dakujem\Toru\Dash;

$mailingList = Dash::collect($mostActiveUsers)
    ->filter(
        predicate: fn(User $user): bool => $user->hasGivenMailingConsent()
    )
    ->adjust(
        values: fn(User $user) => $user->fullName(),
        keys: fn(User $user) => $user->emailAddress(),
    )
    ->limit(100);

foreach ($mailingList as $emailAddress => $recipientName) {
    $mail->addRecipient($emailAddress, $recipientName);
}

任务:创建一个包含目录中所有文件的列表,作为 path => FileInfo 对,而不会耗尽内存

$files = _dash(
        new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir))
    )                                                                   // recursively iterate over a dir
    ->filter(fn(\SplFileInfo $fileInfo) => !$fileInfo->isDir())         // reject directories
    ->reindex(fn(\SplFileInfo $fileInfo) => $fileInfo->getPathname());  // index by full file path

请注意,这里我们使用了全局函数 _dash,您可以在项目中选择性地定义。请参阅下面的“使用全局别名”部分。

用法

以下API部分中描述的大多数原语以 3种形式 实现

  1. 作为静态方法 Itera::*(iterable $input, ...$args),用于简单情况
  2. 作为 Dash 包装器的流畅方法,Dash::*(...$args): Dash,最适合流畅组合
  3. 作为创建部分应用调用的工厂方法,IteraFn::*(...$args): callable,可以组合到管道中或用作过滤器(例如在Twig、Blade、Latte中...)

示例:过滤和映射集合,然后附加一些已处理的元素。

使用单个 静态方法

use Dakujem\Toru\Itera;

$filtered = Itera::filter(input: $collection, predicate: $filterFunction);
$mapped = Itera::apply(input: $filtered, values: $mapperFunction);
$merged = Itera::chain($mapped, $moreElements);
$processed = Itera::valuesOnly(input: $merged);

使用 Dash 包装器进行流畅 调用链

use Dakujem\Toru\Dash;

$processed = Dash::collect($collection)
    ->filter(predicate: $filterFunction)
    ->apply(values: $mapperFunction)
    ->chain($moreElements)
    ->valuesOnly();

使用 部分应用方法

use Dakujem\Toru\Pipeline;
use Dakujem\Toru\IteraFn;

$processed = Pipeline::through(
    $collection,
    IteraFn::filter(predicate: $filterFunction),
    IteraFn::apply(values: $mapperFunction),
    IteraFn::chain($moreElements),
    IteraFn::valuesOnly(),
);

现在可以迭代 $processed 集合。上述所有操作仅在此处、基于每个元素的基础上应用。

foreach ($processed as $value) {
    // The filtered and mapped values from $collection will appear here,
    // followed by the elements present in $moreElements.
}

API

链式多个可迭代对象:chainappend

use Dakujem\Toru\Dash;
use Dakujem\Toru\Itera;
use Dakujem\Toru\IteraFn;

Itera::chain(iterable ...$input): iterable

// `append` is only present in `Dash` and `IteraFn` classes as an alias to `chain`
Dash::append(iterable ...$more): Dash
IteraFn::append(iterable ...$more): callable

chain方法创建一个由所有参数组成的可迭代对象。
结果可迭代对象将依次产生第一个可迭代对象的所有值(保留键),然后是下一个,然后是下一个,依此类推。

array_replace(或array_merge或数组上的联合操作符+)相比,这非常内存高效,因为它不会加倍内存使用。

append方法将可迭代对象追加到包装/输入集合中。它是chain方法的别名。

append方法仅在IteraFnDash类中存在。在Itera类的静态上下文中追加没有意义,因为没有东西可以追加。
在静态上下文中,请使用Itera::chain

映射:mapadjustapplyreindexunfold

use Dakujem\Toru\Itera;

Itera::adjust(iterable $input, ?callable $values = null, ?callable $keys = null): iterable
Itera::apply(iterable $input, callable $values): iterable
Itera::map(iterable $input, callable $values): iterable
Itera::reindex(iterable $input, callable $keys): iterable
Itera::unfold(iterable $input, callable $mapper): iterable

adjust方法允许映射值和键。
apply方法仅映射值,
reindex方法允许映射键(索引)。

不要将map方法与原生的array_map混淆,原生函数具有不同的接口。相反,最好使用apply方法来映射值。map方法是apply方法的别名。

对于这些方法中的每一个,所有映射可调用都接收当前键作为第二个参数。
映射器的签名始终是

fn(mixed $value, mixed $key): mixed

unfold方法允许映射和/或平铺矩阵一级。
使用unfold和单个可调用映射值和键的一个技巧是返回一个单键值对(包含单个元素的数组,具有指定的键),如下所示

use Dakujem\Toru\Itera;

Itera::unfold(
    [1,2,3,4],
    fn($value, $key) => ['double value for key ' . $key => $value * 2],
)

// or "produce new index and key based on the original value"
Itera::unfold(
    ['one:1', 'two:2', 'three:3'],
    function(string $value) {
        [$name, $index] = split(':', $value); // there could be a fairly complex regex here
        return [$index => $name];
    },
)

归约:reduce

这是一个聚合函数,它将立即消耗输入。

array_reduce类似,但适用于任何可迭代对象,并将键传递给归约器。

use Dakujem\Toru\Itera;

Itera::reduce(iterable $input, callable $reducer, mixed $initial): mixed

归约器的签名是

fn(mixed $carry, mixed $value, mixed $key): iterable|mixed

当使用Dash::reduce流畅调用时,结果以两种不同的方式处理

  1. 当返回可迭代值时,结果被包裹在一个新的Dash实例中,以允许继续流畅调用链(对矩阵归约很有用)
  2. 当返回其他混合值类型时,结果按原样返回
use Dakujem\Toru\Dash;

// The value is returned directly, because it is not iterable:
Dash::collect([1,2,3])->reduce(fn() => 42); // 42

// The value `[42]` is iterable, thus a new `Dash` instance is returned:
Dash::collect([1,2,3])->reduce(fn() => [42])->count(); // 1

过滤:filter

创建一个生成器,仅产生对输入集合的项,该项的谓词返回

use Dakujem\Toru\Itera;

Itera::filter(iterable $input, callable $predicate): iterable

接受和消除基于可调用的谓词的元素。
当谓词返回时,元素被接受并产生。
当谓词返回时,元素被拒绝并跳过。

谓词的签名是

fn(mixed $value, mixed $key): bool

array_filteriter\filter类似。

备注

可以用于类似结果的本地CallbackFilterIterator

new CallbackFilterIterator(Itera::toIterator($input), $predicate)

搜索:searchsearchOrFailfirstValuefirstKeyfirstValueOrDefaultfirstKeyOrDefault

这些是聚合函数,它们将立即消耗输入。

use Dakujem\Toru\Itera;

Itera::search(iterable $input, callable $predicate, mixed $default = null): mixed
Itera::searchOrFail(iterable $input, callable $predicate): mixed
Itera::firstValue(iterable $input): mixed
Itera::firstKey(iterable $input): mixed
Itera::firstValueOrDefault(iterable $input, mixed $default = null): mixed
Itera::firstKeyOrDefault(iterable $input, mixed $default = null): mixed

搜索返回谓词返回的第一个元素。

search在没有找到匹配元素时返回默认值,而searchOrFail抛出异常。

firstKeyfirstValue方法在输入为空集合时抛出异常,而*OrDefault变体在这种情况返回指定的默认值。

谓词的签名是

fn(mixed $value, mixed $key): bool

切片:slicelimitomit

use Dakujem\Toru\Itera;

Itera::limit(iterable $input, int $limit): iterable
Itera::omit(iterable $input, int $omit): iterable
Itera::slice(iterable $input, int $offset, int $limit): iterable

使用limit限制产生的元素数量,使用omit跳过开头的一定数量的元素,或使用sliceomitlimit合并到单个调用中。
键将被保留。

将零或负值传递给$limit产生一个空集合,
将零或负值传递给$omit/$offset产生整个集合。

注意,在省略时,选定的元素数量($omit/$offset)仍然被迭代,但不会产生。

array_slice类似,保留键。

注意
array_slice不同,键总是被保留。当需要删除键时,请使用Itera::valuesOnly

变更:valuesOnlykeysOnlyflip

创建一个只产生值、键或翻转它们的生成器。

use Dakujem\Toru\Itera;

Itera::valuesOnly(iterable $input): iterable
Itera::keysOnly(iterable $input): iterable
Itera::flip(iterable $input): iterable

flip函数与array_flip类似,
valuesOnly函数与array_values类似,
keysOnly函数与array_keys类似。

use Dakujem\Toru\Itera;

Itera::valuesOnly(['a' => 'Adam', 'b' => 'Betty']); // ['Adam', 'Betty']
Itera::keysOnly(['a' => 'Adam', 'b' => 'Betty']);   // ['a', 'b']
Itera::flip(['a' => 'Adam', 'b' => 'Betty']);       // ['Adam' => 'a', 'Betty' => 'b']

转换:toArraytoArrayValuestoArrayMergetoIteratorensureTraversable

这些函数立即使用输入。

将输入从通用iterable转换为array/Iterator

use Dakujem\Toru\Itera;

Itera::toArray(iterable $input): array
Itera::toArrayMerge(iterable $input): array
Itera::toArrayValues(iterable $input): array
Itera::toIterator(iterable $input): \Iterator
Itera::ensureTraversable(iterable $input): \Traversable

💡

一般来说,迭代器,特别是生成器,在转换为数组时具有挑战性。请阅读下面的“注意事项”部分。

Itera::toArray(Itera::chain([1,2], [3,4])); // --> [3,4] ❗

“转换为数组”操作有3种变体。

分叉:tapeach

创建一个生成器,在迭代时对每个元素调用一个效果函数。

use Dakujem\Toru\Itera;

Itera::tap(iterable $input, callable $effect): iterable
Itera::each(iterable $input, callable $effect): iterable  // alias for `tap`

效果函数的签名是

fn(mixed $value, mixed $key): void

返回值被丢弃。

重复:repeatloopreplicate

use Dakujem\Toru\Itera;

Itera::repeat(mixed $input): iterable
Itera::loop(iterable $input): iterable
Itera::replicate(iterable $input, int $times): iterable

repeat函数无限期地重复输入。
loop函数无限期地产生输入的各个元素。
replicate函数按指定次数产生输入的各个元素。

如果将repeatloop转换为数组,应将其包装在limitvaluesOnly中。

请注意,如果loopreplicate函数的输入是一个生成器,它们可能会遇到生成器的固有问题——不可重置和具有重叠索引。

生产:makeproduce

produce函数将创建一个无限生成器,在每次迭代时调用提供的生产函数。

use Dakujem\Toru\Itera;

Itera::produce(callable $producer): iterable

它应该与limit函数一起使用。

use Dakujem\Toru\Itera;

Itera::limit(Itera::produce(fn() => rand()), 1000); // a sequence of 1000 pseudorandom numbers
Itera::produce(fn() => 42); // an infinite sequence of the answer to life, the universe, and everything
Itera::produce(fn(int $i) => $i); // an infinite sequence of integers 0, 1, 2, 3, ...

make函数从其参数创建可迭代的集合。仅在需要迭代器(生成器)的情况下才使用,否则使用数组。

这两个函数仅作为静态Itera方法可用。

要同时产生键和值,可以使用unfold来包装produce,这将返回键值对。

use Dakujem\Toru\Itera;

Itera::unfold(
    Itera::produce(fn(int $i) => [ calculateKey($i) => calculateValue($i) ])
);

延迟评估

生成器函数是懒加载的。
调用生成器函数会创建一个生成器对象,但不会执行任何代码。
代码在迭代开始时执行(例如,通过foreach)。

通过将生成器作为输入传递给另一个生成器函数,该生成器被装饰并返回一个新的生成器。这种装饰仍然是懒加载的,并且尚未执行任何代码。

use Dakujem\Toru\Dash;
use Dakujem\Toru\Itera;

// Create a generator from an input collection.
$collection = Itera::apply(input: Itera::filter(input: $input, filter: $filter), values: $mapper);
// or using Dash
$collection = Dash::collect($input)->filter(filter: $filter)->apply(values: $mapper);


// No generator code has been executed so far.
// The evaluation of $filter and $mapper callables begins with the first iteration below.
foreach($collection as $key => $value) {
    // Only at this point the mapper and filter functions are executed,
    // once per element of the input collection.
    // The generator execution is then _paused_ until the next iteration.
}

💡
如果这种迭代在遍历整个集合之前终止(例如,通过break),则不会为剩余的元素调用调用函数。
这提高了在不知道集合将实际消耗多少元素的情况下,效率。

Toru提供的所有返回iterable的函数都使用生成器且是懒加载的。
示例:adjustmapchainfilterfliptapslicerepeat

其他通常返回mixed或标量值的函数是聚合,它们会立即迭代并执行生成器代码,耗尽输入中的生成器。
示例:reducecountsearchtoArrayfirstValue

使用键(索引)

所有方法(映射器、断言器、聚合器和效果函数)的可调用参数始终接收键和值。

这是与原生函数(如array_maparray_reducearray_walk,甚至默认设置下的array_filter)相比的关键优势。

而不是

$mapper = fn($value, $key) => /* ... */;
$predicate = fn($value, $key): bool => /* ... */;

$collection = array_filter(array_map($mapper, $array, array_keys($array)), $predicate, ARRAY_FILTER_USE_BOTH);

可能更方便

use Dakujem\Toru\Dash;

$mapper = fn($value, $key) => /* ... */;
$predicate = fn($value, $key): bool => /* ... */;

$collection = Dash::collect($array)->map($mapper)->filter($predicate);

使用array_reduce这会更复杂,因为没有方法可以将键传递给原生函数。
处理它的一个方法是,将数组值转换为包含索引,并修改reducer以考虑更改后的数据类型。

$myActualReducer = fn($carry, $value, $key) => /**/;

// Transform the array into a form that includes keys
$transformedArray = array_map(function($key, $value) {
    return [$value, $key];
}, array_keys($array), $array);

// Apply array_reduce
$result = array_reduce($transformedArray, function($carry, array $valueAndKey) use ($myActualReducer){ 
    [$value, $key] = $valueAndKey;
    return $myActualReducer($carry, $value, $key);
});

在这里,解决方案可能更简洁

use Dakujem\Toru\Itera;

$myActualReducer = fn($carry, $value, $key) => /**/;

$result = Itera::reduce($array, $myActualReducer);

自定义转换

为了在使用Dash时不会中断流畅的调用链,提供了两种方法来支持自定义转换

  • Dash::alter
    • 期望一个返回更改后的可迭代集合的装饰器函数
    • 将可迭代结果包装在新的Dash实例中,以继续流畅的调用链
  • Dash::aggregate
    • 期望一个返回任何值(mixed)的聚合函数
    • 结束流畅的链

Dash::alter将装饰器的返回值包装到新的Dash实例中,允许进行流畅的后续调用。

use Dakujem\Toru\Dash;

Dash::collect(['zero', 'one', 'two', 'three',])
    ->alter(function (iterable $collection): iterable {
        foreach ($collection as $k => $v) {
            yield $k * 2 => $v . ' suffix';
        }
    })
    ->alter(function (iterable $collection): iterable {
        foreach ($collection as $k => $v) {
            yield $k + 1 => 'prefix ' . $v;
        }
    })
    ->filter( /* ... */ )
    ->map( /* ... */ );

Dash::aggregate返回可调用参数产生的任何值,将其包装到新的Dash实例中。

缺少“键求和”函数?需要计算中位数吗?

use Dakujem\Toru\Dash;
use Dakujem\Toru\Itera;

$keySum = Dash::collect($input)
    ->filter( /* ... */ )
    ->aggregate(function (iterable $collection): int {
        $keySum = 0;
        foreach ($collection as $k => $v) {
            $keySum += $k;
        }
        return $keySum;
    }); // no more fluent calls, integer is returned

$median = Dash::collect($input)
    ->filter( /* ... */ )
    ->aggregate(function (iterable $collection): int {
        return MyCalculus::computeMedian(Itera::toArray($collection));
    }); // the median value is returned

扩展Toru

可以通过扩展Dash类来实现自定义转换或聚合,以便在流畅的调用链中使用。
为了与扩展Dash保持一致,可以扩展IteraIteraFn类。

use Dakujem\Toru\Dash;
use Dakujem\Toru\Itera;

class MyItera extends Itera
{
    /**
     * A shorthand for mapping arrays to use instead of `array_map`
     * with keys provided for the mapper.
     */
    public static function mapToArray(iterable $input, callable $mapper): iterable
    {
        return static::toArray(
            static::apply(input: $input, values: $mapper),
        );
    }

    public static function appendBar(iterable $input): iterable
    {
        return static::chain($input, ['bar' => 'bar']);
    }
}

class MyDash extends Dash
{
    public function appendFoo(): self
    {
        return new static(
            Itera::chain($this->collection, ['foo' => 'foo'])
        );
    }

    public function appendBar(): self
    {
        return new static(
            MyItera::appendBar($this->collection)
        );
    }

    public function aggregateZero(): int
    {
        return 0; // ¯\_(ツ)_/¯ 
    }
}

// 0 (zero)
MyDash::collect([1, 2, 3])->appendFoo()->appendBar()->aggregateZero();

// [1, 2, 3, 'foo' => 'foo', 'bar' => 'bar']
MyDash::collect([1, 2, 3])->appendFoo()->appendBar()->toArray();

// [1, 2, 3, 'foo' => 'foo', 'bar' => 'bar'] (generator)
MyItera::appendBar([1, 2, 3]);

使用全局别名

如果您想要一个全局别名来创建Dash包装的集合,例如_dash,最佳方式是在您的引导程序中注册全局函数,如下所示

use Dakujem\Toru\Dash;

if (!function_exists('_dash')) {
    function _dash(iterable $input): Dash {
        return Dash::collect($input);
    }
}

您也可以将此函数定义放置在文件中(例如/bootstrap/dash.php),并使用Composer自动加载。在您的composer.json文件中,添加自动加载规则如下

{
  "autoload": {
    "files": [
      "bootstrap/dash.php"
    ]
  }
}

您不再需要导入Dash类。

_dash($collection)->filter( /* ... */ )->map( /* ... */ )->toArray();

在定义全局函数___时要小心,因为它可能会与其他函数(例如Gettext扩展)或常见i8n函数别名发生冲突。

注意事项

生成器虽然功能强大,但也存在一些注意事项

  1. 处理键(索引)可能很棘手
  2. 生成器不可重置

在使用Toru之前请理解生成器,这可能有助于避免头痛
📖 生成器概述
📖 生成器语法

将生成器转换为数组时的键注意事项

在将生成器转换为数组时,存在两个原生挑战

  1. 重叠的键(索引)
  2. 键类型

重叠的键会在使用iterator_to_array时导致值被覆盖。
由于生成器可以产生任何类型的键,因此将它们用作数组键可能会导致TypeError异常。

chaintoArray(或iterator_to_array)的组合类似于原生的array_replace

use Dakujem\Toru\Itera;

Itera::toArray(
    Itera::chain([1, 2], [3, 4])
);

结果将是[3, 4],这可能是意外的。原因是可迭代的(在这种情况下是数组)具有重叠的键,在转换为数组时,后面的值会覆盖前面的值。

use Dakujem\Toru\Itera;

Itera::toArray(
    Itera::chain([
        0 => 1, 
        1 => 2,
    ], [
        0 => 3,
        1 => 4,
    ])
);

在遍历迭代器时不存在此问题

use Dakujem\Toru\Itera;

foreach(Itera::chain([1, 2], [3, 4]) as $key => $value){
    echo "$key: $value\n";
}

上面的代码将正确输出

0:1
1:2
0:3
1:4

查看此代码的实际效果: 生成器键冲突

如果我们能够丢弃键,那么最快的解决方案是使用toArrayValues,它是链式调用Itera::toArray(Itera::valuesOnly( $input ))的缩写。

如果我们想要模拟array_merge的行为,Toru提供了toArrayMerge函数。
这个变体保留了关联键,同时丢弃了数字键。

use Dakujem\Toru\Itera;

Itera::toArrayMerge(
    Itera::chain([
        0 => 1,
        1 => 2,
        'foo' => 'bar',
    ], [
        0 => 3,
        1 => 4,
    ])
);

该调用将生成以下数组

[
     0 => 1,
     1 => 2,
     'foo' => 'bar',
     2 => 3,
     3 => 4,
]

💡

请注意,生成器通常可以产生任何类型的键,但当转换为数组时,只有可用作原生数组键的值被允许,对于其他值类型的键,将会抛出 TypeError

生成器不可重置

一旦生成器的迭代开始,对其调用 rewind 将会抛出错误。可以使用提供的 Regenerator 迭代器解决这个问题(见下文)。

支持内容

Regenerator

Regenerator 是对返回迭代器的可调用对象的透明包装,特别是 生成器对象。这些可以是直接的 生成器函数,或者包装它们的可调用对象。

Regenerator 允许 重置生成器,这在一般情况下是不允许的。生成器实际上并没有被重置,但在每次重置时都会重新创建。

由于本库中的大多数迭代原语都是使用生成器实现的,这可能会很有用。

注意:使用 foreach 迭代时,重置会自动发生。

让我们用一个例子来说明。

$generatorFunction = function(): iterable {
    yield 1;
    yield 2;
    yield 'foo' => 'bar';
    yield 42;
};

$generatorObject = $generatorFunction();

foreach ($generatorObject as $key => $val) { /* ... */ }
// subsequent iteration will throw an exception
// Exception: Cannot rewind a generator that was already run
foreach ($generatorObject as $key => $val) { /* ... */ }

可以通过对每个迭代重复调用 生成器函数 来解决这个问题。

// Instead of iterating over the same generator object, the generator function
// is called multiple times. Each call creates a new generator object.
foreach ($generatorFunction() as $key => $val) { /* ... */ }
// A new generator object is created with every call to $generatorFunction().
foreach ($generatorFunction() as $key => $val) { /* ... */ } // ✅ no exception

在大多数情况下,这将是解决方案,但有时需要 iterable/Traversable 对象。

use Dakujem\Toru\Dash;

// Not possible, the generator function is not iterable itself
$it = Dash::collect($generatorFunction);           // TypeError

// Not possible, the argument to `collect` must be iterable
$it = Dash::collect(fn() => $generatorFunction()); // TypeError

// The correct way is to wrap the generator returned by the call,
// but it has the same drawback as described above
$dash = Dash::collect($generatorFunction());       
foreach ($dash->filter($filterFn) as $val) { /* ... */ }
// Exception: Cannot rewind a generator that was already run
foreach ($dash->filter($otherFilterFn) as $val) { /* ... */ } // fails

这就是 Regenerator 发挥作用的地方。

use Dakujem\Toru\Dash;
use Dakujem\Toru\Regenerator;

$dash = new Regenerator(fn() => Dash::collect($generatorFunction()));
foreach ($dash->filter($filterFn) as $val) { /* ... */ }
foreach ($dash->filter($otherFilterFn) as $val) { /* ... */ } // works, hooray!

Regenerator 在需要时(即每次重置时)内部调用提供函数,同时实现 Traversable 接口。

存储中间值

由于大多数 Toru 函数调用都返回生成器,将中间值存储在变量中会遇到同样的问题。

use Dakujem\Toru\Itera;

$filtered = Itera::filter($input, $predicate);
$mapped = Itera::apply($filtered, $mapper);

foreach($mapped as $k => $v) { /* ...*/ }

// Exception: Cannot rewind a generator that was already run
foreach($filtered as $k => $v) { /* ...*/ }

同样,解决方案可能是创建一个函数,如下所示

use Dakujem\Toru\Itera;

$filtered = fn() => Itera::filter($input, $predicate);
$mapped = fn() => Itera::apply($filtered(), $mapper);

foreach($mapped() as $k => $v) { /* ...*/ }

// this will work, the issue is mitigated by iterating over a new generator
foreach($filtered() as $k => $v) { /* ...*/ }

或者,Regenerator 类也很有用。

use Dakujem\Toru\Itera;
use Dakujem\Toru\Regenerator;

$filtered = new Regenerator(fn() => Itera::filter($input, $predicate));
$mapped = new Regenerator(fn() => Itera::apply($filtered(), $mapper));

foreach($mapped as $k => $v) { /* ...*/ }

// In this case, the regenerator handles function calls.
foreach($filtered as $k => $v) { /* ...*/ }

管道

这是一个简单的处理管道实现。与 IteraFn 类结合使用,可以组合处理算法。

use Dakujem\Toru\IteraFn;
use Dakujem\Toru\Pipeline;

$alteredCollection = Pipeline::through(
    $collection,
    IteraFn::filter(predicate: $filterFunction),
    IteraFn::map(values: $mapper),
);
// Pipelines are not limited to producing iterable collections, they may produce any value types:
$average = Pipeline::through(
    $collection,
    IteraFn::filter(predicate: $filterFunction),
    IteraFn::reduce(reducer: fn($carry, $current) => $carry + $current, initial: 0),
    fn(int $sum) => $sum / $sizeOfCollection,
);

为什么需要迭代器

PHP 已经提供了对数组的广泛支持,为什么还需要迭代器呢?
有很多情况下,迭代器可能比数组更高效。通常当处理大(或可能甚至无限)集合时。

迭代器的使用场景类似于 资源。你知道将上传的文件放入字符串变量并不是总是最好的主意。对于小文件来说,这肯定没问题,但试着用 4K 视频试试。

一个很好的例子可能是目录迭代器。
可能会有多少个文件?几十个?还是几百万个?将这些放入数组可能会很快耗尽应用程序的内存储备。

那么为什么使用 iterable 类型提示而不是 array 呢?
仅仅是为了尽可能扩展函数/方法的潜在用途。

内存效率

生成器的效率来源于这样一个事实:在进行链式多个集合、过滤、映射等操作时,不需要分配额外的内存

另一方面,foreach 块总是执行得更快,因为没有涉及额外的函数调用。根据您的用例,性能差异可能可以忽略不计。

然而,在云环境中,内存可能很昂贵。这是一个权衡。

在实际场景中,如果启用了 OpCache,使用 Toru 将会以最小的/可忽略不计的影响减少内存使用。

例如,将多个集合链接到一起而不是使用 array_merge 将会更高效。

https://3v4l.org/Ymksm
https://3v4l.org/OmUb3
https://3v4l.org/HMasj

如果您对性能感到担忧,请使用 /tests/performance 中的比较脚本与您的实际环境进行比较。

替代方案

您可能不需要这个库。

  • mpetrovich/dash 提供了一系列转换函数,内部使用 数组
  • lodash-php/lodash-php 模仿 Lodash,并提供了一系列实用工具,内部使用 数组
  • nikic/iter 使用 生成器 实现了一系列迭代原语,由 PHP 核心团队成员编写
  • illuminate/collections 应该能满足大多数 Laravel 开发者的需求,提供基于数组和基于生成器的两种实现
  • 在许多情况下,使用 foreach 就能完成任务

Toru 库(dakujem/toru)不提供完整的预定义转换函数,而是提供最常见的函数以及引入和组合自定义转换的方法。
它与上述库以及其他类似的库配合得很好。

Toru 最初是作为 nikic/iter 的替代品开始使用的,后者对我来说界面有点繁琐。
Itera 静态 尝试通过使用单个类导入而不是多个函数导入,并重新排序参数,使输入集合始终是第一个,来解决这一点。
然而,将多个操作组合成一个转换感觉有点繁琐,因此实现了 IteraFn 工厂来解决这个问题。它工作得很好,但对于日常任务来说,仍然有点冗长。
为了允许简洁的流畅/链式调用(如 Lodash),然后设计了 Dash 类。
有了它,就可以整洁地组合转换。

贡献和未来开发

意图不是提供大量特定的函数,而是提供大多数使用情况下的工具。

尽管如此,高质量的 PR 仍将被接受。

可能的添加包括

  • combine 值和键
  • zip 多个可迭代对象(Python,Haskell 等)
  • alternate 多个可迭代对象(负载均衡元素,混合)

附录:带注释的示例代码

各种方法的说明

观察下面的代码,了解 foreachDash 如何解决一个简单问题。了解何时以及为什么 Dash 可能比单独使用 Itera 更合适。

use Dakujem\Toru\Itera;
use Dakujem\Toru\IteraFn;
use Dakujem\Toru\Pipeline;
use Dakujem\Toru\Dash;

$sequence = Itera::produce(fn() => rand()); // infinite iterator

// A naive foreach may be the best solution in certain cases...
$count = 0;
$array = [];
foreach ($sequence as $i) {
    if (0 == $i % 2) {
        $array[$i] = 'the value is ' . $i;
        if ($count >= 1000) {
            break;
        }
    }
}

// While the standalone static methods may be handy,
// they are not suited for complex computations.
$interim = Itera::filter($sequence, fn($i) => 0 == $i % 2);
$interim = Itera::reindex($interim, fn($i) => $i);
$interim = Itera::apply($interim, fn($i) => 'the value is ' . $i);
$interim = Itera::limit($interim, 1000);
$array = Itera::toArray($interim);

// Without the interim variable(s), the reading order of the calls is reversed
// and the whole computation is not exactly legible.
$array = Itera::toArray(
    Itera::limit(
        Itera::apply(
            Itera::reindex(
                Itera::filter(
                    $sequence,
                    fn($i) => 0 == $i % 2,
                ),
                fn($i) => $i,
            ),
            fn($i) => 'the value is ' . $i,
        ),
        1000,
    )
);

// Complex pipelines may be composed using partially applied callables.
$array = Pipeline::through(
    $sequence,
    IteraFn::filter(fn($i) => 0 == $i % 2),
    IteraFn::reindex(fn($i) => $i),
    IteraFn::apply(fn($i) => 'the value is ' . $i),
    IteraFn::limit(1000),
    IteraFn::toArray(),
);

// Lodash-style fluent call chaining.
$array = Dash::collect($sequence)
    ->filter(fn($i) => 0 == $i % 2)
    ->reindex(fn($i) => $i)
    ->map(fn($i) => 'the value is ' . $i)
    ->limit(1000)
    ->toArray();

示例:列出目录中的图像

让我们解决一个简单的任务:递归列出目录中的所有图像。

您也可以让生成式 AI 做这件事,或者提出类似这样的方案

$images = [];
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir));
/** @var SplFileInfo $fileInfo */
foreach ($iterator as $fileInfo) {
    // Skip directories
    if ($fileInfo->isDir()) {
        continue;
    }
    // Get the full path of the file
    $filePath = $fileInfo->getPathname();
    // Reject non-image files (hacky)
    if (!@getimagesize($filePath)) {
        continue;
    }
    $images[$filePath] = $fileInfo;
}

这将在开发中工作,但如果尝试列出 数百万 张图像,将对您的服务器产生巨大影响,这在中型内容项目中并不罕见。

解决这个问题的方式是利用生成器

$listImages = function(string $dir): Generator {
    $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir));
    /** @var SplFileInfo $fileInfo */
    foreach ($iterator as $fileInfo) {
        // Skip directories
        if ($fileInfo->isDir()) {
            continue;
        }
        // Get the full path of the file
        $filePath = $fileInfo->getPathname();
        // Reject non-image files (hacky)
        if (!@getimagesize($fileInfo->getPathname())) {
            continue;
        }
        yield $filePath => $fileInfo;
    }
};
$images = $listImages($dir);

那么,如果您能创建一个等效的生成器,就像这样...

$images = _dash(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir))) // recursively iterate over a dir
    ->filter(fn(SplFileInfo $fileInfo) => !$fileInfo->isDir())                       // reject directories
    ->filter(fn(SplFileInfo $fileInfo) => @getimagesize($fileInfo->getPathname()))   // accept only images (hacky)
    ->reindex(fn(SplFileInfo $fileInfo) => $fileInfo->getPathname());                // key by the full file path

现在这取决于个人喜好。两种方法都能解决问题,并且效率相同。