Functional Powershell with classes is not an oxymoron, I guarantee it

Hey Habr! I present to your attention the translation of the article "Functional PowerShell with Classes.
I promise it's not an oxymoron"
by Christopher Kuech.

The object-oriented and functional programming paradigms may seem at odds with each other, but both are equally supported in Powershell. Almost all programming languages, functional and not, have facilities for extended name and value binding; Classes, like structs and records, are just one approach. If we limit our use of Classes to binding names and values, and avoid "heavy" object-oriented programming concepts like inheritance, polymorphism, or mutability, we can take advantage of them without complicating our code. Further, by adding immutable type conversion methods, we can enrich our functional code with Classes.

Cast Magic

Casting is one of the most powerful features in Powershell. When you cast a value, you rely on the environment to add implicit initialization and validation to your application. For example, a simple cast of a string in [xml] will run it through the parser code and generate a complete xml tree. We can use Classes in our code for the same purpose.

Cast hashtables

If you don't have a constructor, you can continue without one by casting the hashtable to your class type. Don't forget to use validation attributes to take full advantage of this pattern. At the same time, we can use typed class properties to run even deeper initialization and validation logic.

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
}

In addition, casting helps to get a clean output. Compare the output of the Cluster hashtable array passed to Format-Table with what happens if you cast these hashtables to the class first. Class properties are always listed in the order in which they are defined there. Don't forget to add the hidden keyword in front of any properties you don't want to be visible in the output.

Functional Powershell with classes is not an oxymoron, I guarantee it

Cast values

If you have a constructor with one argument, casting a value to your class type will pass a value to that constructor of yours, in which you can initialize your class instance

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"

Cast to string

You can also override the [string] ToString() class method to define logic for the object's string representation, such as using string interpolation.

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'"

Cast serialized instances

Cast allows safe deserialization. The examples below will fail if the data does not meet our Cluster specification

# Валидация сСриализованных Π΄Π°Π½Π½Ρ‹Ρ…

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

Castes in your function code

Functional programs first define data structures, then implement the program as a sequence of transformations over immutable data structures. Despite the contradictory impression, classes really help to write functional code, thanks to the type conversion methods.

Am I writing a functional Powershell?

Many people who come from C# or with a similar background write Powershell which is similar to C#. In doing so, you forego using functional programming concepts and will probably benefit from a deep dive into object-oriented programming in Powershell or by learning more about functional programming.

If you rely heavily on transforming immutable data using pipelines (|), Where-Object, ForEach-Object, Select-Object, Group-Object, Sort-Object, etc., you have a more functional style and using Powershell classes in functional style.

Functional use of classes

Castes, although using an alternate syntax, are just a mapping between two domains. You can map an array of values ​​in a pipeline using ForEach-Object.

In the example below, the Node constructor is executed every time a Datum is cast, and this saves us from writing a fair amount of code. As a result, our pipeline focuses on declarative data query and aggregation, while our classes take care of data parsing and validation.

# ΠŸΡ€ΠΈΠΌΠ΅Ρ€ комбинирования классов с ΠΊΠΎΠ½Π²Π΅ΠΉΠ΅Ρ€Π°ΠΌΠΈ для 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

Packing a class for reuse

Nothing is as good as it seems

Unfortunately, classes cannot be exported by modules in the same way as functions or variables; but there are some tricks. Let's say your classes are defined in the file ./my-classes.ps1

  • You can dotsource a file with classes:. ./my-classes.ps1. This will execute my-classes.ps1 in your current scope and define all the classes in the file there.

  • You can create a Powershell module that exports all your custom APIs (cmdlets) and set the variable ScriptsToProcess = "./my-classes.ps1" in your module manifest, with the same result: ./my-classes.ps1 will be executed in your environment .

Whichever option you choose, keep in mind that the Powershell type system cannot resolve similarly named types loaded from different places.
Even if you have loaded two identical classes with the same properties from different places, you run the risk of running into problems.

The way forward

The best way to avoid type resolution problems is to never expose your classes to users. Instead of expecting the user to import the type defined in the class, export a function from your module that frees you from having to directly access the class. When applied to Cluster, we can export the New-Cluster function, which will support user-friendly parameter sets and return a 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

What else to read

About Classes
Defensive PowerShell
Functional Programming in PowerShell

Source: habr.com

Add a comment