Почему ссылка на объект преждевременно разрушена? - PullRequest
0 голосов
/ 03 мая 2020

Я создал простой класс VBA с параметризованным конструктором. Класс имеет VB_PredeclaredID = True. Эта разработка выполняется на Ma c в Office 365. Код ниже. (Код не является пуленепробиваемым. Я создал этот простой пример, чтобы показать проблему, обнаруженную в более сложном классе.) Когда выполняется 5-я строка процедуры Make, вызывается обработчик Class_Terminate для объекта, созданного во 2-й строке. т. е. тот, который управляет блоком «с». Class_Terminate падает при выходе с ошибкой переполнения. (В моем более сложном примере ошибка «без конца».) Я установил отладку, поэтому я знаю, что свойство Birthday никогда не вызывается в строке 5. Может ли кто-нибудь объяснить мне, что в моем коде вызывает желание системы уничтожить ссылку на объект, когда он еще используется, и как я могу обойти это? Спасибо.

Sub TestClass()
    Dim cl As CTest
    Set cl = CTest.Make(DateValue("12/6/1946"))
    Debug.Print "TestClass", IIf(Not cl Is Nothing, cl.Birthday, "Nothing")
End Sub

Private m_birthday As Date
Private m_otherdata As Variant

Private Sub Class_Initialize()
    Debug.Print "Enter Initialize"
    If Me Is CTest Then
        m_birthday = DateValue("1/1/1800")
    Else
        m_birthday = Now()
    End If
    Debug.Print "Exit Initialize", m_birthday
End Sub

Private Sub Class_Terminate()
End Sub

Public Function Make(varparam As Variant) As CTest
  If Me Is CTest Then
      With New CTest
          Select Case VarType(varparam)
              Case vbDate:
                  .Birthday = varparam
              Case vbObject:
                  .Birthday = varparam.Birthday
          End Select
          Set Make = .Self
      End With
  ElseIf varparam Is Nothing Then
      With New CTest
          .Birthday = Me.Birthday
          If (VarType(Me.OtherData)) = vbObject Then
            Set .OtherData = Me.OtherData
          Else
            .OtherData = Me.OtherData
          End If
          Set Make = .Self
      End With
  Else
    Set Make = Nothing
  End If
End Function

Public Property Get Self() As CTest
    Set Self = Me
End Property

Public Property Get Birthday() As Date
    Birthday = m_birthday
End Property

Public Property Let Birthday(val As Date)
    m_birthday = val
End Property

Public Property Get OtherData() As Variant
    OtherData = m_otherdata
End Property

Public Property Let OtherData(val As Variant)
    m_otherdata = val
End Property

Public Property Set OtherData(val As Variant)
    Set m_otherdata = val
End Property

Ответы [ 2 ]

0 голосов
/ 03 мая 2020

Я должен снять свою шапку с @MathieuGuindon и других парней и образов в Rubberduck, так как мое понимание VBA значительно улучшилось благодаря чтению блогов Rubberduck.

Я тоже пережил некоторые интересные времена, используя PredeclaredId и, следовательно, предлагают некоторые из моих мыслей о том, как код OP должен быть построен. Поскольку я все еще развиваю свое понимание OOP в VBA, люди могут свободно стрелять в меня в огне, если я ошибаюсь или неправильно понимаю вещи.

Есть две вещи, которые я развил из идей, представленных в Блоги rubberduck.

This

Я различаю 'this' на p, s, b и u, представляющие определения типов свойств, State, BaseInstance и Using.

Self

Я продолжаю создание экземпляра класса и передаю параметры Make в вызов метода Self. Таким образом, параметры можно использовать для настройки закрытых членов нового экземпляра без необходимости публиковать свойства c.

Option Explicit

Sub TestCTest()

    Dim myCTest As CTest

    ' no errors
    Set myCTest = CTest.Make(DateValue("4/6/2020"))
    Debug.Print myCTest.Birthday

    On Error Resume Next
    ' Gives "CTest: Expecting Variant/Date or Variant/CTest: Found String"
    Set myCTest = CTest.Make("4/6/2020")
    Debug.Print Err.Description

    On Error GoTo 0
    On Error Resume Next
    Dim myCtest2 As CTest
    'Gives "CTest: Make should only be used with the PredeclaredId"
    Set myCtest2 = myCTest.Make(DateValue("4/6/2020"))
    Debug.Print Err.Description

    On Error GoTo 0
    On Error Resume Next
    ' Gives "New is not permitted outside of the Make Method" error
    Dim myCtest3 As CTest
    Set myCtest3 = New CTest
    Debug.Print Err.Description
    On Error GoTo 0

End Sub

Class CTest

Option Explicit
'@PredeclaredId

' Variables used as the private repositories for public properties are located here
Private Type Properties

    Birthday                As Date
    OtherData               As Variant ' OP may have a specific type in mind
    ' NewIsAllowed appears in every instance but we will only ever use
    ' the value in the predeclared Id to toggle if new is or is not allowed
    ' via the AllowNew property
    NewIsAllowed            As Boolean

End Type

Private p As Properties

' If any were present the State type would be used for variables representing
' the state of the instance but which are not intended to be made public through Properties
' Private Type State
'   StateVar1 as Typename
' End Type
'
' Private s As State
'
' Used only for PredeclaredId to allow boilerplate code to be written
Private Type BaseInstance

    PredeclaredId           As CTest

End Type

Private b As BaseInstance

Private Sub Class_Initialize()

    ' This method runs the **first** time the **PredecalredID** is used in an expression
    ' and for every subsequent use of New.  Therefore managing what happens for the PredeclaredId
    ' vs instances can become a bit Eulerish.

    ' Declaring b.predeclaredId allows us to boilerplate code elsewhere
    ' as it means that the only places that the actual class name is used
    ' is here ,the Type declaration above and other method declarations.
    Set b.PredeclaredId = CTest

    ' The code to exit on the first use of the PredeclaredID in an expression
    If Me Is b.PredeclaredId Then Exit Sub

    ' Trap the use of New when not used by the Make Function
    ' the code below means that bad code will be detected at testing time
    If Not AllowNew Then

        Err.Raise 445 + vbObjectError, TypeName(Me), TypeName(Me) & ": New is not permitted outside of the Make method"

    End If

End Sub

Public Function Make(ByVal varparam As Variant) As CTest
    ' From the OP code we are expecting varparam to be either
    ' a Date , a CTest object or nothing
    If InStr("Date,CTest,Empty,Null,Nothing", TypeName(varparam)) = 0 Then

       Err.Raise 13 + vbObjectError, TypeName(Me), TypeName(Me) & ": Expecting Variant/Date or Variant/CTest: Found " & TypeName(varparam)

    End If


    ' In the OP code it is not clear if the OP has
    ' restricted the use of the Make function to CTest.Make or 
    ' allows the use of <instance>.Make.
    ' Both uses are legal as Make is a public method but
    ' in the spirit of declaring a PredecalredId it is
    ' preferable to restrict the use of Make to CTest.Make
    ' Thus the code below detects the use of Make by an instance.
    If Not Me Is b.PredeclaredId Then

        Err.Raise 445 + vbObjectError, TypeName(Me), TypeName(Me) & ": Make should only be used with the PredeclaredId"

    End If

    ' Instruct the PredeclaredId that New is allowed
    AllowNew = True

    With New CTest

        Set Make = .Self(varparam)

    End With

    ' Instruct the PredeclaredId to disallow the use of new
    AllowNew = False

End Function

Public Function Self(ByVal varparam As Variant) As CTest

    ' This code is inside the new instance that is being constructed.
    ' Therefore there is free access to the private variables of the
    ' instance 'under construction'



    ' Its a little difficult to untangle the OP logic for what constitutes
    ' the birthday so the Case statement below may well be incorrect

    Select Case TypeName(varparam)

        Case "Empty", "Null", "Nothing"

            ' The Me in the OP code occurs in the Make function and
            ' consequently refers to the instance of which Make was called.
            ' IF make was used as discussed above this implies that Me is b.PredecalredId
            ' only if the OP has adhered to CTest.Make
            ' If this is the case????
            p.Birthday = DateValue("1/1/1800")

            ' The OP assigns otherdata in the case of nothing
            ' using Me.Otherdata.  The Me will now refer to the
            ' the instance under construction so it is likely that a second
            ' parameter will be required for the Make function
                       '

        Case "Date"

            p.Birthday = CDate(varparam)

        Case "CTest"

            Dim myCTest As CTest
            Set myCTest = varparam
            p.Birthday = myCTest.Birthday

        Case Else

            Err.Raise 13 + vbObjectError, TypeName(Me), TypeName(Me) & ": Expecting Variant/Date or Variant/CTest: Found " & TypeName(varparam)

    End Select

    Set Self = Me

End Function

' The alternative to the AllowNew property is to have a public AllowNew field.
' but as the code below is bolerplate and can be copied to new classes without issue
' I'm happy to use the code below.

' Due to the differentiation of p,s,b
' we have an easily identifiable warning to check if we
' see anything but the p. prefix in Property declarations.
Public Property Get AllowNew() As Boolean

    If Me Is b.PredeclaredId Then
        AllowNew = p.NewIsAllowed
    Else
        AllowNew = b.PredeclaredId.AllowNew
    End If

End Property

Public Property Let AllowNew(ByVal Value As Boolean)

    If Me Is b.PredeclaredId Then
        p.NewIsAllowed = Value
    Else
        b.PredeclaredId.AllowNew = Value
    End If

End Property

Public Property Get Birthday() As Date
    Birthday = p.Birthday
End Property

Public Property Let Birthday(ByVal val As Date)
    p.Birthday = val
End Property

Public Property Get OtherData() As Variant
    OtherData = p.OtherData
End Property

Public Property Let OtherData(ByVal val As Variant)
    p.OtherData = val
End Property

Public Property Set OtherData(ByVal val As Variant)
    Set p.OtherData = val
End Property
0 голосов
/ 03 мая 2020

Я создал этот простой пример, чтобы показать проблему, обнаруженную в более сложном классе

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

Private Sub Class_Initialize()
    Debug.Print "Enter Initialize"
    If Me Is CTest Then
        m_birthday = DateValue("1/1/1800")
    Else
        m_birthday = Now()
    End If
    Debug.Print "Exit Initialize", m_birthday
End Sub

Полезная часть информации, которую вы не выводите, является ли инициализирующий экземпляр экземпляром по умолчанию. Рассмотрим:

Debug.Print "Initializing " & TypeName(Me) & IIf(Me Is CTest, " (default instance)", vbNullString)

Одна проблема заключается в следующем:

    If Me Is CTest Then
        m_birthday = DateValue("1/1/1800") '<~
    Else
        m_birthday = Now()
    End If

Если текущий экземпляр является экземпляром класса по умолчанию, внутреннее состояние бесполезно. Сохранение экземпляра по умолчанию без сохранения состояния является ключевым, на самом деле: m_birthday является подробностью реализации для интерфейса класса по умолчанию (CTest). Это было бы лучшим защитным предложением:

    If Me Is CTest Then Exit Sub
    m_birthday = Now()

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

Теперь, если вы введете это в ближайшей панели :

Set a = New CTest

Вы получите такой вывод:

Initializing CTest (default instance)
Initializing CTest

Вы пропустив этот след:

Private Sub Class_Terminate()
    Debug.Print "Terminating " & TypeName(Me) & IIf(Me Is CTest, " (default instance)", vbNullString)
End Sub

При заводском методе Make вы на самом деле хотите еще более сильное спасение:

Public Function Make(varparam As Variant) As CTest
  If Me Is CTest Then
    '...

Рассмотрим:

Public Function Make(varparam As Variant) As CTest
    If Not Me Is CTest Then Err.Raise 5, TypeName(Me), "Member call is only valid from default/predeclared instance."

И это удаляет ветку в условном пути. Это также заставляет меня задуматься об этом:

  ElseIf varparam Is Nothing Then

Это условие оценивается, когда Me Is CTest равно False, то есть когда фабричный метод вызывается из пользовательского экземпляра ... и это не должно допускаться.

Это еще одна проблема:

      Select Case VarType(varparam)
          Case vbDate:
              .Birthday = varparam
          Case vbObject:
              .Birthday = varparam.Birthday

vbObject означает, что varparam является ссылкой Object, а не CTest object: поскольку мы работаем с Variant, вызов члена имеет позднюю привязку, поэтому, если у объекта нет члена Birthday, здесь возникает ошибка 438 времени выполнения. Мы можем сохранить вызов члена с поздней привязкой, но по-прежнему проверять тип:

Case vbObject:
    If TypeOf varparam Is CTest Then .Birthday = varparam.Birthday

Или вы можете получить проверку во время компиляции, введя переменную:

Case vbObject:
    Dim typedParam As CTest
    If TypeOf varparam Is CTest Then 
        Set typedParam = varparam
        .Birthday = typedParam.Birthday '<~ early-bound member call now
    End If

Это не только помогает компилятор подхватывает опечатки (даже Option Explicit не может спасти вас от опечатки при вызове с поздним связыванием), он также помогает использовать инструменты анализа кода, такие как Rubberduck , которые теперь «видят» вызов участника: если член переименован, инструменты рефакторинга теперь могут обновить этот сайт вызовов - это невозможно сделать с кодом с поздним связыванием.

Public Property Get Self() As CTest
    Set Self = Me
End Property

Это синтаксический сахар, который хорошо работает когда задействован явный интерфейс, для чистого отделения экземпляра CTest по умолчанию без состояния от явного ICTest клиентского интерфейса (который может включать Property Get на день рождения, но без Let метода доступа).

Улучшенный синтаксический сахар, который не влияет на интерфейсы publi c ваших классов и значительно очищает окно инструментов localals в модулях классов, переводя состояние экземпляра в * 1 090 *:

Private Type TState
    Birthday As Date
    OtherData As Variant '<~ note: this breaks strong-typing and gets you back into late-bound land.
End Type
Private this As TState

Эта Private this переменная экземпляра (на уровне модуля) заменяет все переменные с префиксом m_, и теперь свойство Birthday выглядит так:

Public Property Get Birthday() As Date
    Birthday = this.Birthday
End Property

Public Property Let Birthday(ByVal val As Date)
    this.Birthday = val
End Property

...

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

Напишите отдельную частную функцию, которая работает с Date, другим, который работает с CTest объектом и условно вызывает соответствующий объект из Make.

С функциями, которые делают меньше вещей, меньшее количество вещей может go ошибаться.

Защитите свои методы - если метод включает состояние экземпляра, запретите вызывать его из экземпляра по умолчанию / предварительно объявленного. Если предполагается, что метод вызывается из экземпляра по умолчанию, запретите вызывать его из других экземпляров.

См. эту статью для обновления шаблона, а этот чтобы увидеть это в действии с реальным кодом.

...