3

I am trying to figure out how to work on a specific row among a big range. However it appears that a range created with the rows property does not behave the same as a simple range. Check the following code, the first time the variable SpecificRow is defined, it is not possible to select a specific cell. However with a weird workaround that redefines the range, it works fine. Do you have an idea why and how could I define the range with a more elegant way?

'The following shows the weird behavior of Rows property
Dim SourceRng As Range
Dim SpecificRow As Range
Dim i As Long

i = 3

Set SourceRng = Range("A1:D20")
Set SpecificRow = SourceRng.Rows(i)

'This will show the address of the selected row ("A3:D3")
MsgBox SpecificRow.Address

'Unexplicable behavior of the range when trying to select a specific cell
'where it will instead consider the whole row (within the limits of SourceRng)
MsgBox SpecificRow(1).Address
MsgBox SpecificRow(2).Address

'This would send an error
'MsgBox SpecificRow(1, 1).Address

'Workaround
Set SpecificRow = Intersect(SpecificRow, SpecificRow)

'The following will select the same address than before
MsgBox SpecificRow.Address

'However, now it has a correct behavior when selecting a specific cell
MsgBox SpecificRow(1).Address
MsgBox SpecificRow(2).Address
Kersijus
  • 87
  • 1
  • 7
  • 2
    Using `range(x)` directly sometimes does what you think it does and sometimes it does not. I'm not sure what the exact rules are, but I always use `range.cells(x)` to avoid confusion. It might very well be that a range remembers that it was created by a call to the `Rows` property and will treat the argument to its default property as the row number, but I never saw documentation on that. – GSerg Jan 03 '17 at 15:39
  • 1
    It kind of makes sense if you consider that it is allowed to go outside of a range's bounds when using it, i.e. if you define a range with three cells, you can still ask for `range(4)` and get a meaningful result. In order to do that, a range must know in which direction and in which steps it is going to be moving. I see it as a controversial feature where Excel is trying to outsmart you and gets you confused instead. A good question though. – GSerg Jan 03 '17 at 15:59
  • I never use `Range.Cells()` but instead use `Range.Offset()` and/or `Range.Resize()`. – John Alexiou Jan 03 '17 at 20:24
  • @GSerg Thanks a lot. I always thought that using Cells property was superfluous on a Range. As you pointed out, adding it to the "SpecificRow" variable works better. – Kersijus Jan 04 '17 at 08:24

3 Answers3

5

You should expect weird behavior if you're passing indexed properties the incorrect parameters. As demonstrated by your code, the Range returned by SourceRng.Rows(i) is actually correct. It just isn't doing what you think it's doing. The Rows property of a Range just returns a pointer to the exact same Range object that it was called on. You can see that in its typelib definition:

HRESULT _stdcall Rows([out, retval] Range** RHS);

Note that it doesn't take any parameters. The returned Range object is what you're providing the indexing for, and you're indexing it based on it's default property of Item (technically it's _Default, but the 2 are interchangeable). The first parameter (which is the only one you're passing with Rows(i), is RowIndex. So Rows(i) is exactly the same thing as Rows.Item(RowIndex:=i). You can actually see this in the IntelliSense tooltip that pops up when you provide a Row index:

IntelliSense

Excel handles the indexing differently on this call though, because providing any value parameter for the second parameter is a Run-time error '1004'. Note that a similar property call is going on when you call SpecificRow(1).Address. Again, the default property of Range is Range.Item(), so you're specifying a row again - not a column. SpecificRow(1).Address is exactly the same thing as SpecificRow.Item(RowIndex:=1).Address.

The oddity in Excel appears to be that the Range returned by Range.Rows "forgets" the fact that it was called within the context of a Rows call and doesn't suppress the column indexer anymore. Remember from the typelib definition above that the object returned is just a pointer back to the original Range object. That means SpecificRow(2) "leaks" out of the narrowed context.

All things considered, I'd say the Excel Rows implementation is somewhat of a hack. Application.Intersect(SpecificRow, SpecificRow) is apparently giving you back a new "hard" Range object, but the last 2 lines of code are not what you should consider "correct" behavior. Again, when you provide only the first parameter to Range.Items, it is declared as the RowIndex:

Still the RowIndex here too

What appears to happen is that Excel determines that there is only one row in the Range at this point and just assumes that the single parameter passed is a ColumnIndex.

As pointed out by @CallumDA, you can avoid all of this squirrelly behavior by not relying on default properties at all and explicitly providing all of the indexes that you need, i.e.:

Debug.Print SpecificRow.Item(1, 1).Address
'...or...
Debug.Print SpecificRow.Cells(1, 1).Address
Comintern
  • 21,855
  • 5
  • 33
  • 80
  • 1
    This answer is kind of misleading and therefore looks wrong, and it takes quite some time to understand that it is ultimately correct, even though it kind of arrives at the right conclusion through wrong arguments (i.e. it is correct that `SourceRng.Rows(i)` resolves as `SourceRng.Rows.[_Default](i)` which [apparently](http://stackoverflow.com/q/32996772/11683) then calls `SourceRng.Rows.Item(i)`, but it should not matter as soon as the result is returned as an object). – GSerg Jan 03 '17 at 17:44
  • The actually correct conclusion here is that a range remembers in what kind of "child items" it's going to count in when someone uses its `Item` property. A range created via a call to `Rows` treats the only parameter of `Item` as the row number, a range created via `Columns` uses it as a column number, and a range created by neither of the two uses it as a cell number. (E.g. with `Set rc = Me.Range("A1:D5").Columns(2)`, `rc(1).Address` is `$B$1:$B$5` and `rc(2).Address` is `$C$1:$C$5`.) – GSerg Jan 03 '17 at 17:46
  • @GSerg - It *shouldn't* matter as soon as the result is returned. The point is that it *does*. The ***conclusion*** is that you shouldn't rely on Excel to figure out what you want - you should ***explicitly*** pass the parameters that you need. The weird/buggy behavior seems to be because of the `.Rows` implementation. – Comintern Jan 03 '17 at 17:54
  • It shouldn't and it does not matter (that is, it does not matter that default properties are involved). What matters is that `Range` apparently has a private property `CountIn` that can be `Cells`, `Rows` or `Columns`. It does not appear to be a bug because it is consistent across these three modes, with the `Cells` mode being default. It is also not about parameters, because the `Range.Cells` is a parameterless property too, just like `Rows`, and the only parameter you pass to `r.Cells(i)` in fact goes to `r.Cells.Item(i)`. – GSerg Jan 03 '17 at 18:02
  • @GSerg - Except that it isn't consistently *setting* that mode as demonstrated by the OP's code. I'm not sure what `.Cells` has to do with this - I wouldn't call ever recommend calling `.Cells` with only one parameter either, for exactly the reason you point out. – Comintern Jan 03 '17 at 18:10
  • `Range` has to support one parameter when indexing, otherwise it would not be iterable. I've posted my own answer as I believe our logic is different after all. – GSerg Jan 03 '17 at 18:46
3

This is how I would work with rows and specific cells within those rows. The only real difference is the use of .Cells():

Sub WorkingWithRows()
    Dim rng As Range, rngRow As Range
    Set rng = Sheet1.Range("A1:C3")

    For Each rngRow In rng.Rows
        Debug.Print rngRow.Cells(1, 1).Address
        Debug.Print rngRow.Cells(1, 2).Address
        Debug.Print rngRow.Cells(1, 3).Address
    Next rngRow
End Sub

which returns:

$A$1
$B$1
$C$1
$A$2
$B$2
$C$2
$A$3
$B$3
$C$3

As you would expect

CallumDA
  • 12,025
  • 6
  • 30
  • 52
2

I cannot find any proper documentation on this, but this observed behaviour actually appears to be very logical.

The Range class in Excel has two important properties:

  • A single instance of Range is enough to represent any possible range on a sheet
  • It is iterable (can be used in a For Each loop)

I believe that in order to achieve logically looking iterability and yet avoid creating unnecessary entities (i.e. separate classes like CellsCollection, RowsCollection and ColumnsCollection), the Excel developers came up with a design where each instance of Range holds a private property that tells it in which units it is going to count itself (so that one range could be "a collection of rows" and another range could be "a collection of cells").

This property is set to (say) "rows" when you create a range via the Rows property, to (say) "columns" when you create a range via the Columns property, and to (say) "cells" when you create a range in any other way.

This allows you to do this and not become unnecessarily surprised:

For Each r In SomeRange.Rows
  ' will iterate through rows
Next
For Each c In SomeRange.Columns
  ' will iterate through columns
Next

Both Rows and Columns here return the same type, Range, that refers to the exactly same sheet area, and yet the For Each loop iterates via rows in the first case and via columns in the second, as if Rows and Columns returned two different types (RowsCollection and ColumnsCollection).

It makes sense that it was designed this way, because the important property of a For Each loop is that it cannot provide multiple parameters to a Range object in order to fetch the next item (cell, row, or column). In fact, For Each cannot provide any parameters at all, it can only ask "Next one please."

To support that, the Range class had to be able to give the next "something" without parameters, even though a range is two-dimensional and needs two coordinates to fetch the "something." Which is why each instance of Range has to remember in what units it will be counting itself.

A side effect of that design is that it is perfectly fine to look up "somethings" in a Range providing only one coordinate. This is exactly what the For Each mechanism would do, we are just directly jumping to the ith item.
When iterating over (or indexing into) a range returned by Rows, we're going to get the ith row, from top to bottom; for a range returned by Columns we're getting the ith column, from left to right; and for a range returned by Cells or by any other method we're going to get the ith cell, counting from top left corner to the right and then to the bottom.

Another side effect of this design is that can "step out" of a range in a meaningful way. That is, if you have a range of three cells, and you ask for the 4th cell, you still get it, and it will be the cell dictated by the shape of the range and the units it's counting itself in:

Dim r As Range
Set r = Range("A1:C3")          ' Contains 9 cells

Debug.Print r.Cells(12).Address ' $C$4 - goes outside of the range but maintains its shape

So your workaround of Set SpecificRow = Intersect(SpecificRow, SpecificRow) resets the internal counting mode of that specific Range instance from (say) "rows" to (say) "cells".

You could have achieved the same with

Set SpecificRow = SpecificRow.Cells
MsgBox SpecificRow(1).Address

But it's better to keep the Cells close to the point of usage rather than the point of range creation:

MsgBox SpecificRow.Cells(1).Address
GSerg
  • 76,472
  • 17
  • 159
  • 346
  • This is incorrect. `For Each` syntax with objects doesn't use an indexed interface - it calls `_NewEnum` on the returned interface. In the case of `For Each r In SomeRange.Rows`, it would be functionally equivalent to `Do While SomeRange.Rows._NewEnum Is Not Nothing`. This is on the `IRange` interface of the return value of `.Rows` and is also the reason that you get an error if you try to `For Each` anything that doesn't have a `_NewEnum` member. – Comintern Jan 03 '17 at 19:07
  • @Comintern I'm not saying `for each` uses an indexed interface. I'm saying that the fact that `Range` supports `for each` means it does not need all parameters for indexing by design. When I say `exactly what the For Each mechanism would do` I mean that the observed effect is the same - by supplying the only parameter `i` for `Item` we immediately arrive at the item that would have been returned by `For Each` on the `i`th iteration. – GSerg Jan 03 '17 at 19:11
  • Well, the fact that it doesn't need all parameters by design is obvious in that both of the index parameters are optional - in the OP's code, `For Each r In SourceRng.Rows(i): Debug.Print r.Address: Next` would return 1 result. That doesn't explain why `Debug.Print SpecificRow(2).Address` would return `$A$4:$D$4`. The object you call `._NewEnum` on is free to return *anything*, so the fact that it only yields a single Range in this case and `SpecificRow(2)` returns something ***outside*** that Range points to an implementation issue. – Comintern Jan 03 '17 at 19:18
  • @Comintern At that point `SpecificRow` contains an instance of `Range` that refers to `A3:D3` and counts itself in `"rows"`, so providing `1` or `2` for the indexer does the expected and returns rows (as opposed to cells or columns). Being able to go outside of a range's boundaries requires the internal mode (to know *how* to go outside), but the internal mode does not *make* you go outside. The developers could raise an error instead, it was their decision to allow going outside. And as they are allowing it, they are making it work as if `For Each` was allowed to count over the limits. – GSerg Jan 03 '17 at 19:36
  • @Comintern I'm not saying or thinking that "going outside" or indexing actually iterates like `For Each`. On contrary, I believe the enumerator returned by range ultimately calls `Item(i)` and maintains the `i`. – GSerg Jan 03 '17 at 19:38
  • Except that the behavior of the OP's code disproves that. The range is question is `SourceRng.Rows(i)` for both `For Each r In SpecificRow` ***and*** `MsgBox SpecificRow(2).Address`, but they exhibit different behavior. If the implementation of `_NewEnum` and `Item(i)` are the same, why is the result different? – Comintern Jan 03 '17 at 19:43
  • @Comintern I do not see a contradiction. The OP is not using `For Each` anywhere; `For Each r In SpecificRow` only prints `$A$3:$D$3` (correct) (it's as if the enumerator called `.Item(1)`); `SpecificRow(2).Address` returns `$A$4:$D$4` (correct) (it's `.Item(2)`). Again, the enumerator calls into `Item()`, not the other way round. The enumerator will *not try* to go outside of the range boundaries (even though `Range` supports that), but *you* can. – GSerg Jan 03 '17 at 19:56
  • @Comintern and GSerg thank you both for your great insight. It seems that you both wanted to explain the same but I felt GSerg answer slightly bit easier to understand. As you both suggested, I will keep using the Cells parameter to avoid any confusing. Again I'm sorry I did not do since I thought it was superfluous. – Kersijus Jan 04 '17 at 08:39