company_banner

Правильно пишем командлеты на Powershell и заодно симулируем парадокс Монти Холла

    Хабр точно знаком с парадоксом, а вот с некоторыми фичами павершелла, вероятно, нет, поэтому тут больше про него.




    Используем пайплайн в Powershell


    Алгоритм прост, первым идет генератор случайных дверей, затем генератор выбора пользователя, затем логика открытия дверей ведущим, еще одно действие пользователя и подсчет статистики.

    А поможет нам в этом павершельный ValueFromPipeline, который позволяет указывать командлет один за другим, трансформируя объект шаг за шагом. Вот примерно должен выглядеть наш пайплайн:

    New-Doors | Select-Door | Open-Door | Invoke-UserAction

    New-Doors генерирует новые двери, в команде Select-Door игрок выбирает одну из дверей, в Open-Door ведущий открывает дверь в которой точно нет козы и которая не была выбрана игроком, а в Invoke-UserAction мы симулируем разное поведение пользователя.

    Объект, описывающий двери, подается слева направо постепенно преобразовываясь.

    Такой метод написания кода помогает разделять его на куски с четким разделением по ответственности.

    В Powershell есть свои конвенции. В том числе, конвенции по правильному наименованию функций, их тоже нужно соблюдать и мы их почти соблюдаем.

    Делаем двери


    Так как мы собираемся симулировать ситуацию, подробно опишем еще и двери.

    Дверь содержит либо козу, либо автомобиль. Дверь может быть выбрана игроком или открыта ведущим.

    class Door {
        <#
        Модель данных, где описана каждая дверь. 
        Выбрана ли она игроком и открыта ли она ведущим.
        #>
        [string]$Contains = "Goat"
        [bool]$Selected = $false
        [bool]$Opened = $false
    }
    

    Каждую из дверей мы поместим в отдельное поле в отдельном классе.

    class Doors {
        <#
        Модель данных, где описаны 3 двери
        #>
        [Door]$DoorOne 
        [Door]$DoorTwo 
        [Door]$DoorThree
    }

    Можно было их поместить все двери в массив, но чем подробнее все будет описано, тем, лучше. Кстати в Powershell 7, классы, их конструкторы, методы и все остальное ООП, которое работает почти как надо, но об этом в другой раз. 

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

    function New-Doors {
        <#
        Генератор случайных дверей.
        #>
        $i = [Doors]::new()
     
        $i.DoorOne = [Door]::new()
        $i.DoorTwo = [Door]::new()
        $i.DoorThree = [Door]::new()
     
        switch ( Get-Random -Maximum 3 -Minimum 0 ) {
            0 { 
                $i.DoorOne.Contains = "Car"
            }
            1 { 
                $i.DoorTwo.Contains = "Car"
            }
            2 { 
                $i.DoorThree.Contains = "Car"
            }
            Default {
                Write-Error "Something in door generator went wrong"
                break
            }
        }
        
        return $i
    

    Наш пайп выглядит так:

    New-Doors

    Игрок выбирает дверь


    Теперь опишем изначальный выбор.  Игрок может выбрать одну из трех дверей. Для целей симуляции большего количества ситуаций, пусть игрок сможет выбирать только первую, только вторую, только третью и случайную дверь каждый раз. 

    [Parameter(Mandatory)]
    [ValidateSet("First", "Second", "Third", "Random")]
    $Principle

    Чтобы принимать аргументы из пайплайна, в блоке параметров нужно указать переменную, которая будет это делать. Делается это так:

    [parameter(ValueFromPipeline)]
    [Doors]$i

    Можно писать ValueFromPipeline без True.

    Вот так выглядит законченный блок выбора двери:

    function Select-Door {
        <#
        Игрок выбирает дверь.
        #>
        Param (
            [parameter(ValueFromPipeline)]
            [Doors]$i,
            [Parameter(Mandatory)]
            [ValidateSet("First", "Second", "Third", "Random")]
            $Principle
        )
        
        switch ($Principle) {
            "First" {
                $i.DoorOne.Selected = $true
            }
            "Second" {
                $i.DoorTwo.Selected = $true
            }
            "Third" {
                $i.DoorThree.Selected = $true
            }
            "Random" {
                switch ( Get-Random -Maximum 3 -Minimum 0 ) {
                    0 { 
                        $i.DoorOne.Selected = $true
                    }
                    1 { 
                        $i.DoorTwo.Selected = $true
                    }
                    2 { 
                        $i.DoorThree.Selected = $true
                    }
                    Default {
                        Write-Error "Something in door selector went wrong"
                        break
                    }
                }
            }
            Default {
                Write-Error "Something in door selector went wrong"
                break
            }
        }
     
        return $i 
    

    Наш пайп выглядит так:

    New-Doors | Select-Door -Principle Random

    Ведущий открывает дверь


    Тут все очень просто. Если дверь не была выбрана игроком и если за ней коза, то меняем поле Opened на True.  Конкретно в это случае называть команду словом Open не корректно, вызываемый ресурс не читается, а изменяется. В подобных случаях используйте Set, а Open оставим для наглядности.

    function Open-Door {
        <#
        Ведущий открывает дверь с козой, но не ту, что выбрал игрок.
        #>
        Param (
            [parameter(ValueFromPipeline)]
            [Doors]$i
        )
        switch ($false) {
            $i.DoorOne.Selected {
                if ($i.DoorOne.Contains -eq "Goat") {
                    $i.DoorOne.Opened = $true
                    continue
                }
               
            }
            $i.DoorTwo.Selected { 
                if ($i.DoorTwo.Contains -eq "Goat") {
                    $i.DoorTwo.Opened = $true
                    continue
                }
               
            }
            $i.DoorThree.Selected { 
                if ($i.DoorThree.Contains -eq "Goat") {
                    $i.DoorThree.Opened = $true
                    continue
                }
                
            }
        }
        return $i
    

    Для пущей убедительности нашей симуляции мы «открываем» эту дверь, меняя поле .opened на $true, а не удаляем объект из массива дверей.

    Не забывайте про continue в свитчах, сравнение не останавливается после первого совпадения. Coninue выходит из свитча и продолжает выполнять скрипт, а оператор break в свитче завершит работу скрипта.

    Добавляем еще одну функцию в пайп, он он теперь выглядит так:

    New-Doors | Select-Door -Principle Random | Open-Door

    Игрок меняет выбор 


    Игрок либо меняет дверь, либо не меняет. В блоке параметров у нас только переменная из пайпа и булёвый аргумент. 

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

    function Invoke-UserAction {
        <#
        Ситуация, где игрок менят или не меняет свой выбор.
        #>
        Param (
            [parameter(ValueFromPipeline)]
            [Doors]$i,
            [Parameter(Mandatory)]
            [bool]$SwitchDoor
        )
     
        if ($true -eq $SwitchDoor) {
            switch ($false) {
                $i.DoorOne.Opened {  
                    if ( $i.DoorOne.Selected ) {
                        $i.DoorOne.Selected = $false
                    }
                    else {
                        $i.DoorOne.Selected = $true
                    }
                }
                $i.DoorTwo.Opened {
                    if ( $i.DoorTwo.Selected ) {
                        $i.DoorTwo.Selected = $false
                    }
                    else {
                        $i.DoorTwo.Selected = $true
                    }
                }
                $i.DoorThree.Opened {
                    if ( $i.DoorThree.Selected ) {
                        $i.DoorThree.Selected = $false
                    }
                    else {
                        $i.DoorThree.Selected = $true
                    }
                }
            }  
        }
     
        return $i
    

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

    Еще одна функция в пайплайн.

    New-Doors | Select-Door -Principle Random | Open-Door | Invoke-UserAction -SwitchDoor $True

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

    Поведение игрока


    Как часто игрок меняет дверь. Предусмотрены 5 линий поведения:

    1. Never – игрок никогда не меняет свой выбор
    2. Fifty-Fifty – 50 на 50. Количество симуляций делится на два прохода. Первый проход игрок не меняет дверь, второй проход меняет.
    3. Random – в каждой новой симуляции игрок подкидывает монетку
    4. Always – игрок всегда меняет свой выбор.
    5. Ration – игрок меняет выбор в N% случаях.

    switch ($SwitchDoors) {
            "Never" { 
                0..$Count | ForEach-Object {
                    $Win += Invoke-Simulation -Door $Door -SwitchDoors $false
                }
                continue
            }
            "FiftyFifty" {
                $Fifty = [math]::Round($Count / 2)
     
                0..$Fifty | ForEach-Object {
                    $Win += Invoke-Simulation -Door $Door -SwitchDoors $false
                }
     
                0..$Fifty | ForEach-Object {
                    $Win += Invoke-Simulation -Door $Door -SwitchDoors $true
                }
                continue
            }
            "Random" {
                0..$Count | ForEach-Object {
                    [bool]$Random = Get-Random -Maximum 2 -Minimum 0
                    $Win += Invoke-Simulation -Door $Door -SwitchDoors $Random
                }
                continue
            }
            "Always" {
                0..$Count | ForEach-Object {
                    $Win += Invoke-Simulation -Door $Door -SwitchDoors $true
                }
                continue
            }
            "Ratio" {
                $TrueRatio = $Ratio / 100 * $Count 
                $FalseRatio = $Count - $TrueRatio
     
                0..$TrueRatio | ForEach-Object {
                    $Win += Invoke-Simulation -Door $Door -SwitchDoors $true
                }
     
                0..$FalseRatio | ForEach-Object {
                    $Win += Invoke-Simulation -Door $Door -SwitchDoors $false
                }
                continue
            }
        }
    

    ForEach-Object в Powershell 7 работает значительно быстрее цикла for, плюс, может быть распараллелен, поэтому тут используется вместо цикла for.

    Оформляем командлет


    Теперь нужно правильно дооформить командлет. Первым делом, нужно сделать валидацию входящих аргументов. Бонус не только в том, что человек не может ввести неверный аргумент в поле, но еще список всех доступных аргументов появляется в подсказках.

    Так выглядит код в блоке параметров:

    param (
            [Parameter(Mandatory = $false,
                HelpMessage = "How often the player changes his choice.")]
            [ValidateSet("Never", "FiftyFifty", "Random", "Always", "Ratio")]
            $SwitchDoors = "Random"
        )
    

    Так выглядит подсказка:


    Перед блоком параметров можно сделать comment based help. Вот так выглядит код перед блоком параметров:

    
      <#
          .SYNOPSIS
       
          Performs monty hall paradox simulation.
       
          .DESCRIPTION
       
          The Invoke-MontyHallParadox.ps1 script invoke monty hall paradox simulation.
       
          .PARAMETER Door
          Specifies door the player will choose during the entire simulation
       
          .PARAMETER SwitchDoors
          Specifies principle how the player changes his choice.
       
          .PARAMETER Count
          Specifies how many times to run the simulation.
       
          .PARAMETER Ratio
          If -SwitchDoors Ratio, specifies how often the player changes his choice. As a percentage."
       
          .INPUTS
       
          None. You cannot pipe objects to Update-Month.ps1.
       
          .OUTPUTS
       
          None. Update-Month.ps1 does not generate any output.
       
          .EXAMPLE
       
          PS> Invoke-MontyHallParadox -SwitchDoors Always -Count 10000
       
          #>
    

    Вот так выглядит сама подсказка:


    Запускаем симуляцию


    Результаты симуляции:


    Если человек никогда не меняет свой выбор, то он побеждает в 33,37% случаев.

    В случае двух проходов, в половине которых мы отказываемся менять свой выбор, шансы на победу составляют 49.9134%, что очень близко к ровным 50%.

    В случае подкидывания монетки ничего не меняется, шанс на победу остается в районе 50,131%.

    Ну а если игрок всегда меняет свой выбор, шанс на победу повышается до 66,6184%, иными словами, скучно и ничего нового.

    Производительность:

    Что касается производительности. Скрипт кажется не оптимальным. String вместо Bool, много разных функций со свитчаим внутри, передающих друг другу объект, но тем не менее, вот результаты Measure-Command по этому скрипту и скрипту от другого автора.

    Сравнение проводилось на двух системах, везде стоял pwsh 7.1,  100 000 проходов.

    ▍I5-5200u


    Этот алгоритм:

    Days              : 0
    Hours             : 0
    Minutes           : 0
    Seconds           : 4
    Milliseconds      : 581
    Ticks             : 45811819
    TotalDays         : 5,30229386574074E-05
    TotalHours        : 0,00127255052777778
    TotalMinutes      : 0,0763530316666667
    TotalSeconds      : 4,5811819
    TotalMilliseconds : 4581,1819

    Тот алгоритм:

    Days              : 0
    Hours             : 0
    Minutes           : 0
    Seconds           : 5
    Milliseconds      : 104
    Ticks             : 51048392
    TotalDays         : 5,9083787037037E-05
    TotalHours        : 0,00141801088888889
    TotalMinutes      : 0,0850806533333333
    TotalSeconds      : 5,1048392
    TotalMilliseconds : 5104,8392

    ▍I9-9900K


    Этот алгоритм:

    Days              : 0
    Hours             : 0
    Minutes           : 0
    Seconds           : 1
    Milliseconds      : 891
    Ticks             : 18917629
    TotalDays         : 2,18954039351852E-05
    TotalHours        : 0,000525489694444444
    TotalMinutes      : 0,0315293816666667  
    TotalSeconds      : 1,8917629
    TotalMilliseconds : 1891,7629

    Тот алгоритм:

    Days              : 0
    Hours             : 0
    Minutes           : 0
    Seconds           : 1
    Milliseconds      : 954
    Ticks             : 19543236
    TotalDays         : 2,26194861111111E-05
    TotalHours        : 0,000542867666666667
    TotalMinutes      : 0,03257206
    TotalSeconds      : 1,9543236
    TotalMilliseconds : 1954,3236

    Преимущество 63 мс, но результаты все равно очень странные, учитывая сколько раз в скрипте сравниваются строки.

    Автор надеется, что эта статья послужит убедительным примером для тех, кто считает что шансы всегда составляют 50 на 50, ну а ознакомиться с кодом вы можете под этим спойлером.

    Весь код
    class Doors {
    <#
    Модель данных, где описаны 3 двери
    #>
    [Door]$DoorOne
    [Door]$DoorTwo
    [Door]$DoorThree
    }

    class Door {
    <#
    Модель данных, где описана каждая дверь.
    Выбрана ли она игроком и открыта ли она ведущим.
    #>
    [string]$Contains = «Goat»
    [bool]$Selected = $false
    [bool]$Opened = $false
    }

    function New-Doors {
    <#
    Генератор случайных дверей.
    #>
    $i = [Doors]::new()

    $i.DoorOne = [Door]::new()
    $i.DoorTwo = [Door]::new()
    $i.DoorThree = [Door]::new()

    switch ( Get-Random -Maximum 3 -Minimum 0 ) {
    0 {
    $i.DoorOne.Contains = «Car»
    }
    1 {
    $i.DoorTwo.Contains = «Car»
    }
    2 {
    $i.DoorThree.Contains = «Car»
    }
    Default {
    Write-Error «Something in door generator went wrong»
    break
    }
    }

    return $i
    }

    function Select-Door {
    <#
    Игрок выбирает дверь.
    #>
    Param (
    [parameter(ValueFromPipeline)]
    [Doors]$i,
    [Parameter(Mandatory)]
    [ValidateSet(«First», «Second», «Third», «Random»)]
    $Principle
    )

    switch ($Principle) {
    «First» {
    $i.DoorOne.Selected = $true
    continue
    }
    «Second» {
    $i.DoorTwo.Selected = $true
    continue
    }
    «Third» {
    $i.DoorThree.Selected = $true
    continue
    }
    «Random» {
    switch ( Get-Random -Maximum 3 -Minimum 0 ) {
    0 {
    $i.DoorOne.Selected = $true
    continue
    }
    1 {
    $i.DoorTwo.Selected = $true
    continue
    }
    2 {
    $i.DoorThree.Selected = $true
    continue
    }
    Default {
    Write-Error «Something in selector generator went wrong»
    break
    }
    }
    continue
    }
    Default {
    Write-Error «Something in door selector went wrong»
    break
    }
    }

    return $i
    }

    function Open-Door {
    <#
    Ведущий открывает дверь с козой, но не ту, что выбрал игрок.
    #>
    Param (
    [parameter(ValueFromPipeline)]
    [Doors]$i
    )
    switch ($false) {
    $i.DoorOne.Selected {
    if ($i.DoorOne.Contains -eq «Goat») {
    $i.DoorOne.Opened = $true
    continue
    }
    }
    $i.DoorTwo.Selected {
    if ($i.DoorTwo.Contains -eq «Goat») {
    $i.DoorTwo.Opened = $true
    continue
    }
    }
    $i.DoorThree.Selected {
    if ($i.DoorThree.Contains -eq «Goat») {
    $i.DoorThree.Opened = $true
    continue
    }
    }
    }
    return $i
    }

    function Invoke-UserAction {
    <#
    Ситуация, где игрок менят или не меняет свой выбор.
    #>
    Param (
    [parameter(ValueFromPipeline)]
    [Doors]$i,
    [Parameter(Mandatory)]
    [bool]$SwitchDoor
    )

    if ($true -eq $SwitchDoor) {
    switch ($false) {
    $i.DoorOne.Opened {
    if ( $i.DoorOne.Selected ) {
    $i.DoorOne.Selected = $false
    }
    else {
    $i.DoorOne.Selected = $true
    }
    }
    $i.DoorTwo.Opened {
    if ( $i.DoorTwo.Selected ) {
    $i.DoorTwo.Selected = $false
    }
    else {
    $i.DoorTwo.Selected = $true
    }
    }
    $i.DoorThree.Opened {
    if ( $i.DoorThree.Selected ) {
    $i.DoorThree.Selected = $false
    }
    else {
    $i.DoorThree.Selected = $true
    }
    }
    }
    }

    return $i
    }

    function Get-Win {
    Param (
    [parameter(ValueFromPipeline)]
    [Doors]$i
    )
    switch ($true) {
    ($i.DoorOne.Selected -and $i.DoorOne.Contains -eq «Car») {
    return $true
    }
    ($i.DoorTwo.Selected -and $i.DoorTwo.Contains -eq «Car») {
    return $true
    }
    ($i.DoorThree.Selected -and $i.DoorThree.Contains -eq «Car») {
    return $true
    }
    default {
    return $false
    }
    }
    }

    function Invoke-Simulation {
    param (
    [Parameter(Mandatory = $false,
    HelpMessage = «Which door the player will choose during the entire simulation.»)]
    [ValidateSet(«First», «Second», «Third», «Random»)]
    $Door = «Random»,

    [bool]$SwitchDoors
    )
    return New-Doors | Select-Door -Principle $Door | Open-Door | Invoke-UserAction -SwitchDoor $SwitchDoors | Get-Win
    }

    function Invoke-MontyHallParadox {
    <#
    .SYNOPSIS

    Performs monty hall paradox simulation.

    .DESCRIPTION

    The Invoke-MontyHallParadox.ps1 script invoke monty hall paradox simulation.

    .PARAMETER Door
    Specifies door the player will choose during the entire simulation

    .PARAMETER SwitchDoors
    Specifies principle how the player changes his choice.

    .PARAMETER Count
    Specifies how many times to run the simulation.

    .PARAMETER Ratio
    If -SwitchDoors Ratio, specifies how often the player changes his choice. As a percentage."

    .INPUTS

    None. You cannot pipe objects to Update-Month.ps1.

    .OUTPUTS

    None. Update-Month.ps1 does not generate any output.

    .EXAMPLE

    PS> Invoke-MontyHallParadox -SwitchDoors Always -Count 10000

    #>
    param (
    [Parameter(Mandatory = $false,
    HelpMessage = «Which door the player will choose during the entire simulation.»)]
    [ValidateSet(«First», «Second», «Third», «Random»)]
    $Door = «Random»,

    [Parameter(Mandatory = $false,
    HelpMessage = «How often the player changes his choice.»)]
    [ValidateSet(«Never», «FiftyFifty», «Random», «Always», «Ratio»)]
    $SwitchDoors = «Random»,

    [Parameter(Mandatory = $false,
    HelpMessage = «How many times to run the simulation.»)]
    [uint32]$Count = 10000,

    [Parameter(Mandatory = $false,
    HelpMessage = «How often the player changes his choice. As a percentage.»)]
    [uint32]$Ratio = 30
    )

    [uint32]$Win = 0

    switch ($SwitchDoors) {
    «Never» {
    0..$Count | ForEach-Object {
    $Win += Invoke-Simulation -Door $Door -SwitchDoors $false
    }
    continue
    }
    «FiftyFifty» {
    $Fifty = [math]::Round($Count / 2)

    0..$Fifty | ForEach-Object {
    $Win += Invoke-Simulation -Door $Door -SwitchDoors $false
    }

    0..$Fifty | ForEach-Object {
    $Win += Invoke-Simulation -Door $Door -SwitchDoors $true
    }
    continue
    }
    «Random» {
    0..$Count | ForEach-Object {
    [bool]$Random = Get-Random -Maximum 2 -Minimum 0
    $Win += Invoke-Simulation -Door $Door -SwitchDoors $Random
    }
    continue
    }
    «Always» {
    0..$Count | ForEach-Object {
    $Win += Invoke-Simulation -Door $Door -SwitchDoors $true
    }
    continue
    }
    «Ratio» {
    $TrueRatio = $Ratio / 100 * $Count
    $FalseRatio = $Count — $TrueRatio

    0..$TrueRatio | ForEach-Object {
    $Win += Invoke-Simulation -Door $Door -SwitchDoors $true
    }

    0..$FalseRatio | ForEach-Object {
    $Win += Invoke-Simulation -Door $Door -SwitchDoors $false
    }
    continue
    }
    }

    Write-Output («Player won in » + $Win + " times out of " + $Count)
    Write-Output («Whitch is » + ($Win / $Count * 100) + "%")

    return $Win
    }

    #Invoke-MontyHallParadox -SwitchDoors Always -Count 500000




    RUVDS.com
    VDS/VPS-хостинг. Скидка 10% по коду HABR

    Comments 4

      +2

      Ни в одной из ваших функций, которые используют
      [parameter(ValueFromPipeline)]
      нет блока PROCESS {}


      Вы учите как НЕ надо писать коммандлеты....

        –1

        Расскажите подробнее. Периодически пишу скрипты на поше, но так глубоко не вкапывался :)

          +8

          Пайплайны прежде всего предназначены для потоковой обработки данных.


          В статье же предлагается использовать пайплайны в функциональном стиле.


          Это, конечно, не запрещено, но надо понимать разницу. А для начинающего эта разница может оказаться "фатальной"


          Банальнейший пример:


          >function addition([parameter(valuefrompipeline)]$v) { $v+1 }
          >1,2,3 | addition

          Написавший функцию будет ожидать ответ


          2
          3
          4

          но получит просто


          4

          Классическая "полная" функция с поддержкой пайплайнов — состоит из трёх функциональных блоков


          function Do-Something([Parameter(ValueFromPipeline)]$param, $simpleParam) {
            BEGIN { # подготовка пайплайна к работе, инициализация }
            PROCESS { # собственно работа }
            END { # Финальная обработка, выдача суммарного результата, очистка }
          }

          Обычные параметры видны во всех трёх блоках, те что поддерживают пайплайн — обычно в PROCESS{}, он вызывается многократно, для каждого из значений на входе,
          последнее из пайплайна видно также в END{}
          Функции из статьи — это функции у которых есть только блок END {}
          Они, конечно, получат своё значение из помеченного ValueFromPipeline параметра, но только последнее.
          Поэтому чтобы получить ожидаемые 2.3.4 правильно было бы написать


          >function addition([parameter(valuefrompipeline)]$v) { PROCESS{ $v+1 } }

          В случае всего одного параметра на входе ничего не сломается, а при нескольких будет работать правильно


          Вот пример использования всех блоков


          >function Get-Summary([parameter(valuefrompipeline)]$v) {
           BEGIN{ $s = 0 }
           PROCESS{ $s+=$v; $v }
           END{ "Total: $s" }
           }
          > 1,2,3 | Get-Summary
          1
          2
          3
          Total: 6
            0

            Замечательный ответ, большое спасибо!

      Only users with full accounts can post comments. Log in, please.