Привет, Хабр! Представляю вашему вниманию перевод статьи "Functional PowerShell with Classes.
I promise it’s not an oxymoron"
автора Christopher Kuech.


Объектно-ориентированная и функциональная парадигмы программирования могут казаться не в ладах друг с другом, но обе в равной мере поддерживаются в Powershell. Практически все программные языки, функциональные и нет, имеют средства расширенного связывания имён и значений; Классы, подобно struct-ам и record-ам, это всего лишь один подход. Если мы ограничим использование Классов связыванием имён и значений и станем избегать таких "тяжёлых" объектно-ориентированных программных концепций, как наследование, полиморфизм, или изменяемость (mutability), мы сможем использовать их преимущества, не усложняя наш код. Далее, добавляя неизменяемые (immutable) методы преобразования типов, мы можем обогатить Классами наш функциональный код.


Магия кастов


Касты одна из самых мощных фич в Powershell. Когда вы подвергаете значение касту, вы полагаетесь на добавляемую средой в ваше приложение возможность неявных инициализации и валидации. Например, простой каст строки в [xml] прогонит её через код парсера и сгенерирует полное дерево xml. Мы можем в своём коде использовать Классы с той же целью.


Каст хэштаблиц


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


class Cluster {
    [ValidatePattern("^[A-z]+$")]
    [string] $Service
    [ValidateSet("TEST", "STAGE", "CANARY", "PROD")]
    [string] $FlightingRing
    [ValidateSet("EastUS", "WestUS", "NorthEurope")]
    [string] $Region
    [ValidateRange(0, 255)]
    [int] $Index
}

[Cluster]@{
    Service       = "MyService"
    FlightingRing = "PROD"
    Region        = "EastUS"
    Index         = 2
}

Кроме того, каст помогает получить чистый вывод. Сравните вывод массива хэштаблиц Cluster переданный в Format-Table c тем, что получится, если прежде кастовать эти хэштаблицы в класс. Свойства класса всегда перечисляются в том порядке, в котором они там определены. Не забудьте добавить ключевое слово hidden перед всеми теми свойствами, которые не должны быть видны в выдаче.


image

Каст значений


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


class Cluster {
    [ValidatePattern("^[A-z]+$")]
    [string] $Service
    [ValidateSet("TEST", "STAGE", "CANARY", "PROD")]
    [string] $FlightingRing
    [ValidateSet("EastUS", "WestUS", "NorthEurope")]
    [string] $Region
    [ValidateRange(0, 255)]
    [int] $Index

    Cluster([string] $id) {
        $this.Service, $this.FlightingRing, $this.Region, $this.Index = $id -split "-"
    }
}

[Cluster]"MyService-PROD-EastUS-2"

Каст к строке


Также вы можете переопределить метод класса [string] ToString(), чтобы определить логику строкового представления объекта, например использовать интерполяцию строк.


class Cluster {
    [ValidatePattern("^[A-z]+$")]
    [string] $Service
    [ValidateSet("TEST", "STAGE", "CANARY", "PROD")]
    [string] $FlightingRing
    [ValidateSet("EastUS", "WestUS", "NorthEurope")]
    [string] $Region
    [ValidateRange(0, 255)]
    [int] $Index

    [string] ToString() {
        return $this.Service, $this.FlightingRing, $this.Region, $this.Index -join "-"
    }
}

$cluster = [Cluster]@{
    Service       = "MyService"
    FlightingRing = "PROD"
    Region        = "EastUS"
    Index         = 2
}

Write-Host "We just created a model for '$cluster'"

Каст сериализованных инстансов


Каст позволяет безопасную десериализацию. Примеры ниже завершатся ошибкой, если данные не отвечают нашей спецификации в Cluster


# Валидация сериализованных данных

[Cluster]$cluster = Get-Content "./my-cluster.json" | ConvertFrom-Json
[Cluster[]]$clusters = Import-Csv "./my-clusters.csv"


Касты в вашем функциональном коде


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


Функциональный ли Powershell я пишу?


Много людей пришедших из C# или с подобным прошлым пишут Powershell, который похож на С#. Поступая так, вы отказываетесь от использования концепций функционального программирования и, вероятно, получите пользу усиленно погрузившись в объектно-ориентированное программирование в Powershell или лучше изучив функциональное программирование.


Если вы глубоко полагаетесь на трансформацию иммутабельных данных, используя конвейеры (|), Where-Object, ForEach-Object, Select-Object, Group-Object, Sort-Object и т. д. — у вас более функциональный стиль, и вам поможет использование классов Powershell в функциональном стиле.


Функциональное использование классов


Касты, хотя и используют альтернативный синтаксис, всего лишь маппинг между двумя доменами. В конвейере можно смапить массив значений, используя ForEach-Object.


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


# Пример комбинирования классов с конвейерами для separation of concerns в конвейерах

class Node {
    [ValidateLength(3, 7)]
    [string] $Name
    [ValidateSet("INT", "PPE", "PROD")]
    [string] $FlightingRing
    [ValidateSet("EastUS", "WestUS", "NorthEurope", "WestEurope")]
    [string] $Region
    Node([string] $Name) {
        $Name -match "([a-z]+)(INT|PPE|PROD)([a-z]+)"
        $_, $this.Service, $this.FlightingRing, $this.Region = $Matches
        $this.Name = $Name
    }
}

class Datum {
    [string] $Name
    [int] $Value
    [Node] $Computer
    [int] Severity() {
        $this.Name -match "[0-9]+$"
        return $Matches[0]
    }
}

Write-Host "Urgent Security Audit Issues:"
Import-Csv "./audit-results.csv" `
    | ForEach-Object {[Datum]$_} `
    | Where-Object Value -gt 0 `
    | Group-Object {$_.Severity()} `
    | Where-Object Name -lt 2 `
    | ForEach-Object Group `
    | ForEach-Object Computer `
    | Where-Object FlightingRing -eq "PROD" `
    | Sort-Object Name, Region -Unique


Упаковка класса для переиспользования


Ничто не столь уж хорошо, как кажется


К сожалению, классы не могут быть экспортированы модулями тем же образом, как функции или переменные; но хитрости кое-какие есть. Допустим, ваши классы определены в файле ./my-classes.ps1


  • Вы можете сделать дотсорсинг файла с классами:. ./my-classes.ps1. Это выполнит my-classes.ps1 в вашей текущей области видимости и определит там все классы из файла.


  • Вы можете создать модуль Powershell, который экспортирует все ваши пользовательские API (командлеты) и установить переменную ScriptsToProcess = "./my-classes.ps1" в манифесте вашего модуля, с тем же результатом: ./my-classes.ps1 выполнится в вашем окружении.



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


Путь вперед


Лучший способ избежать проблем с разрешением типов — никогда не выставлять для юзеров ваши классы. Вместо того, чтобы ожидать, что юзер импортирует определённый в классе тип, экспортируйте из вашего модуля функцию, которая освобождает от необходимости напрямую обращаться к классу. Применительно к Cluster, мы можем экспортировать функцию New-Cluster, которая поддержит дружественные пользователю наборы параметров и вернёт Cluster.


class Cluster {
    [ValidatePattern("^[A-z]+$")]
    [string] $Service
    [ValidateSet("TEST", "STAGE", "CANARY", "PROD")]
    [string] $FlightingRing
    [ValidateSet("EastUS", "WestUS", "NorthEurope")]
    [string] $Region
    [ValidateRange(0, 255)]
    [int] $Index
}

function New-Cluster {
    [OutputType([Cluster])]
    Param(
        [Parameter(Mandatory, ParameterSetName = "Id", Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string] $Id,
        [Parameter(Mandatory, ParameterSetName = "Components")]
        [string] $Service,
        [Parameter(Mandatory, ParameterSetName = "Components")]
        [string] $FlightingRing,
        [Parameter(Mandatory, ParameterSetName = "Components")]
        [string] $Region,
        [Parameter(Mandatory, ParameterSetName = "Components")]
        [int] $Index
    )

    if ($Id) {
        $Service, $FlightingRing, $Region, $Index = $Id -split "-"
    }

    [Cluster]@{
        Service       = $Service
        FlightingRing = $FlightingRing
        Region        = $Region
        Index         = $Index
    }
}

Export-ModuleMember New-Cluster

Что ещё почитать


About Classes
Defensive PowerShell
Functional Programming in PowerShell