PowerShellPracticeAndStyle icon indicating copy to clipboard operation
PowerShellPracticeAndStyle copied to clipboard

How to deal with a module that contains multiple interdependent classes?

Open iRon7 opened this issue 1 year ago • 6 comments
trafficstars

Related to issue #59, for my ObjectGraphTools project, I would like to separate my interdependent classes contained by a single file over multiple files to make my module better manageable.

Is there any recommended best practice for this issue?

For details, see: https://stackoverflow.com/questions/68657692/is-it-possible-to-declare-two-interdependent-classes-each-in-a-separate-file

iRon7 avatar Apr 15 '24 09:04 iRon7

Single file is the best practice, due to all the issues involved with classes that likely will never be fixed because classes were originally intended just as a framework to make implementing DSC resources easier and not meant to be general purpose.

If you use a tool like VSCode, you have the ability to jump to definition and use the Outline view to more effectively manage this single file.

In PowerShell, you should generally use classes just for object modeling and intellisense, and only add methods that:

  • override internal behavior (e.g. ToString and op_addition)
  • implement interfaces for stuff like argument completors:
  • Provide explicit/implicit constructors/converters form other types.

Everything else should be done in functions, do not put business logic into methods. If this is what you want to do, write it in C#/F# and import it to your module as an assembly instead.

JustinGrote avatar Apr 16 '24 16:04 JustinGrote

@JustinGrote, thanks for you guidelines, the "class issues" and the VSCode Outline View referral. Just for background on my specific use case: I guess it falls under points 1 (override internal behavior) and 3 (Provide explicit/implicit constructors/converters). The project (ObjectGraphTools) were I am working on doesn't concern business logic but extensive PowerShell logic. For this, I have even an additional reason why I am using classes vs. functions:

Meanwhile I have 4 ([PSSerialize], [PSDeserialize], [Xdn] and [PSNode]) (interdepended) major classes in a single "class" file (of nearly 1200 lines, and I am not done yet).

Anyways, I don't have enough knowledge of C# (and zero of F#) to do the same in one of these languages 😞. Besides, I guess it will be more work due to the PowerShell/C# language differences (as e.g. strict vs. loosely).

iRon7 avatar Apr 17 '24 07:04 iRon7

Yeah, like Justin said, at runtime, any PowerShell classes that you want to expose outside your module (whether as parameters or outputs) basically have to be defined within the root module -- the main psm1 file.

You don't necessarily have to author them that way, if you merge them for publishing -- the discussion in #59 (which we moved to #171) resulted in the PoshCode/ModuleBuilder project, which works well for that.

However, the PowerShell editor in VSCode still doesn't automatically see types from other files (even when you open a folder), and the way they run PSScriptAnalyzer means it will complain about it, if your classes reference each other...

Jaykul avatar Apr 18 '24 05:04 Jaykul

there is a lot of recursive functionally in the project due to the nature of object-graphs, therefore I am also concerned about the function/method performance difference.

While I don't have any facts to back this up I would be surprised if there was any major difference between a cmdlet and a PowerShell class method. When you define a PowerShell class the underlying .NET IL code is essentially just invoking the ScriptBlock of the method which is essentially what a function is. For example

class MyClass {
    [string]Method([string]$Foo) {
        return $Foo
    }
}

$c = [MyClass]::new()
$c.Method('test')

Calling $c.Method() here is equivalent to this psuedo code

$sbk = {
    param([string]$Foo)

    return $Foo
}
$sbk.Invoke('test')

It is definitely more complicated than that psuedo code but the general workflow applies. A function is a scriptblock so it has a very similar behaviour when it comes to invoking them.

If you have recursion you should probably want to avoid that type of logic in favour of things like stacks/queues/loops.

jborean93 avatar Apr 18 '24 05:04 jborean93

@jborean93,

While I don't have any facts to back this up

$Iterations = 10
$MaxDepth   = 6

class MyClass {
    static $Iterations = 10
    static $MaxDepth   = 6
    Method([int]$Depth) {
        if ($Depth -ge [MyClass]::MaxDepth) { return }
        $NewDepth = $Depth + 1
        for ($i = 0; $i -lt [MyClass]::Iterations; $i++) {
            # Write-Host $i 'Depth:' $Depth
            $this.Method($NewDepth)
        }
    }
}

@{ 
    Class = (Measure-Command {
        [MyClass]::Iterations = $Iterations
        [MyClass]::MaxDepth   = $MaxDepth
        $c = [MyClass]::new()
        $c.Method(0)
    }).TotalMilliseconds
}

$sbk = {
    param([int]$Depth)
    if ($Depth -ge $MaxDepth) { return }
    $NewDepth = $Depth + 1
    for ($i = 0; $i -lt $Iterations; $i++) {
        # Write-Host $i 'Depth:' $Depth
        $sbk.Invoke($NewDepth)
    }
}

@{ 
    ScriptBlock = (Measure-Command {
        $sbk.Invoke(0)
    }).TotalMilliseconds
}
Name                           Value
----                           -----
Class                          2797.1561
ScriptBlock                    4784.1456

Both examples iterate 10 times at every level (up to 6 levels). This means that the method/function is called $Iterate^$MaxDepth (= 10^6 = 1.000.000) times.

iRon7 avatar Apr 18 '24 10:04 iRon7

Correct, WikiPedia quote:

It follows that, for problems that can be solved easily by iteration, recursion is generally less efficient.

Meaning that for complex problems you not only need to handle the call stack but also isolate the variables in the current function scope (as the $Depth variable in the above example).

iRon7 avatar Apr 18 '24 12:04 iRon7