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

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

00cvloz-yxkfnlscs5ltuj4hkh4.png

Используем пайплайн в 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"
    )


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

1062932e8b98f59b863fceb1dd0f4295.png


Перед блоком параметров можно сделать 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
   
      #>


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

46972361728bf2b78ac9969682ad53af.png


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


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

2b616bcc179dc378bc45a0bd1b205da8.png


Если человек никогда не меняет свой выбор, то он побеждает в 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

8xzqbhb0at3_pjylb5c4366w_t8.png

oug5kh6sjydt9llengsiebnp40w.png

© Habrahabr.ru